diff --git a/README.md b/README.md index fbd8ed4..d6544ec 100644 --- a/README.md +++ b/README.md @@ -16,13 +16,15 @@ For full documentation, please visit our [dedicated](https://armis-python-sdk.re ## Usage -All interaction with the SDK happens through the `ArmisSdk` class. You'll need three things: +All interaction with the SDK happens through the `ArmisSdk` class. You'll need five things: -1. **Tenant name**: The name of the tenant you want to interact with. -2. **Secret key**: The secret key associated with the tenant, obtained from the tenant itself. -3. **Client id**: A unique identifier for your application. Currently, this can be any string. +1. **Audience**: The url of the tenant you want to interact with, including trailing slash (e.g. `https://acme.armis.com/`). +2. **Client ID**: The email address of the user account within the tenant that was used to generate the Client Secret. +3. **Client Secret**: The confidential credential generated by your customer within Armis, paired with the Client ID. +4. **Vendor ID**: An identifier unique to your developer account or integration, obtained when you register on our developer portal. +5. **Scopes**: The specific permissions required by your access token to interact with the desired API endpoints. -You can either provide these values using the environment variables `ARMIS_TENANT`, `ARMIS_SECRET_KEY`, and `ARMIS_CLIENT_ID`: +You can either provide these values using the environment variables `ARMIS_AUDIENCE`, `ARMIS_CLIENT_ID`, `ARMIS_CLIENT_SECRET`, `ARMIS_VENDOR_ID`, and `ARMIS_CLIENT_ID`: ```python from armis_sdk import ArmisSdk @@ -32,16 +34,30 @@ armis_sdk = ArmisSdk() or by passing them explicitly: ```python from armis_sdk import ArmisSdk - -armis_sdk = ArmisSdk(tenant="", secret_key="", client_id="") +from armis_sdk import ClientCredentials + +credentials = ClientCredentials( + audience="", + client_id="", + client_secret="", + vendor_id="", + scopes=["scope1", "scope2"], +) +armis_sdk = ArmisSdk(credentials=credentials) ``` > [!TIP] -> If you're building an application that interacts with multiple tenants, you can populate only the `ARMIS_CLIENT_ID` environment variable and pass the `tenant` and `secret_key` explicitly: +> If you're building an application that interacts with multiple tenants, you can populate only the `ARMIS_VENDOR_ID` and `ARMIS_SCOPES` environment variable and pass the `audience`, `client_id` and `client_secret` explicitly: > ```python > from armis_sdk import ArmisSdk +> from armis_sdk import ClientCredentials > -> armis_sdk = ArmisSdk(tenant="", secret_key="") +> credentials = ClientCredentials( +> audience="", +> client_id="", +> client_secret="", +> ) +> armis_sdk = ArmisSdk(credentials=credentials) > ``` ## Entity clients @@ -63,7 +79,7 @@ from armis_sdk.entities.site import Site armis_sdk = ArmisSdk() async def main(): - site = Site(id="1", location="new location") + site = Site(id=1, location="new location") await armis_sdk.sites.update(site) asyncio.run(main()) diff --git a/armis_sdk/__init__.py b/armis_sdk/__init__.py index 744e4b5..67329af 100644 --- a/armis_sdk/__init__.py +++ b/armis_sdk/__init__.py @@ -1 +1,2 @@ from armis_sdk.core.armis_sdk import ArmisSdk +from armis_sdk.core.client_credentials import ClientCredentials diff --git a/armis_sdk/clients/sites_client.py b/armis_sdk/clients/sites_client.py index e8a8d4a..b6a10f7 100644 --- a/armis_sdk/clients/sites_client.py +++ b/armis_sdk/clients/sites_client.py @@ -3,8 +3,6 @@ import universalasync -from armis_sdk.clients.network_equipment_client import NetworkEquipmentClient -from armis_sdk.clients.site_integrations_client import SiteIntegrationsClient from armis_sdk.core import response_utils from armis_sdk.core.armis_error import ArmisError from armis_sdk.core.base_entity_client import BaseEntityClient @@ -18,17 +16,8 @@ class SitesClient(BaseEntityClient): A client for interacting with sites. The primary entity for this client is [Site][armis_sdk.entities.site.Site]. - - Attributes: - network_equipment_client (NetworkEquipmentClient): An instance of [NetworkEquipmentClient][armis_sdk.clients.network_equipment_client.NetworkEquipmentClient] - site_integrations_client (SiteIntegrationsClient): An instance of [SiteIntegrationsClient][armis_sdk.clients.site_integrations_client.SiteIntegrationsClient] """ - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.network_equipment_client = NetworkEquipmentClient(self._armis_client) - self.site_integrations_client = SiteIntegrationsClient(self._armis_client) - async def create(self, site: Site) -> Site: """Create a `Site`. @@ -69,21 +58,13 @@ async def main(): raise ArmisError("Can't create a site without a name.") payload = site.model_dump( - by_alias=True, - exclude={"children", "network_equipment_device_ids"}, + exclude={"children"}, exclude_none=True, ) async with self._armis_client.client() as client: - response = await client.post("/api/v1/sites/", json=payload) + response = await client.post("/v3/settings/sites", json=payload) data = response_utils.get_data_dict(response) - created_site = site.model_copy(update={"id": int(data["id"])}, deep=True) - - if site.network_equipment_device_ids: - await self.network_equipment_client.add( - created_site, site.network_equipment_device_ids - ) - - return created_site + return Site.model_validate(data) async def delete(self, site: Site): """Delete a `Site`. @@ -112,10 +93,10 @@ async def main(): raise ArmisError("Can't delete a site without an id.") async with self._armis_client.client() as client: - response = await client.delete(f"/api/v1/sites/{site.id}/") + response = await client.delete(f"/v3/settings/sites/{site.id}") response_utils.raise_for_status(response) - async def get(self, site_id: str) -> Site: + async def get(self, site_id: int) -> Site: """Get a `Site` by its ID. Args: @@ -144,7 +125,7 @@ async def main(): ``` """ async with self._armis_client.client() as client: - response = await client.get(f"/api/v1/sites/{site_id}/") + response = await client.get(f"/v3/settings/sites/{site_id}") data = response_utils.get_data_dict(response) return Site.model_validate(data) @@ -218,10 +199,10 @@ async def main(): Site(id=2) ``` """ - async for item in self._list("/api/v1/sites/", "sites", Site): + async for item in self._list("/v3/settings/sites", Site): yield item - async def update(self, site: Site): + async def update(self, site: Site) -> Site: """Update a site's properties. Args: @@ -253,23 +234,14 @@ async def main(): ) data = site.model_dump( - by_alias=True, - exclude={ - "children", - "id", - "integration_ids", - "network_equipment_device_ids", - }, + exclude={"children", "id"}, exclude_none=True, ) - if data: - async with self._armis_client.client() as client: - response = await client.patch(f"/api/v1/sites/{site.id}/", json=data) - response_utils.raise_for_status(response) - - if site.network_equipment_device_ids is not None: - await self.network_equipment_client.update(site) + if not data: + return site - if site.integration_ids is not None: - await self.site_integrations_client.update(site) + async with self._armis_client.client() as client: + response = await client.patch(f"/v3/settings/sites/{site.id}", json=data) + data = response_utils.get_data_dict(response) + return Site.model_validate(data) diff --git a/armis_sdk/core/armis_auth.py b/armis_sdk/core/armis_auth.py index d181e67..3596f3f 100644 --- a/armis_sdk/core/armis_auth.py +++ b/armis_sdk/core/armis_auth.py @@ -6,6 +6,7 @@ from armis_sdk.core import response_utils from armis_sdk.core.armis_error import ArmisError +from armis_sdk.core.client_credentials import ClientCredentials AUTHORIZATION = "Authorization" @@ -17,16 +18,16 @@ class ArmisAuth(httpx.Auth): 1. Before performing any request check if there's a valid access token. 2. If there is, use it with the `Authorization` header. - 3. If there isn't, make a POST request to `/api/v1/access_token/` + 3. If there isn't, make a POST request to `/v3/oauth/token` to generate a new access token. 4. Save the new access token and also use it with the `Authorization` header. """ requires_response_body = True - def __init__(self, base_url: str, secret_key: str): + def __init__(self, base_url: str, credentials: ClientCredentials): self._base_url = base_url - self._secret_key = secret_key + self._credentials = credentials self._access_token: Optional[str] = None self._expires_at: Optional[datetime.datetime] = None @@ -36,7 +37,7 @@ def auth_flow( if ( self._access_token is None or self._expires_at is None - or self._expires_at < datetime.datetime.now(datetime.timezone.utc) + or self._expires_at < datetime.datetime.now() ): access_token_response = yield self._build_access_token_request() self._update_access_token(access_token_response) @@ -46,24 +47,33 @@ def auth_flow( "Something went wrong, there is no access token available." ) - request.headers[AUTHORIZATION] = self._access_token + request.headers[AUTHORIZATION] = f"Bearer {self._access_token}" response = yield request if response.status_code == httpx.codes.UNAUTHORIZED: access_token_response = yield self._build_access_token_request() self._update_access_token(access_token_response) - request.headers[AUTHORIZATION] = self._access_token + request.headers[AUTHORIZATION] = f"Bearer {self._access_token}" yield request def _build_access_token_request(self): return httpx.Request( "POST", - f"{self._base_url}/api/v1/access_token/", - json={"secret_key": self._secret_key}, + f"{self._base_url}/v3/oauth/token", + json={ + "grant_type": "client_credentials", + "vendor_id": self._credentials.vendor_id, + "audience": self._credentials.audience, + "client_id": self._credentials.client_id, + "client_secret": self._credentials.client_secret, + "scopes": self._credentials.scopes, + }, ) def _update_access_token(self, response: httpx.Response): data = response_utils.get_data_dict(response) self._access_token = data["access_token"] - self._expires_at = datetime.datetime.fromisoformat(data["expiration_utc"]) + self._expires_at = datetime.datetime.now() + datetime.timedelta( + seconds=data["expires_in"] + ) diff --git a/armis_sdk/core/armis_client.py b/armis_sdk/core/armis_client.py index 373692a..be27cda 100644 --- a/armis_sdk/core/armis_client.py +++ b/armis_sdk/core/armis_client.py @@ -12,16 +12,17 @@ from armis_sdk.core import response_utils from armis_sdk.core.armis_auth import ArmisAuth +from armis_sdk.core.client_credentials import ClientCredentials -ARMIS_BASE_DOMAIN = "ARMIS_BASE_DOMAIN" +API_BASE_URL = "https://api.armis.com" ARMIS_CLIENT_ID = "ARMIS_CLIENT_ID" +ARMIS_CLIENT_SECRET = "ARMIS_CLIENT_SECRET" ARMIS_PAGE_SIZE = "ARMIS_PAGE_SIZE" ARMIS_REQUEST_BACKOFF = "ARMIS_REQUEST_BACKOFF" ARMIS_REQUEST_RETRIES = "ARMIS_REQUEST_RETRIES" -ARMIS_SECRET_KEY = "ARMIS_SECRET_KEY" -ARMIS_TENANT = "ARMIS_TENANT" -BASE_DOMAIN = "armis.com" -BASE_URL = "https://{tenant}.{base_domain}" +ARMIS_SCOPES = "ARMIS_SCOPES" +ARMIS_AUDIENCE = "ARMIS_AUDIENCE" +ARMIS_VENDOR_ID = "ARMIS_VENDOR_ID" DEFAULT_PAGE_LENGTH = 1000 try: VERSION = importlib.metadata.version("armis_sdk") @@ -47,38 +48,10 @@ class ArmisClient: # pylint: disable=too-few-public-methods 4. Proxy configuration via HTTPS_PROXY and HTTP_PROXY environment variables. """ - def __init__( - self, - tenant: Optional[str] = None, - secret_key: Optional[str] = None, - client_id: Optional[str] = None, - base_domain: Optional[str] = BASE_DOMAIN, - ): - tenant = os.getenv(ARMIS_TENANT, tenant) - secret_key = os.getenv(ARMIS_SECRET_KEY, secret_key) - client_id = os.getenv(ARMIS_CLIENT_ID, client_id) - base_domain = os.getenv(ARMIS_BASE_DOMAIN, base_domain) - - if not tenant: - raise ValueError( - f"Either populate the {ARMIS_TENANT!r} environment variable " - f"or pass an explicit value to the constructor" - ) - if not secret_key: - raise ValueError( - f"Either populate the {ARMIS_SECRET_KEY!r} environment variable " - f"or pass an explicit value to the constructor" - ) - if not client_id: - raise ValueError( - f"Either populate the {ARMIS_CLIENT_ID!r} environment variable " - f"or pass an explicit value to the constructor" - ) - - self._base_url = BASE_URL.format(tenant=tenant, base_domain=base_domain) - self._auth = ArmisAuth(self._base_url, secret_key) + def __init__(self, credentials: Optional[ClientCredentials] = None): + credentials = self._get_credentials(credentials) + self._auth = ArmisAuth(API_BASE_URL, credentials) self._user_agent = " ".join(USER_AGENT_PARTS) - self._client_id = client_id try: self._default_retries = int(os.getenv(ARMIS_REQUEST_RETRIES, "3")) except ValueError: @@ -101,21 +74,19 @@ def client(self, retries: Optional[int] = None, backoff: Optional[float] = None) return httpx.AsyncClient( auth=self._auth, - base_url=self._base_url, + base_url=API_BASE_URL, headers={ "User-Agent": self._user_agent, - "Armis-API-Client-Id": self._client_id, }, transport=transport, trust_env=True, ) - async def list(self, url: str, key: str) -> AsyncIterator[dict]: + async def list(self, url: str) -> AsyncIterator[dict]: """List all items from a paginated endpoint. Args: url (str): The relative endpoint URL. - key (str): The key inside the data object that contains the items. Returns: An (async) iterator of `dict`s. @@ -129,7 +100,7 @@ async def list(self, url: str, key: str) -> AsyncIterator[dict]: async def main(): armis_client = ArmisClient() - async for item in armis_client.list("/api/v1/sites/", "sites"): + async for item in armis_client.list("/v3/settings/sites"): print(item) asyncio.run(main()) @@ -142,16 +113,63 @@ async def main(): """ page_size = int(os.getenv(ARMIS_PAGE_SIZE, str(DEFAULT_PAGE_LENGTH))) async with self.client() as client: - from_ = 0 - while from_ is not None: - params = {"from": from_, "length": page_size} + params = {"limit": page_size} + while True: response = await client.get(url, params=params) data = response_utils.get_data_dict(response) - items = data[key] + items = data["items"] for item in items: yield item - from_ = data.get("next") + if next_ := data.get("next"): + params["after"] = next_ + else: + break + + @classmethod + def _get_credentials( + cls, credentials: Optional[ClientCredentials] + ) -> ClientCredentials: + credentials = credentials or ClientCredentials() + credentials.vendor_id = credentials.vendor_id or os.getenv(ARMIS_VENDOR_ID) + credentials.audience = credentials.audience or os.getenv(ARMIS_AUDIENCE) + credentials.client_id = credentials.client_id or os.getenv(ARMIS_CLIENT_ID) + credentials.client_secret = credentials.client_secret or os.getenv( + ARMIS_CLIENT_SECRET + ) + env_scopes = os.getenv(ARMIS_SCOPES) + credentials.scopes = credentials.scopes or ( + env_scopes.split(",") if env_scopes else [] + ) + + if not credentials.audience: + raise ValueError( + f"Either populate the {ARMIS_AUDIENCE!r} environment variable " + "or pass an explicit value to the ClientCredentials class" + ) + if not credentials.client_id: + raise ValueError( + f"Either populate the {ARMIS_CLIENT_ID!r} environment variable " + "or pass an explicit value to the ClientCredentials class" + ) + if not credentials.client_secret: + raise ValueError( + f"Either populate the {ARMIS_CLIENT_SECRET!r} environment variable " + "or pass an explicit value to the ClientCredentials class" + ) + if not credentials.scopes: + raise ValueError( + f"Either populate the {ARMIS_SCOPES!r} environment variable " + "or pass an explicit value to the ClientCredentials class" + ) + if not credentials.vendor_id: + raise ValueError( + f"Either populate the {ARMIS_VENDOR_ID!r} environment variable " + "or pass an explicit value to the ClientCredentials class" + ) + + return credentials - def _get_proxy_config(self): + @classmethod + def _get_proxy_config(cls): """Get proxy configuration from environment variables.""" return os.getenv("HTTPS_PROXY") or os.getenv("HTTP_PROXY") diff --git a/armis_sdk/core/armis_error.py b/armis_sdk/core/armis_error.py index d32a2df..530c95b 100644 --- a/armis_sdk/core/armis_error.py +++ b/armis_sdk/core/armis_error.py @@ -5,8 +5,20 @@ from typing import List from typing import Optional +from typing import Union from httpx import HTTPStatusError +from pydantic import BaseModel + + +class DetailItem(BaseModel): + loc: list[Union[str, int]] + msg: str + type: str + + +class ErrorBody(BaseModel): + detail: Union[str, List[DetailItem]] class ArmisError(Exception): @@ -22,9 +34,21 @@ class ResponseError(ArmisError): For example, if the server returns 400 for invalid input, an instance of this class will be raised. """ - def __init__(self, *args, response_errors: Optional[List[HTTPStatusError]] = None): - super().__init__(*args) + def __init__( + self, + error_body: ErrorBody, + response_errors: Optional[List[HTTPStatusError]] = None, + ): + super().__init__(self._get_message(error_body)) self.response_errors = response_errors + self.detail = error_body.detail + + @classmethod + def _get_message(cls, error_body: ErrorBody) -> str: + if isinstance(error_body.detail, str): + return error_body.detail + + return "\n".join(item.msg for item in error_body.detail) class AlreadyExistsError(ResponseError): diff --git a/armis_sdk/core/armis_sdk.py b/armis_sdk/core/armis_sdk.py index 07354d9..bd9502a 100644 --- a/armis_sdk/core/armis_sdk.py +++ b/armis_sdk/core/armis_sdk.py @@ -1,8 +1,8 @@ from typing import Optional from armis_sdk.clients.sites_client import SitesClient -from armis_sdk.core import armis_client from armis_sdk.core.armis_client import ArmisClient +from armis_sdk.core.client_credentials import ClientCredentials class ArmisSdk: # pylint: disable=too-few-public-methods @@ -31,17 +31,6 @@ async def main(): ``` """ - def __init__( - self, - tenant: Optional[str] = None, - secret_key: Optional[str] = None, - client_id: Optional[str] = None, - base_domain: Optional[str] = armis_client.BASE_DOMAIN, - ): - self.client = ArmisClient( - tenant=tenant, - client_id=client_id, - secret_key=secret_key, - base_domain=base_domain, - ) - self.sites = SitesClient(self.client) + def __init__(self, credentials: Optional[ClientCredentials] = None): + self.client: ArmisClient = ArmisClient(credentials=credentials) + self.sites: SitesClient = SitesClient(self.client) diff --git a/armis_sdk/core/base_entity_client.py b/armis_sdk/core/base_entity_client.py index 0a32536..0ae2327 100644 --- a/armis_sdk/core/base_entity_client.py +++ b/armis_sdk/core/base_entity_client.py @@ -15,7 +15,7 @@ def __init__(self, armis_client: Optional[ArmisClient] = None) -> None: @universalasync.async_to_sync_wraps async def _list( - self, url: str, key: str, model: Type[BaseEntityT] + self, url: str, model: Type[BaseEntityT] ) -> AsyncIterator[BaseEntityT]: - async for item in self._armis_client.list(url, key): + async for item in self._armis_client.list(url): yield model.model_validate(item) diff --git a/armis_sdk/core/client_credentials.py b/armis_sdk/core/client_credentials.py new file mode 100644 index 0000000..e187d6e --- /dev/null +++ b/armis_sdk/core/client_credentials.py @@ -0,0 +1,11 @@ +import dataclasses +from typing import Optional + + +@dataclasses.dataclass +class ClientCredentials: + audience: Optional[str] = None + client_id: Optional[str] = None + client_secret: Optional[str] = None + vendor_id: Optional[str] = None + scopes: Optional[list[str]] = None diff --git a/armis_sdk/core/response_utils.py b/armis_sdk/core/response_utils.py index d188e50..3732dbf 100644 --- a/armis_sdk/core/response_utils.py +++ b/armis_sdk/core/response_utils.py @@ -1,12 +1,15 @@ +import json from json import JSONDecodeError from typing import Type from typing import TypeVar import httpx from httpx import HTTPStatusError +from pydantic import ValidationError from armis_sdk.core.armis_error import AlreadyExistsError from armis_sdk.core.armis_error import BadRequestError +from armis_sdk.core.armis_error import ErrorBody from armis_sdk.core.armis_error import NotFoundError from armis_sdk.core.armis_error import ResponseError @@ -18,10 +21,12 @@ def get_data( data_type: Type[DataTypeT], ) -> DataTypeT: raise_for_status(response) - parsed = parse_response(response, dict) - data = parsed.get("data") + data = parse_response(response, dict) + if not isinstance(data, data_type): - raise ResponseError("Response data represents neither a dict nor a list.") + detail = "Response data represents neither a dict nor a list." + raise ResponseError(ErrorBody(detail=detail)) + return data @@ -35,12 +40,15 @@ def parse_response( ) -> DataTypeT: try: response_data = response.json() - if isinstance(response_data, data_type): - return response_data - raise ResponseError("Response body represents neither a dict nor a list.") except JSONDecodeError as error: - message = f"Response body is not a valid JSON: {response.text}" - raise ResponseError(message) from error + detail = f"Response body is not a valid JSON: {response.text}" + raise ResponseError(ErrorBody(detail=detail)) from error + + if not isinstance(response_data, data_type): + detail = "Response body represents neither a dict nor a list." + raise ResponseError(ErrorBody(detail=detail)) + + return response_data def raise_for_status(response: httpx.Response): @@ -48,15 +56,18 @@ def raise_for_status(response: httpx.Response): response.raise_for_status() except HTTPStatusError as error: parsed = parse_response(error.response, dict) - message = parsed.get("message", "Something went wrong.") + try: + error_body = ErrorBody.model_validate(parsed) + except ValidationError: + error_body = ErrorBody(detail=json.dumps(parsed)) if error.response.status_code == httpx.codes.NOT_FOUND: - raise NotFoundError(message, response_errors=[error]) from error + raise NotFoundError(error_body, response_errors=[error]) from error if error.response.status_code == httpx.codes.BAD_REQUEST: - raise BadRequestError(message, response_errors=[error]) from error + raise BadRequestError(error_body, response_errors=[error]) from error if error.response.status_code == httpx.codes.CONFLICT: - raise AlreadyExistsError(message, response_errors=[error]) from error + raise AlreadyExistsError(error_body, response_errors=[error]) from error - raise ResponseError(message, response_errors=[error]) from error + raise ResponseError(error_body, response_errors=[error]) from error diff --git a/armis_sdk/entities/site.py b/armis_sdk/entities/site.py index 447724c..2ab5825 100644 --- a/armis_sdk/entities/site.py +++ b/armis_sdk/entities/site.py @@ -67,7 +67,7 @@ class Site(BaseEntity): tier: Optional[str] = None """The tier of the site.""" - asq_rule: Optional[AsqRule] = Field(alias="rule", default=None) + asq_rule: Optional[AsqRule] = Field(default=None) """The ASQ rule of the site.""" network_equipment_device_ids: Annotated[ diff --git a/tests/armis_sdk/clients/sites_client_test.py b/tests/armis_sdk/clients/sites_client_test.py index bd3fe6c..7ba5842 100644 --- a/tests/armis_sdk/clients/sites_client_test.py +++ b/tests/armis_sdk/clients/sites_client_test.py @@ -11,20 +11,23 @@ async def test_create(httpx_mock: pytest_httpx.HTTPXMock): httpx_mock.add_response( - url="https://mock_tenant.armis.com/api/v1/sites/", + url="https://api.armis.com/v3/settings/sites", method="POST", match_json={ "name": "mock_site", "location": "mock_location", - "parentId": 2, - "integrationIds": [4, 5, 6], + "parent_id": 2, + "network_equipment_device_ids": [1, 2, 3], + "integration_ids": [4, 5, 6], + }, + json={ + "id": "1", + "name": "mock_site", + "location": "mock_location", + "parent_id": 2, + "network_equipment_device_ids": [1, 2, 3], + "integration_ids": [4, 5, 6], }, - json={"data": {"id": "1"}}, - ) - httpx_mock.add_response( - url="https://mock_tenant.armis.com/api/v1/sites/1/network-equipment/_bulk/", - method="POST", - match_json={"networkEquipmentDeviceIds": [1, 2, 3]}, ) site_to_create = Site( @@ -74,7 +77,7 @@ async def test_create_without_name(httpx_mock: pytest_httpx.HTTPXMock): async def test_delete(httpx_mock: pytest_httpx.HTTPXMock): httpx_mock.add_response( - url="https://mock_tenant.armis.com/api/v1/sites/1/", method="DELETE" + url="https://api.armis.com/v3/settings/sites/1", method="DELETE" ) site = Site(id=1) @@ -95,16 +98,14 @@ async def test_delete_without_id(httpx_mock: pytest_httpx.HTTPXMock): async def test_get(httpx_mock: pytest_httpx.HTTPXMock): httpx_mock.add_response( - url="https://mock_tenant.armis.com/api/v1/sites/1/", + url="https://api.armis.com/v3/settings/sites/1", method="GET", json={ - "data": { - "id": "1", - "name": "mock_site_1", - "ruleAql": '{"or": ["asq1", "asq2"]}', - "networkEquipmentDeviceIds": ["1", "2", "3"], - "integrationIds": ["4", "5", "6"], - } + "id": "1", + "name": "mock_site_1", + "ruleAql": '{"or": ["asq1", "asq2"]}', + "network_equipment_device_ids": ["1", "2", "3"], + "integration_ids": ["4", "5", "6"], }, ) @@ -122,20 +123,18 @@ async def test_get(httpx_mock: pytest_httpx.HTTPXMock): async def test_hierarchy(httpx_mock: pytest_httpx.HTTPXMock): httpx_mock.add_response( - url="https://mock_tenant.armis.com/api/v1/sites/?from=0&length=1000", + url="https://api.armis.com/v3/settings/sites?limit=1000", method="GET", json={ - "data": { - "sites": [ - {"id": "1", "name": "mock_site_1"}, - {"id": "2", "name": "mock_site_2", "parentId": "1"}, - {"id": "3", "name": "mock_site_3", "parentId": "1"}, - {"id": "4", "name": "mock_site_4", "parentId": "2"}, - {"id": "5", "name": "mock_site_5"}, - {"id": "6", "name": "mock_site_6", "parentId": "5"}, - {"id": "7", "name": "mock_site_7", "parentId": "999"}, - ] - } + "items": [ + {"id": "1", "name": "mock_site_1"}, + {"id": "2", "name": "mock_site_2", "parent_id": "1"}, + {"id": "3", "name": "mock_site_3", "parent_id": "1"}, + {"id": "4", "name": "mock_site_4", "parent_id": "2"}, + {"id": "5", "name": "mock_site_5"}, + {"id": "6", "name": "mock_site_6", "parent_id": "5"}, + {"id": "7", "name": "mock_site_7", "parent_id": "999"}, + ], }, ) sites_client = SitesClient() @@ -203,10 +202,10 @@ async def test_hierarchy(httpx_mock: pytest_httpx.HTTPXMock): "lat": 1.23, "lng": 4.56, "location": "mock_location", - "parentId": "1", + "parent_id": "1", "tier": "mock_tier", - "integrationIds": ["4", "5", "6"], - "networkEquipmentDeviceIds": ["7", "8", "9"], + "integration_ids": ["4", "5", "6"], + "network_equipment_device_ids": ["7", "8", "9"], }, Site( id=2, @@ -225,9 +224,9 @@ async def test_hierarchy(httpx_mock: pytest_httpx.HTTPXMock): ) async def test_list_sites(from_response, expected, httpx_mock: pytest_httpx.HTTPXMock): httpx_mock.add_response( - url="https://mock_tenant.armis.com/api/v1/sites/?from=0&length=1000", + url="https://api.armis.com/v3/settings/sites?limit=1000", method="GET", - json={"data": {"sites": [from_response]}}, + json={"items": [from_response]}, ) sites_client = SitesClient() @@ -241,41 +240,35 @@ async def test_list_sites_with_multiple_pages( ): monkeypatch.setenv("ARMIS_PAGE_SIZE", "2") httpx_mock.add_response( - url="https://mock_tenant.armis.com/api/v1/sites/?from=0&length=2", + url="https://api.armis.com/v3/settings/sites?limit=2", method="GET", json={ - "data": { - "next": 2, - "sites": [ - {"id": "1", "name": "mock_site_1"}, - {"id": "2", "name": "mock_site_2"}, - ], - } + "next": 2, + "items": [ + {"id": "1", "name": "mock_site_1"}, + {"id": "2", "name": "mock_site_2"}, + ], }, ) httpx_mock.add_response( - url="https://mock_tenant.armis.com/api/v1/sites/?from=2&length=2", + url="https://api.armis.com/v3/settings/sites?after=2&limit=2", method="GET", json={ - "data": { - "next": 4, - "sites": [ - {"id": "3", "name": "mock_site_3"}, - {"id": "4", "name": "mock_site_4"}, - ], - } + "next": 4, + "items": [ + {"id": "3", "name": "mock_site_3"}, + {"id": "4", "name": "mock_site_4"}, + ], }, ) httpx_mock.add_response( - url="https://mock_tenant.armis.com/api/v1/sites/?from=4&length=2", + url="https://api.armis.com/v3/settings/sites?after=4&limit=2", method="GET", json={ - "data": { - "next": None, - "sites": [ - {"id": "5", "name": "mock_site_5"}, - ], - } + "next": None, + "items": [ + {"id": "5", "name": "mock_site_5"}, + ], }, ) @@ -296,75 +289,31 @@ async def test_update_with_nothing_to_change(httpx_mock: pytest_httpx.HTTPXMock) sites_client = SitesClient() site = Site(id=1) - await sites_client.update(site) + updated_site = await sites_client.update(site) + assert updated_site == site async def test_update_simple_properties(httpx_mock: pytest_httpx.HTTPXMock): httpx_mock.add_response( - url="https://mock_tenant.armis.com/api/v1/sites/1/", + url="https://api.armis.com/v3/settings/sites/1", method="PATCH", - match_json={"name": "new_name", "location": "new location", "parentId": 2}, + match_json={"name": "new_name", "location": "new location", "parent_id": 2}, + json={ + "id": 1, + "name": "new_name", + "location": "new location", + "parent_id": 2, + }, ) sites_client = SitesClient() site = Site(id=1, name="new_name", location="new location", parent_id=2) - await sites_client.update(site) - - -async def test_update_with_network_equipment_device_ids( - httpx_mock: pytest_httpx.HTTPXMock, -): - # List current ids - httpx_mock.add_response( - url="https://mock_tenant.armis.com/api/v1/sites/1/network-equipment/", - method="GET", - json={"data": {"networkEquipmentDeviceIds": []}}, - ) - - # Add new ids - httpx_mock.add_response( - url="https://mock_tenant.armis.com/api/v1/sites/1/network-equipment/_bulk/", - method="POST", - match_json={"networkEquipmentDeviceIds": [1, 2, 3]}, + updated_site = await sites_client.update(site) + assert updated_site == Site( + id=1, name="new_name", location="new location", parent_id=2 ) - sites_client = SitesClient() - site = Site(id=1, network_equipment_device_ids=[1, 2, 3]) - - await sites_client.update(site) - - -async def test_update_with_integration_id(httpx_mock: pytest_httpx.HTTPXMock): - # List current ids - httpx_mock.add_response( - url="https://mock_tenant.armis.com/api/v1/sites/1/integrations-ids/", - method="GET", - json={"data": {"integrationIds": []}}, - ) - - # Add new ids - httpx_mock.add_response( - url="https://mock_tenant.armis.com/api/v1/sites/1/integrations-ids/", - method="POST", - match_json={"integrationId": 1}, - ) - httpx_mock.add_response( - url="https://mock_tenant.armis.com/api/v1/sites/1/integrations-ids/", - method="POST", - match_json={"integrationId": 2}, - ) - httpx_mock.add_response( - url="https://mock_tenant.armis.com/api/v1/sites/1/integrations-ids/", - method="POST", - match_json={"integrationId": 3}, - ) - - sites_client = SitesClient() - site = Site(id=1, integration_ids=[1, 2, 3]) - - await sites_client.update(site) - async def test_update_without_id(httpx_mock: pytest_httpx.HTTPXMock): httpx_mock.reset() diff --git a/tests/armis_sdk/core/armis_client_test.py b/tests/armis_sdk/core/armis_client_test.py index 2f7e732..d06cf7d 100644 --- a/tests/armis_sdk/core/armis_client_test.py +++ b/tests/armis_sdk/core/armis_client_test.py @@ -23,9 +23,8 @@ async def test_request_headers(httpx_mock: pytest_httpx.HTTPXMock): f"python-httpx/{httpx.__version__} " f"ArmisPythonSDK/v{VERSION}" ), - "Armis-API-Client-Id": "mock_client_id", }, - url="https://mock_tenant.armis.com/mock/endpoint", + url="https://api.armis.com/mock/endpoint", ) armis_client = ArmisClient() @@ -37,15 +36,15 @@ async def test_retries(monkeypatch, httpx_mock: pytest_httpx.HTTPXMock): monkeypatch.setenv("ARMIS_REQUEST_RETRIES", "2") monkeypatch.setenv("ARMIS_REQUEST_BACKOFF", "0") httpx_mock.add_response( - url="https://mock_tenant.armis.com/mock/endpoint", + url="https://api.armis.com/mock/endpoint", status_code=httpx.codes.GATEWAY_TIMEOUT, # original request, fails ) httpx_mock.add_response( - url="https://mock_tenant.armis.com/mock/endpoint", + url="https://api.armis.com/mock/endpoint", status_code=httpx.codes.GATEWAY_TIMEOUT, # first retry, fails again ) httpx_mock.add_response( - url="https://mock_tenant.armis.com/mock/endpoint", + url="https://api.armis.com/mock/endpoint", status_code=httpx.codes.OK, # second retry, succeeds ) @@ -61,15 +60,15 @@ async def test_retries_with_eventual_failure( ): monkeypatch.setenv("ARMIS_REQUEST_RETRIES", "2") httpx_mock.add_response( - url="https://mock_tenant.armis.com/mock/endpoint", + url="https://api.armis.com/mock/endpoint", status_code=httpx.codes.GATEWAY_TIMEOUT, # original request, fails ) httpx_mock.add_response( - url="https://mock_tenant.armis.com/mock/endpoint", + url="https://api.armis.com/mock/endpoint", status_code=httpx.codes.GATEWAY_TIMEOUT, # first retry, fails again ) httpx_mock.add_response( - url="https://mock_tenant.armis.com/mock/endpoint", + url="https://api.armis.com/mock/endpoint", status_code=httpx.codes.GATEWAY_TIMEOUT, # second retry, fails again ) @@ -86,7 +85,7 @@ async def test_retrie_with_writable_method( monkeypatch.setenv("ARMIS_REQUEST_RETRIES", "2") httpx_mock.add_response( method="POST", - url="https://mock_tenant.armis.com/mock/endpoint", + url="https://api.armis.com/mock/endpoint", status_code=httpx.codes.GATEWAY_TIMEOUT, # original request, fails - shouldn't retry! ) @@ -102,46 +101,40 @@ async def test_list_with_multiple_pages( ): monkeypatch.setenv("ARMIS_PAGE_SIZE", "2") httpx_mock.add_response( - url="https://mock_tenant.armis.com/api/v1/sites/?from=0&length=2", + url="https://api.armis.com/v3/settings/sites?limit=2", method="GET", json={ - "data": { - "next": 2, - "sites": [ - {"id": "1", "name": "mock_site_1"}, - {"id": "2", "name": "mock_site_2"}, - ], - } + "next": 2, + "items": [ + {"id": "1", "name": "mock_site_1"}, + {"id": "2", "name": "mock_site_2"}, + ], }, ) httpx_mock.add_response( - url="https://mock_tenant.armis.com/api/v1/sites/?from=2&length=2", + url="https://api.armis.com/v3/settings/sites?after=2&limit=2", method="GET", json={ - "data": { - "next": 4, - "sites": [ - {"id": "3", "name": "mock_site_3"}, - {"id": "4", "name": "mock_site_4"}, - ], - } + "next": 4, + "items": [ + {"id": "3", "name": "mock_site_3"}, + {"id": "4", "name": "mock_site_4"}, + ], }, ) httpx_mock.add_response( - url="https://mock_tenant.armis.com/api/v1/sites/?from=4&length=2", + url="https://api.armis.com/v3/settings/sites?after=4&limit=2", method="GET", json={ - "data": { - "next": None, - "sites": [ - {"id": "5", "name": "mock_site_5"}, - ], - } + "next": None, + "items": [ + {"id": "5", "name": "mock_site_5"}, + ], }, ) armis_client = ArmisClient() - items = [item async for item in armis_client.list("/api/v1/sites/", "sites")] + items = [item async for item in armis_client.list("/v3/settings/sites")] assert items == [ {"id": "1", "name": "mock_site_1"}, @@ -173,7 +166,7 @@ async def test_proxy(monkeypatch, httpx_mock, env_var, proxy_url, expected_proxy # proxy_url must match, # Order of parameters in the query string does not matter httpx_mock.add_response( - url="https://mock_tenant.armis.com/mock/endpoint", + url="https://api.armis.com/mock/endpoint", proxy_url=expected_proxy, json={"ok": True}, ) diff --git a/tests/plugins/setup_plugin.py b/tests/plugins/setup_plugin.py index 374eb36..8378255 100644 --- a/tests/plugins/setup_plugin.py +++ b/tests/plugins/setup_plugin.py @@ -6,23 +6,29 @@ @pytest.fixture def setup_env_variables(monkeypatch): + monkeypatch.setenv("ARMIS_AUDIENCE", "https://mock.armis.com/") monkeypatch.setenv("ARMIS_CLIENT_ID", "mock_client_id") - monkeypatch.setenv("ARMIS_SECRET_KEY", "mock_secret_key") - monkeypatch.setenv("ARMIS_TENANT", "mock_tenant") + monkeypatch.setenv("ARMIS_CLIENT_SECRET", "mock_client_secret") + monkeypatch.setenv("ARMIS_SCOPES", "ALL") + monkeypatch.setenv("ARMIS_VENDOR_ID", "mock_vendor_id") @pytest.fixture def authorized(httpx_mock: pytest_httpx.HTTPXMock): - expire_at = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta( - minutes=1 - ) httpx_mock.add_response( - url="https://mock_tenant.armis.com/api/v1/access_token/", + url="https://api.armis.com/v3/oauth/token", method="POST", + match_json={ + "grant_type": "client_credentials", + "vendor_id": "mock_vendor_id", + "audience": "https://mock.armis.com/", + "client_id": "mock_client_id", + "client_secret": "mock_client_secret", + "scopes": ["ALL"], + }, json={ - "data": { - "access_token": "mock_access_token", - "expiration_utc": expire_at.isoformat(), - } + "access_token": "mock_access_token", + "token_type": "Bearer", + "expires_in": datetime.timedelta(minutes=1).total_seconds(), }, )