diff --git a/backend/python/app/sources/client/adobeaem/adobeaem.py b/backend/python/app/sources/client/adobeaem/adobeaem.py new file mode 100644 index 000000000..cc7ccd3be --- /dev/null +++ b/backend/python/app/sources/client/adobeaem/adobeaem.py @@ -0,0 +1,340 @@ +"""Adobe Experience Manager (AEM as Cloud Service) client implementation. + +This module provides a client for interacting with the AEM API using +Bearer token authentication. + +AEM uses instance-based URLs: https://{instance}.adobeaemcloud.com + +API Reference: https://experienceleague.adobe.com/docs/experience-manager-cloud-service/content/implementing/developing/generating-access-tokens-for-server-side-apis.html +""" + +import base64 +import json +import logging +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class AdobeAEMResponse(BaseModel): + """Standardized Adobe AEM API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client class +# --------------------------------------------------------------------------- + + +class AdobeAEMRESTClientViaToken(HTTPClient): + """Adobe AEM REST client via Bearer token. + + Args: + token: Bearer token for authentication + instance: AEM instance identifier (e.g., "author-p12345-e67890") + """ + + def __init__(self, token: str, instance: str) -> None: + super().__init__(token, "Bearer") + self.instance = instance + self.base_url = f"https://{instance}.adobeaemcloud.com" + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL including instance.""" + return self.base_url + + def get_instance(self) -> str: + """Get the AEM instance identifier.""" + return self.instance + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class AdobeAEMTokenConfig(BaseModel): + """Configuration for AEM client via Bearer token. + + Args: + token: Bearer token + instance: AEM instance identifier + """ + + token: str + instance: str + + def create_client(self) -> AdobeAEMRESTClientViaToken: + return AdobeAEMRESTClientViaToken(self.token, self.instance) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class AdobeAEMAuthConfig(BaseModel): + """Auth section of the AEM connector configuration from etcd.""" + + token: str | None = None + apiToken: str | None = None + + class Config: + extra = "allow" + + +class AdobeAEMCredentialsConfig(BaseModel): + """Credentials section of the AEM connector configuration.""" + + access_token: str | None = None + + class Config: + extra = "allow" + + +class AdobeAEMConnectorConfig(BaseModel): + """Top-level AEM connector configuration from etcd.""" + + auth: AdobeAEMAuthConfig = Field(default_factory=AdobeAEMAuthConfig) + credentials: AdobeAEMCredentialsConfig = Field( + default_factory=AdobeAEMCredentialsConfig + ) + instance: str = "" + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class AdobeAEMClient(IClient): + """Builder class for Adobe AEM clients. + + Supports: + - Bearer token authentication + - Instance-based URL construction + """ + + def __init__( + self, + client: AdobeAEMRESTClientViaToken, + ) -> None: + """Initialize with an AEM client object.""" + super().__init__() + self.client = client + + @override + def get_client(self) -> AdobeAEMRESTClientViaToken: + """Return the AEM client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @property + def instance(self) -> str: + """Return the AEM instance identifier.""" + return self.client.get_instance() + + @classmethod + def build_with_config( + cls, + config: AdobeAEMTokenConfig, + ) -> "AdobeAEMClient": + """Build AdobeAEMClient with configuration. + + Args: + config: AdobeAEMTokenConfig instance + + Returns: + AdobeAEMClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "AdobeAEMClient": + """Build AdobeAEMClient using configuration service. + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + AdobeAEMClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get AEM connector configuration" + ) + + connector_config = AdobeAEMConnectorConfig.model_validate( + raw_config + ) + + instance = connector_config.instance + if not instance: + raise ValueError("AEM instance identifier is required") + + token = ( + connector_config.auth.token + or connector_config.auth.apiToken + or connector_config.credentials.access_token + or "" + ) + if not token: + raise ValueError( + "Token required for AEM authentication" + ) + + token_config = AdobeAEMTokenConfig( + token=token, instance=instance + ) + return cls(token_config.create_client()) + + except Exception as e: + logger.error( + f"Failed to build AEM client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "AdobeAEMClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service + + Returns: + AdobeAEMClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], + toolset_config.get("credentials", {}) or {}, + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + instance: str = str(toolset_config.get("instance", "")) + + if not instance: + raise ValueError( + "AEM instance not found in toolset config" + ) + + access_token: str = str( + credentials.get("access_token", "") + or auth_config.get("token", "") + or auth_config.get("apiToken", "") + ) + if not access_token: + raise ValueError( + "Access token not found in toolset config" + ) + + token_config = AdobeAEMTokenConfig( + token=access_token, instance=instance + ) + return cls(token_config.create_client()) + + except Exception as e: + logger.error( + f"Failed to build AEM client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for AEM.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get AEM connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error( + f"Failed to get AEM connector config: {e}" + ) + raise ValueError( + f"Failed to get AEM connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/affinity/affinity.py b/backend/python/app/sources/client/affinity/affinity.py new file mode 100644 index 000000000..a3386f3f6 --- /dev/null +++ b/backend/python/app/sources/client/affinity/affinity.py @@ -0,0 +1,365 @@ +"""Affinity client implementation. + +This module provides a client for interacting with the Affinity CRM API +using API Key authentication via HTTP Basic Auth. + +Affinity uses Basic Auth with an empty username and the API key as the +password: ``Authorization: Basic base64(":api_key")``. + +Authentication Reference: https://api-docs.affinity.co/#authentication +API Reference: https://api-docs.affinity.co/ +""" + +import base64 +import json +import logging +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class AffinityResponse(BaseModel): + """Standardized Affinity API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client class +# --------------------------------------------------------------------------- + + +class AffinityRESTClientViaApiKey(HTTPClient): + """Affinity REST client via API Key (Basic Auth). + + Uses HTTP Basic Auth with an empty username and the API key as + the password: ``Authorization: Basic base64(":api_key")``. + + Args: + api_key: Affinity API key + base_url: API base URL (default: https://api.affinity.co) + """ + + def __init__( + self, + api_key: str, + base_url: str = "https://api.affinity.co", + ) -> None: + # Initialize parent with empty token; we override Authorization below + super().__init__("", token_type="Basic") + self.api_key = api_key + self.base_url = base_url + # Affinity Basic Auth: empty username, api_key as password + credentials = base64.b64encode(f":{api_key}".encode()).decode("utf-8") + self.headers["Authorization"] = f"Basic {credentials}" + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class AffinityApiKeyConfig(BaseModel): + """Configuration for Affinity client via API Key. + + Args: + api_key: Affinity API key + base_url: API base URL (default: https://api.affinity.co) + """ + + api_key: str + base_url: str = "https://api.affinity.co" + + def create_client(self) -> AffinityRESTClientViaApiKey: + return AffinityRESTClientViaApiKey(self.api_key, self.base_url) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class AffinityAuthConfig(BaseModel): + """Auth section of the Affinity connector configuration from etcd.""" + + apiKey: str | None = None + oauthConfigId: str | None = None + + class Config: + extra = "allow" + + +class AffinityConnectorConfig(BaseModel): + """Top-level Affinity connector configuration from etcd.""" + + auth: AffinityAuthConfig = Field(default_factory=AffinityAuthConfig) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class AffinityClient(IClient): + """Builder class for Affinity clients. + + Supports: + - API Key authentication via HTTP Basic Auth + """ + + def __init__( + self, + client: AffinityRESTClientViaApiKey, + ) -> None: + """Initialize with an Affinity client object.""" + super().__init__() + self.client = client + + @override + def get_client(self) -> AffinityRESTClientViaApiKey: + """Return the Affinity client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: AffinityApiKeyConfig, + ) -> "AffinityClient": + """Build AffinityClient with configuration. + + Args: + config: AffinityApiKeyConfig instance + + Returns: + AffinityClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "AffinityClient": + """Build AffinityClient using configuration service. + + Uses API key from the connector configuration for Basic Auth. + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + AffinityClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get Affinity connector configuration" + ) + + connector_config = AffinityConnectorConfig.model_validate( + raw_config + ) + + api_key = connector_config.auth.apiKey or "" + + # Try shared OAuth config if API key is missing + oauth_config_id = connector_config.auth.oauthConfigId + if oauth_config_id and not api_key: + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/affinity", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + api_key = str( + shared.get("apiKey") + or shared.get("api_key") + or api_key + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + if not api_key: + raise ValueError( + "api_key is required for Affinity auth" + ) + + api_key_config = AffinityApiKeyConfig(api_key=api_key) + return cls(api_key_config.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Affinity client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "AffinityClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service for shared config + + Returns: + AffinityClient instance + """ + try: + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + api_key: str = str(auth_config.get("apiKey", "")) + + # Try shared config + oauth_config_id: str | None = cast( + str | None, auth_config.get("oauthConfigId") + ) + if oauth_config_id and config_service and not api_key: + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/affinity", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + api_key = str( + shared.get("apiKey") + or shared.get("api_key") + or api_key + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + if not api_key: + raise ValueError( + "api_key is required in toolset config for Affinity" + ) + + api_key_config = AffinityApiKeyConfig(api_key=api_key) + return cls(api_key_config.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Affinity client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Affinity.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get Affinity connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error(f"Failed to get Affinity connector config: {e}") + raise ValueError( + f"Failed to get Affinity connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/aha/aha.py b/backend/python/app/sources/client/aha/aha.py new file mode 100644 index 000000000..f16565dda --- /dev/null +++ b/backend/python/app/sources/client/aha/aha.py @@ -0,0 +1,573 @@ +"""Aha! client implementation. + +This module provides clients for interacting with the Aha! API using either: +1. OAuth 2.0 access token authentication +2. API Key (Bearer token) authentication + +Aha! API uses a subdomain-based base URL pattern: +https://{subdomain}.aha.io/api/v1 + +API Reference: https://www.aha.io/api +""" + +import base64 +import json +import logging +from enum import Enum +from typing import Any, cast + +from pydantic import BaseModel, Field, field_validator # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class AhaAuthType(str, Enum): + """Authentication types supported by the Aha! connector.""" + + OAUTH = "OAUTH" + API_KEY = "API_KEY" + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class AhaResponse(BaseModel): + """Standardized Aha! API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client classes +# --------------------------------------------------------------------------- + + +class AhaRESTClientViaToken(HTTPClient): + """Aha! REST client via API Key (Bearer token). + + Uses an API key passed as a Bearer token. + + Args: + subdomain: The Aha! account subdomain + api_key: The API key for authentication + """ + + def __init__(self, subdomain: str, api_key: str) -> None: + super().__init__(api_key, token_type="Bearer") + self.subdomain = subdomain + self.base_url = f"https://{subdomain}.aha.io/api/v1" + self.api_key = api_key + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + def get_subdomain(self) -> str: + """Get the Aha! subdomain.""" + return self.subdomain + + +class AhaRESTClientViaOAuth(HTTPClient): + """Aha! REST client via OAuth 2.0 access token. + + OAuth tokens are passed as Bearer tokens in the Authorization header. + + Args: + subdomain: The Aha! account subdomain + access_token: The OAuth access token + client_id: OAuth client ID (for reference / token refresh) + client_secret: OAuth client secret (for reference / token refresh) + """ + + def __init__( + self, + subdomain: str, + access_token: str, + client_id: str | None = None, + client_secret: str | None = None, + ) -> None: + super().__init__(access_token, "Bearer") + self.subdomain = subdomain + self.base_url = f"https://{subdomain}.aha.io/api/v1" + self.access_token = access_token + self.client_id = client_id + self.client_secret = client_secret + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + def get_subdomain(self) -> str: + """Get the Aha! subdomain.""" + return self.subdomain + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class AhaTokenConfig(BaseModel): + """Configuration for Aha! client via API Key. + + Args: + subdomain: The Aha! account subdomain + api_key: The API key for authentication + """ + + subdomain: str + api_key: str + + @field_validator("subdomain") + @classmethod + def validate_subdomain(cls, v: str) -> str: + """Validate subdomain field.""" + if not v or not v.strip(): + raise ValueError("subdomain cannot be empty or None") + if v.startswith(("http://", "https://")): + raise ValueError( + "subdomain should not include protocol (http:// or https://)" + ) + return v + + @field_validator("api_key") + @classmethod + def validate_api_key(cls, v: str) -> str: + """Validate api_key field.""" + if not v or not v.strip(): + raise ValueError("api_key cannot be empty or None") + return v + + def create_client(self) -> AhaRESTClientViaToken: + return AhaRESTClientViaToken(self.subdomain, self.api_key) + + +class AhaOAuthConfig(BaseModel): + """Configuration for Aha! client via OAuth 2.0. + + Args: + subdomain: The Aha! account subdomain + access_token: The OAuth access token + client_id: OAuth client ID + client_secret: OAuth client secret + """ + + subdomain: str + access_token: str + client_id: str | None = None + client_secret: str | None = None + + @field_validator("subdomain") + @classmethod + def validate_subdomain(cls, v: str) -> str: + """Validate subdomain field.""" + if not v or not v.strip(): + raise ValueError("subdomain cannot be empty or None") + if v.startswith(("http://", "https://")): + raise ValueError( + "subdomain should not include protocol (http:// or https://)" + ) + return v + + def create_client(self) -> AhaRESTClientViaOAuth: + return AhaRESTClientViaOAuth( + self.subdomain, + self.access_token, + self.client_id, + self.client_secret, + ) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class AhaAuthConfigModel(BaseModel): + """Auth section of the Aha! connector configuration from etcd.""" + + authType: AhaAuthType = AhaAuthType.API_KEY + apiKey: str | None = None + apiToken: str | None = None + subdomain: str | None = None + clientId: str | None = None + clientSecret: str | None = None + redirectUri: str | None = None + oauthConfigId: str | None = None + + class Config: + extra = "allow" + + +class AhaCredentialsConfig(BaseModel): + """Credentials section of the Aha! connector configuration.""" + + access_token: str | None = None + refresh_token: str | None = None + + class Config: + extra = "allow" + + +class AhaConnectorConfig(BaseModel): + """Top-level Aha! connector configuration from etcd.""" + + auth: AhaAuthConfigModel = Field(default_factory=AhaAuthConfigModel) + credentials: AhaCredentialsConfig = Field( + default_factory=AhaCredentialsConfig + ) + subdomain: str = "" + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Shared OAuth configuration model +# --------------------------------------------------------------------------- + + +class AhaSharedOAuthConfig(BaseModel): + """Shared OAuth configuration for Aha! (from etcd /services/oauth/aha).""" + + _id: str | None = None + clientId: str | None = None + client_id: str | None = None + clientSecret: str | None = None + client_secret: str | None = None + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class AhaClient(IClient): + """Builder class for Aha! clients with different authentication methods. + + Supports: + - API Key (Bearer token) authentication + - OAuth 2.0 access token authentication + - Subdomain-based base URL + """ + + def __init__( + self, + client: AhaRESTClientViaToken | AhaRESTClientViaOAuth, + ) -> None: + """Initialize with an Aha! client object.""" + super().__init__() + self.client = client + + @override + def get_client( + self, + ) -> AhaRESTClientViaToken | AhaRESTClientViaOAuth: + """Return the Aha! client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + def get_subdomain(self) -> str: + """Return the Aha! subdomain.""" + return self.client.get_subdomain() + + @classmethod + def build_with_config( + cls, + config: AhaTokenConfig | AhaOAuthConfig, + ) -> "AhaClient": + """Build AhaClient with configuration. + + Args: + config: AhaTokenConfig or AhaOAuthConfig instance + + Returns: + AhaClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "AhaClient": + """Build AhaClient using configuration service. + + Supports two authentication strategies: + 1. API_KEY: For API key authentication + 2. OAUTH: For OAuth 2.0 access tokens + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + AhaClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError("Failed to get Aha! connector configuration") + + connector_config = AhaConnectorConfig.model_validate(raw_config) + + subdomain = ( + connector_config.auth.subdomain + or connector_config.subdomain + or "" + ) + if not subdomain: + raise ValueError("Subdomain required for Aha! API") + + if connector_config.auth.authType == AhaAuthType.OAUTH: + access_token = connector_config.credentials.access_token or "" + client_id = connector_config.auth.clientId or "" + client_secret = connector_config.auth.clientSecret or "" + + # Try shared OAuth config if credentials are missing + oauth_config_id = connector_config.auth.oauthConfigId + if oauth_config_id and not (client_id and client_secret): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/aha", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + if not access_token: + raise ValueError( + "Access token required for OAuth auth type" + ) + + oauth_cfg = AhaOAuthConfig( + subdomain=subdomain, + access_token=access_token, + client_id=client_id, + client_secret=client_secret, + ) + return cls(oauth_cfg.create_client()) + + elif connector_config.auth.authType == AhaAuthType.API_KEY: + api_key = ( + connector_config.auth.apiKey + or connector_config.auth.apiToken + or "" + ) + if not api_key: + raise ValueError( + "API key required for API_KEY auth type" + ) + + token_config = AhaTokenConfig( + subdomain=subdomain, api_key=api_key + ) + return cls(token_config.create_client()) + + else: + raise ValueError( + f"Invalid auth type: {connector_config.auth.authType}" + ) + + except Exception as e: + logger.error( + f"Failed to build Aha! client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "AhaClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service for shared OAuth config + + Returns: + AhaClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("credentials", {}) or {} + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + subdomain: str = str( + auth_config.get("subdomain") + or toolset_config.get("subdomain", "") + ) + if not subdomain: + raise ValueError("Subdomain not found in toolset config") + + access_token: str = str(credentials.get("access_token", "")) + if not access_token: + raise ValueError("Access token not found in toolset config") + + client_id: str = str(auth_config.get("clientId", "")) + client_secret: str = str(auth_config.get("clientSecret", "")) + + # Try shared OAuth config + oauth_config_id: str | None = cast( + str | None, auth_config.get("oauthConfigId") + ) + if oauth_config_id and config_service and not ( + client_id and client_secret + ): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/aha", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + oauth_cfg = AhaOAuthConfig( + subdomain=subdomain, + access_token=access_token, + client_id=client_id, + client_secret=client_secret, + ) + return cls(oauth_cfg.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Aha! client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Aha!.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get Aha! connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error(f"Failed to get Aha! connector config: {e}") + raise ValueError( + f"Failed to get Aha! connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/amplitude/amplitude.py b/backend/python/app/sources/client/amplitude/amplitude.py new file mode 100644 index 000000000..e68daeb5c --- /dev/null +++ b/backend/python/app/sources/client/amplitude/amplitude.py @@ -0,0 +1,365 @@ +"""Amplitude client implementation. + +This module provides clients for interacting with the Amplitude API using +API Key + Secret Key via HTTP Basic Auth (base64 of "api_key:secret_key"). + +Amplitude has two base URLs: +1. https://amplitude.com/api/2 (v2 HTTP API) +2. https://analytics.amplitude.com/api/3 (Dashboard REST API) + +The client defaults to the v2 base URL and also exposes the v3 base URL +for endpoints that require it. + +Authentication Reference: https://www.docs.developers.amplitude.com/analytics/apis/ +API Reference: https://www.docs.developers.amplitude.com/analytics/apis/ +""" + +import base64 +import json +import logging +from enum import Enum +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class AmplitudeAuthType(str, Enum): + """Authentication types supported by the Amplitude connector.""" + + API_KEY = "API_KEY" + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class AmplitudeResponse(BaseModel): + """Standardized Amplitude API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client classes +# --------------------------------------------------------------------------- + + +class AmplitudeRESTClientViaApiKey(HTTPClient): + """Amplitude REST client via API Key + Secret Key. + + Uses HTTP Basic Auth with api_key as username and secret_key as password. + The credentials are base64-encoded and sent in the Authorization header. + + Args: + api_key: The Amplitude API key + secret_key: The Amplitude secret key + """ + + # Base URLs for the two API versions + BASE_URL_V2 = "https://amplitude.com/api/2" + BASE_URL_V3 = "https://analytics.amplitude.com/api/3" + + def __init__(self, api_key: str, secret_key: str) -> None: + # Encode api_key:secret_key as base64 for Basic auth + credentials = f"{api_key}:{secret_key}" + encoded_credentials = base64.b64encode(credentials.encode()).decode() + + # Initialize HTTPClient with Basic token + super().__init__(encoded_credentials, "Basic") + self.api_key = api_key + self.secret_key = secret_key + self.base_url = self.BASE_URL_V2 + self.base_url_v3 = self.BASE_URL_V3 + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the v2 base URL.""" + return self.base_url + + def get_base_url_v3(self) -> str: + """Get the v3 (Dashboard REST API) base URL.""" + return self.base_url_v3 + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class AmplitudeApiKeyConfig(BaseModel): + """Configuration for Amplitude REST client via API Key + Secret Key. + + Args: + api_key: The Amplitude API key + secret_key: The Amplitude secret key + """ + + api_key: str + secret_key: str + + def create_client(self) -> AmplitudeRESTClientViaApiKey: + """Create Amplitude REST client.""" + return AmplitudeRESTClientViaApiKey(self.api_key, self.secret_key) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class AmplitudeAuthConfig(BaseModel): + """Auth section of the Amplitude connector configuration from etcd.""" + + authType: AmplitudeAuthType = AmplitudeAuthType.API_KEY + apiKey: str | None = None + secretKey: str | None = None + + class Config: + extra = "allow" + + +class AmplitudeConnectorConfig(BaseModel): + """Top-level Amplitude connector configuration from etcd.""" + + auth: AmplitudeAuthConfig = Field(default_factory=AmplitudeAuthConfig) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class AmplitudeClient(IClient): + """Builder class for Amplitude clients. + + Supports: + - API Key + Secret Key authentication via HTTP Basic Auth + """ + + def __init__(self, client: AmplitudeRESTClientViaApiKey) -> None: + """Initialize with an Amplitude client object.""" + super().__init__() + self.client = client + + @override + def get_client(self) -> AmplitudeRESTClientViaApiKey: + """Return the Amplitude client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the v2 base URL.""" + return self.client.get_base_url() + + def get_base_url_v3(self) -> str: + """Return the v3 base URL.""" + return self.client.get_base_url_v3() + + @classmethod + def build_with_config( + cls, + config: AmplitudeApiKeyConfig, + ) -> "AmplitudeClient": + """Build AmplitudeClient with configuration. + + Args: + config: AmplitudeApiKeyConfig instance + + Returns: + AmplitudeClient instance + """ + return cls(config.create_client()) + + @classmethod + def build_with_api_key( + cls, + api_key: str, + secret_key: str, + ) -> "AmplitudeClient": + """Build AmplitudeClient with API key and secret key directly. + + Args: + api_key: The Amplitude API key + secret_key: The Amplitude secret key + + Returns: + AmplitudeClient instance + """ + config = AmplitudeApiKeyConfig(api_key=api_key, secret_key=secret_key) + return cls.build_with_config(config) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "AmplitudeClient": + """Build AmplitudeClient using configuration service. + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + AmplitudeClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get Amplitude connector configuration" + ) + + connector_config = AmplitudeConnectorConfig.model_validate( + raw_config + ) + + if connector_config.auth.authType == AmplitudeAuthType.API_KEY: + api_key = connector_config.auth.apiKey or "" + secret_key = connector_config.auth.secretKey or "" + + if not api_key: + raise ValueError( + "API key required for API_KEY auth type" + ) + if not secret_key: + raise ValueError( + "Secret key required for API_KEY auth type" + ) + + config = AmplitudeApiKeyConfig( + api_key=api_key, + secret_key=secret_key, + ) + return cls(config.create_client()) + + else: + raise ValueError( + f"Invalid auth type: {connector_config.auth.authType}" + ) + + except Exception as e: + logger.error( + f"Failed to build Amplitude client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "AmplitudeClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service + + Returns: + AmplitudeClient instance + """ + try: + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + api_key: str = str(auth_config.get("apiKey", "")) + secret_key: str = str(auth_config.get("secretKey", "")) + + if not api_key: + raise ValueError("API key not found in toolset config") + if not secret_key: + raise ValueError("Secret key not found in toolset config") + + config = AmplitudeApiKeyConfig( + api_key=api_key, + secret_key=secret_key, + ) + return cls(config.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Amplitude client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Amplitude.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get Amplitude connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error(f"Failed to get Amplitude connector config: {e}") + raise ValueError( + f"Failed to get Amplitude connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/ariba/ariba.py b/backend/python/app/sources/client/ariba/ariba.py new file mode 100644 index 000000000..50d24ed95 --- /dev/null +++ b/backend/python/app/sources/client/ariba/ariba.py @@ -0,0 +1,388 @@ +"""SAP Ariba client implementation. + +This module provides a client for interacting with the SAP Ariba API using: +1. OAuth 2.0 client_credentials flow (auto token fetch via Basic Auth) + +Token Endpoint: https://api.ariba.com/v2/oauth/token + (grant_type=client_credentials, Basic Auth header with client_id:client_secret) +API Base URL: https://openapi.ariba.com/api +""" + +import base64 +import json +import logging +from typing import Any, cast + +import httpx # type: ignore +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class AribaResponse(BaseModel): + """Standardized SAP Ariba API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client class +# --------------------------------------------------------------------------- + + +class AribaRESTClientViaClientCredentials(HTTPClient): + """SAP Ariba REST client via OAuth 2.0 client_credentials. + + Automatically fetches an access token from the SAP Ariba token endpoint + using Basic Auth (client_id:client_secret) before making API requests. + + Args: + client_id: OAuth client ID + client_secret: OAuth client secret + token_endpoint: Token endpoint URL + (default: https://api.ariba.com/v2/oauth/token) + base_url: API base URL + (default: https://openapi.ariba.com/api) + """ + + def __init__( + self, + client_id: str, + client_secret: str, + token_endpoint: str = "https://api.ariba.com/v2/oauth/token", + base_url: str = "https://openapi.ariba.com/api", + ) -> None: + # Initialize with empty token; will be set after fetching + super().__init__("", token_type="Bearer") + self.base_url = base_url + self.client_id = client_id + self.client_secret = client_secret + self.token_endpoint = token_endpoint + self._access_token: str | None = None + self.headers["Content-Type"] = "application/json" + + async def _fetch_token(self) -> str: + """Fetch an access token using client_credentials grant. + + Uses Basic Auth header with client_id:client_secret to authenticate + at the token endpoint. + + Returns: + Access token string. + """ + credentials = base64.b64encode( + f"{self.client_id}:{self.client_secret}".encode() + ).decode("utf-8") + + async with httpx.AsyncClient() as client: # type: ignore[reportUnknownMemberType] + response = await client.post( # type: ignore[reportUnknownMemberType] + self.token_endpoint, + data={"grant_type": "client_credentials"}, + headers={ + "Authorization": f"Basic {credentials}", + "Content-Type": "application/x-www-form-urlencoded", + }, + ) + response.raise_for_status() # type: ignore[reportUnknownMemberType] + token_data: dict[str, Any] = response.json() # type: ignore[reportUnknownMemberType] + access_token: str = str(token_data.get("access_token", "")) # type: ignore[reportUnknownMemberType, reportUnknownArgumentType] + if not access_token: + raise ValueError("No access_token in token response") + return access_token + + async def ensure_token(self) -> None: + """Ensure a valid access token is set in headers.""" + if not self._access_token: + self._access_token = await self._fetch_token() + self.headers["Authorization"] = f"Bearer {self._access_token}" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class AribaClientCredentialsConfig(BaseModel): + """Configuration for SAP Ariba client via client_credentials. + + Args: + client_id: OAuth client ID + client_secret: OAuth client secret + token_endpoint: Token endpoint URL + base_url: API base URL + """ + + client_id: str + client_secret: str + token_endpoint: str = "https://api.ariba.com/v2/oauth/token" + base_url: str = "https://openapi.ariba.com/api" + + def create_client(self) -> AribaRESTClientViaClientCredentials: + return AribaRESTClientViaClientCredentials( + self.client_id, + self.client_secret, + self.token_endpoint, + self.base_url, + ) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class AribaAuthConfigModel(BaseModel): + """Auth section of the Ariba connector configuration from etcd.""" + + clientId: str | None = None + clientSecret: str | None = None + tokenEndpoint: str | None = None + baseUrl: str | None = None + + class Config: + extra = "allow" + + +class AribaConnectorConfig(BaseModel): + """Top-level Ariba connector configuration from etcd.""" + + auth: AribaAuthConfigModel = Field(default_factory=AribaAuthConfigModel) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class AribaClient(IClient): + """Builder class for SAP Ariba clients. + + Supports: + - OAuth 2.0 client_credentials flow (auto token fetch) + """ + + def __init__( + self, + client: AribaRESTClientViaClientCredentials, + ) -> None: + """Initialize with an Ariba client object.""" + super().__init__() + self.client = client + + @override + def get_client(self) -> AribaRESTClientViaClientCredentials: + """Return the Ariba client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: AribaClientCredentialsConfig, + ) -> "AribaClient": + """Build AribaClient with configuration. + + Args: + config: AribaClientCredentialsConfig instance + + Returns: + AribaClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "AribaClient": + """Build AribaClient using configuration service. + + Uses client_credentials OAuth flow. + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + AribaClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get Ariba connector configuration" + ) + + connector_config = AribaConnectorConfig.model_validate(raw_config) + + client_id = connector_config.auth.clientId or "" + client_secret = connector_config.auth.clientSecret or "" + + if not (client_id and client_secret): + raise ValueError( + "client_id and client_secret required for Ariba" + ) + + token_endpoint = ( + connector_config.auth.tokenEndpoint + or "https://api.ariba.com/v2/oauth/token" + ) + base_url = ( + connector_config.auth.baseUrl + or "https://openapi.ariba.com/api" + ) + + config = AribaClientCredentialsConfig( + client_id=client_id, + client_secret=client_secret, + token_endpoint=token_endpoint, + base_url=base_url, + ) + return cls(config.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Ariba client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "AribaClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service (unused for Ariba) + + Returns: + AribaClient instance + """ + try: + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + client_id: str = str(auth_config.get("clientId", "")) + client_secret: str = str(auth_config.get("clientSecret", "")) + + if not (client_id and client_secret): + raise ValueError( + "client_id and client_secret not found in toolset config" + ) + + token_endpoint: str = str( + auth_config.get( + "tokenEndpoint", + "https://api.ariba.com/v2/oauth/token", + ) + ) + base_url: str = str( + auth_config.get( + "baseUrl", + "https://openapi.ariba.com/api", + ) + ) + + config = AribaClientCredentialsConfig( + client_id=client_id, + client_secret=client_secret, + token_endpoint=token_endpoint, + base_url=base_url, + ) + return cls(config.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Ariba client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Ariba.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get Ariba connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error(f"Failed to get Ariba connector config: {e}") + raise ValueError( + f"Failed to get Ariba connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/bamboohr/bamboohr.py b/backend/python/app/sources/client/bamboohr/bamboohr.py new file mode 100644 index 000000000..f3c7b1e65 --- /dev/null +++ b/backend/python/app/sources/client/bamboohr/bamboohr.py @@ -0,0 +1,311 @@ +"""BambooHR client implementation. + +This module provides clients for interacting with the BambooHR API using: +1. API Key authentication (HTTP Basic Auth with api_key as username, "x" as password) + +Authentication Reference: https://documentation.bamboohr.com/docs/getting-started#authentication +API Reference: https://documentation.bamboohr.com/reference +""" + +import base64 +import json +import logging +from enum import Enum +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class BambooHRAuthType(str, Enum): + """Authentication types supported by the BambooHR connector.""" + + API_KEY = "API_KEY" + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class BambooHRResponse(BaseModel): + """Standardized BambooHR API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client classes +# --------------------------------------------------------------------------- + + +class BambooHRRESTClientViaApiKey(HTTPClient): + """BambooHR REST client via API Key. + + BambooHR uses HTTP Basic Authentication with the API key as the username + and "x" as the password. + + Args: + company_domain: The BambooHR company subdomain (e.g., 'mycompany') + api_key: The API key for authentication + """ + + def __init__(self, company_domain: str, api_key: str) -> None: + # BambooHR uses Basic auth with API key as username, 'x' as password + credentials = f"{api_key}:x" + encoded_credentials = base64.b64encode(credentials.encode()).decode() + + # Initialize HTTPClient with Basic token + super().__init__(encoded_credentials, "Basic") + self.company_domain = company_domain + self.base_url = ( + f"https://api.bamboohr.com/api/gateway.php/{company_domain}/v1" + ) + self.api_key = api_key + self.headers["Accept"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + def get_company_domain(self) -> str: + """Get the BambooHR company domain.""" + return self.company_domain + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class BambooHRApiKeyConfig(BaseModel): + """Configuration for BambooHR client via API Key. + + Args: + company_domain: The BambooHR company subdomain (e.g., 'mycompany') + api_key: The API key for authentication + """ + + company_domain: str + api_key: str + + def create_client(self) -> BambooHRRESTClientViaApiKey: + return BambooHRRESTClientViaApiKey(self.company_domain, self.api_key) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class BambooHRAuthConfig(BaseModel): + """Auth section of the BambooHR connector configuration from etcd.""" + + authType: BambooHRAuthType = BambooHRAuthType.API_KEY + apiKey: str | None = None + companyDomain: str | None = None + + class Config: + extra = "allow" + + +class BambooHRCredentialsConfig(BaseModel): + """Credentials section of the BambooHR connector configuration.""" + + api_key: str | None = None + + class Config: + extra = "allow" + + +class BambooHRConnectorConfig(BaseModel): + """Top-level BambooHR connector configuration from etcd.""" + + auth: BambooHRAuthConfig = Field(default_factory=BambooHRAuthConfig) + credentials: BambooHRCredentialsConfig = Field( + default_factory=BambooHRCredentialsConfig + ) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class BambooHRClient(IClient): + """Builder class for BambooHR clients with API Key authentication. + + Supports: + - API Key authentication (HTTP Basic Auth) + """ + + def __init__( + self, + client: BambooHRRESTClientViaApiKey, + ) -> None: + """Initialize with a BambooHR client object.""" + super().__init__() + self.client = client + + @override + def get_client(self) -> BambooHRRESTClientViaApiKey: + """Return the BambooHR client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + def get_company_domain(self) -> str: + """Return the company domain.""" + return self.client.get_company_domain() + + @classmethod + def build_with_config( + cls, + config: BambooHRApiKeyConfig, + ) -> "BambooHRClient": + """Build BambooHRClient with configuration. + + Args: + config: BambooHRApiKeyConfig instance + + Returns: + BambooHRClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "BambooHRClient": + """Build BambooHRClient using configuration service. + + Supports API Key authentication strategy. + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + BambooHRClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError("Failed to get BambooHR connector configuration") + + connector_config = BambooHRConnectorConfig.model_validate(raw_config) + + if connector_config.auth.authType == BambooHRAuthType.API_KEY: + api_key = ( + connector_config.auth.apiKey + or connector_config.credentials.api_key + or "" + ) + company_domain = connector_config.auth.companyDomain or "" + + if not api_key: + raise ValueError( + "API key required for API_KEY auth type" + ) + if not company_domain: + raise ValueError( + "Company domain required for API_KEY auth type" + ) + + api_key_config = BambooHRApiKeyConfig( + company_domain=company_domain, + api_key=api_key, + ) + return cls(api_key_config.create_client()) + + else: + raise ValueError( + f"Invalid auth type: {connector_config.auth.authType}" + ) + + except Exception as e: + logger.error( + f"Failed to build BambooHR client from services: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for BambooHR.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get BambooHR connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error(f"Failed to get BambooHR connector config: {e}") + raise ValueError( + f"Failed to get BambooHR connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/benchling/benchling.py b/backend/python/app/sources/client/benchling/benchling.py new file mode 100644 index 000000000..299eef4eb --- /dev/null +++ b/backend/python/app/sources/client/benchling/benchling.py @@ -0,0 +1,344 @@ +"""Benchling client implementation. + +This module provides clients for interacting with the Benchling API using the +official ``benchling-sdk`` Python package. + +Authentication: + - API Key: Passed via ``ApiKeyAuth`` to the SDK + +SDK Reference: https://docs.benchling.com/docs/getting-started-with-the-sdk +""" + +import base64 +import json +import logging +from typing import Any, cast + +from benchling_sdk.auth.api_key_auth import ( # type: ignore[reportMissingImports] + ApiKeyAuth, # type: ignore[reportUnknownVariableType] +) +from benchling_sdk.benchling import Benchling # type: ignore[reportMissingImports] +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class BenchlingResponse(BaseModel): + """Standardized Benchling API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# SDK wrapper classes +# --------------------------------------------------------------------------- + + +class BenchlingClientViaApiKey: + """Benchling SDK wrapper authenticated via API Key. + + Wraps the official ``benchling-sdk`` ``Benchling`` client. + + Args: + tenant_url: Full tenant URL, e.g. ``https://your-tenant.benchling.com`` + api_key: The Benchling API key + """ + + def __init__(self, tenant_url: str, api_key: str) -> None: + super().__init__() + self.tenant_url = tenant_url.rstrip("/") + self.api_key = api_key + self._sdk: Any = None # Benchling + + def create_client(self) -> Any: # Benchling + """Create and return the SDK client.""" + self._sdk = Benchling( # type: ignore[reportUnknownVariableType] + url=self.tenant_url, + auth_method=ApiKeyAuth(self.api_key), # type: ignore[reportUnknownVariableType] + ) + return self._sdk # type: ignore[reportUnknownMemberType,reportUnknownVariableType] + + def get_sdk(self) -> Any: # Benchling + """Return the SDK client, creating it lazily if needed.""" + if self._sdk is None: + return self.create_client() + return self._sdk + + def get_base_url(self) -> str: + """Get the tenant URL.""" + return self.tenant_url + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class BenchlingApiKeyConfig(BaseModel): + """Configuration for Benchling client via API Key. + + Args: + api_key: The Benchling API key + tenant_url: Full tenant URL (e.g. ``https://your-tenant.benchling.com``) + """ + + api_key: str + tenant_url: str + + def create_client(self) -> BenchlingClientViaApiKey: + return BenchlingClientViaApiKey( + tenant_url=self.tenant_url, + api_key=self.api_key, + ) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class BenchlingAuthConfig(BaseModel): + """Auth section of the Benchling connector configuration from etcd.""" + + apiKey: str | None = None + tenant: str | None = None + tenantUrl: str | None = None + baseUrl: str | None = None + + class Config: + extra = "allow" + + +class BenchlingConnectorConfig(BaseModel): + """Top-level Benchling connector configuration from etcd.""" + + auth: BenchlingAuthConfig = Field(default_factory=BenchlingAuthConfig) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class BenchlingClient(IClient): + """Builder class for Benchling clients using the official SDK. + + Supports: + - API Key authentication via ``benchling-sdk`` + """ + + def __init__(self, client: BenchlingClientViaApiKey) -> None: + """Initialize with a Benchling SDK wrapper.""" + super().__init__() + self.client = client + + @override + def get_client(self) -> BenchlingClientViaApiKey: + """Return the Benchling SDK wrapper.""" + return self.client + + def get_sdk(self) -> Any: # Benchling + """Return the underlying Benchling SDK instance.""" + return self.client.get_sdk() + + def get_base_url(self) -> str: + """Return the tenant URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: BenchlingApiKeyConfig, + ) -> "BenchlingClient": + """Build BenchlingClient with configuration. + + Args: + config: BenchlingApiKeyConfig instance + + Returns: + BenchlingClient instance + """ + wrapper = config.create_client() + wrapper.get_sdk() # eagerly initialize + return cls(wrapper) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "BenchlingClient": + """Build BenchlingClient using configuration service. + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + BenchlingClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get Benchling connector configuration" + ) + + connector_config = BenchlingConnectorConfig.model_validate( + raw_config + ) + + api_key = connector_config.auth.apiKey or "" + if not api_key: + raise ValueError( + "API key required for Benchling authentication" + ) + + # Resolve tenant URL + tenant_url = connector_config.auth.tenantUrl or connector_config.auth.baseUrl or "" + if not tenant_url and connector_config.auth.tenant: + tenant_url = f"https://{connector_config.auth.tenant}.benchling.com" + if not tenant_url: + raise ValueError("Tenant URL required for Benchling") + + config = BenchlingApiKeyConfig( + api_key=api_key, + tenant_url=tenant_url, + ) + wrapper = config.create_client() + wrapper.get_sdk() + return cls(wrapper) + + except Exception as e: + logger.error( + f"Failed to build Benchling client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "BenchlingClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service + + Returns: + BenchlingClient instance + """ + try: + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + api_key: str = str(auth_config.get("apiKey", "")) + if not api_key: + raise ValueError( + "API key not found in toolset config" + ) + + tenant_url: str = str( + auth_config.get("tenantUrl", "") + or auth_config.get("baseUrl", "") + ) + if not tenant_url: + tenant = str(auth_config.get("tenant", "")) + if tenant: + tenant_url = f"https://{tenant}.benchling.com" + if not tenant_url: + raise ValueError("Tenant URL not found in toolset config") + + config = BenchlingApiKeyConfig( + api_key=api_key, + tenant_url=tenant_url, + ) + wrapper = config.create_client() + wrapper.get_sdk() + return cls(wrapper) + + except Exception as e: + logger.error( + f"Failed to build Benchling client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Benchling.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get Benchling connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error(f"Failed to get Benchling connector config: {e}") + raise ValueError( + f"Failed to get Benchling connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/bigquery/bigquery.py b/backend/python/app/sources/client/bigquery/bigquery.py new file mode 100644 index 000000000..8fb7bd721 --- /dev/null +++ b/backend/python/app/sources/client/bigquery/bigquery.py @@ -0,0 +1,222 @@ +import logging +from typing import Any + +from google.cloud import bigquery # type: ignore[import-untyped] +from google.oauth2 import service_account # type: ignore[import-untyped] +from google.oauth2.credentials import Credentials # type: ignore[import-untyped] +from pydantic import BaseModel, Field +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.iclient import IClient + + +class BigQueryResponse(BaseModel): + success: bool + data: Any | None = None + error: str | None = None + message: str | None = None + + def to_dict(self) -> dict[str, Any]: + return self.model_dump() + + +class BigQueryClientViaServiceAccount: + def __init__( + self, + service_account_json: dict[str, Any], + project_id: str, + *, + location: str | None = None, + ) -> None: + super().__init__() + self.service_account_json = service_account_json + self.project_id = project_id + self.location = location + + self._sdk: bigquery.Client | None = None # type: ignore[no-any-unimported] + + def create_client(self) -> Any: # bigquery.Client + credentials = service_account.Credentials.from_service_account_info( # type: ignore[no-untyped-call] + self.service_account_json + ) + kwargs: dict[str, Any] = { + "credentials": credentials, + "project": self.project_id, + } + if self.location is not None: + kwargs["location"] = self.location + + self._sdk = bigquery.Client(**kwargs) # type: ignore[no-untyped-call] + return self._sdk # type: ignore[reportUnknownVariableType] + + def get_sdk(self) -> Any: # bigquery.Client + if self._sdk is None: # type: ignore[reportUnknownMemberType] + return self.create_client() # type: ignore[reportUnknownVariableType] + return self._sdk # type: ignore[reportUnknownVariableType] + + def get_project_id(self) -> str: + return self.project_id + + +class BigQueryClientViaOAuth: + def __init__( + self, + access_token: str, + project_id: str, + *, + location: str | None = None, + ) -> None: + super().__init__() + self.access_token = access_token + self.project_id = project_id + self.location = location + + self._sdk: bigquery.Client | None = None # type: ignore[no-any-unimported] + + def create_client(self) -> Any: # bigquery.Client + credentials = Credentials(token=self.access_token) # type: ignore[no-untyped-call] + kwargs: dict[str, Any] = { + "credentials": credentials, + "project": self.project_id, + } + if self.location is not None: + kwargs["location"] = self.location + + self._sdk = bigquery.Client(**kwargs) # type: ignore[no-untyped-call] + return self._sdk # type: ignore[reportUnknownVariableType] + + def get_sdk(self) -> Any: # bigquery.Client + if self._sdk is None: # type: ignore[reportUnknownMemberType] + return self.create_client() # type: ignore[reportUnknownVariableType] + return self._sdk # type: ignore[reportUnknownVariableType] + + def get_project_id(self) -> str: + return self.project_id + + +class BigQueryServiceAccountConfig(BaseModel): + service_account_json: dict[str, Any] = Field( + ..., description="Service account JSON key" + ) + project_id: str = Field(..., description="GCP project ID") + location: str | None = Field( + default=None, description="Default BigQuery location" + ) + + def create_client(self) -> BigQueryClientViaServiceAccount: + return BigQueryClientViaServiceAccount( + service_account_json=self.service_account_json, + project_id=self.project_id, + location=self.location, + ) + + +class BigQueryOAuthConfig(BaseModel): + access_token: str = Field(..., description="OAuth access token") + project_id: str = Field(..., description="GCP project ID") + location: str | None = Field( + default=None, description="Default BigQuery location" + ) + + def create_client(self) -> BigQueryClientViaOAuth: + return BigQueryClientViaOAuth( + access_token=self.access_token, + project_id=self.project_id, + location=self.location, + ) + + +BigQueryClientWrapper = BigQueryClientViaServiceAccount | BigQueryClientViaOAuth + + +class BigQueryClient(IClient): + def __init__(self, client: BigQueryClientWrapper) -> None: + super().__init__() + self.client = client + + @override + def get_client(self) -> BigQueryClientWrapper: + return self.client + + def get_sdk(self) -> Any: # bigquery.Client + return self.client.get_sdk() # type: ignore[reportUnknownMemberType] + + @classmethod + def build_with_config( + cls, + config: BigQueryServiceAccountConfig | BigQueryOAuthConfig, + ) -> "BigQueryClient": + client = config.create_client() + _ = client.get_sdk() # type: ignore[reportUnknownMemberType] + return cls(client) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "BigQueryClient": + """Build BigQueryClient using configuration service.""" + config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not config: + raise ValueError( + "Failed to get BigQuery connector configuration" + ) + auth_config = config.get("auth", {}) + auth_type = auth_config.get("authType", "SERVICE_ACCOUNT") + project_id = auth_config.get("projectId", "") + location = auth_config.get("location") + + if auth_type == "SERVICE_ACCOUNT": + sa_json = auth_config.get("serviceAccountJson", {}) + if not sa_json: + raise ValueError( + "serviceAccountJson required for SERVICE_ACCOUNT auth" + ) + wrapper: BigQueryClientWrapper = BigQueryClientViaServiceAccount( + service_account_json=sa_json, + project_id=project_id, + location=location, + ) + elif auth_type == "OAUTH": + access_token = auth_config.get("accessToken", "") + if not access_token: + raise ValueError("accessToken required for OAUTH auth") + wrapper = BigQueryClientViaOAuth( + access_token=access_token, + project_id=project_id, + location=location, + ) + else: + raise ValueError(f"Invalid auth type: {auth_type}") + + _ = wrapper.create_client() # type: ignore[reportUnknownVariableType,reportUnknownMemberType] + return cls(wrapper) + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for BigQuery.""" + try: + config: dict[str, Any] = await config_service.get_config( # type: ignore[assignment] + f"/services/connectors/{connector_instance_id}/config" + ) + if not config: + raise ValueError( + f"Failed to get BigQuery connector configuration for instance {connector_instance_id}" + ) + return config + except Exception as e: + logger.error( + "Failed to get BigQuery connector config: %s", e + ) + raise ValueError( + f"Failed to get BigQuery connector configuration for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/bynder/bynder.py b/backend/python/app/sources/client/bynder/bynder.py new file mode 100644 index 000000000..d56f8e15c --- /dev/null +++ b/backend/python/app/sources/client/bynder/bynder.py @@ -0,0 +1,564 @@ +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownParameterType=false +"""Bynder client implementation. + +This module provides clients for interacting with the Bynder API using the +official ``bynder-sdk`` Python package. + +Authentication: + - Permanent Token: Direct token authentication + - OAuth 2.0: Client credentials with token + +SDK Reference: https://github.com/Bynder/bynder-python-sdk +""" + +import base64 +import json +import logging +from enum import Enum +from typing import Any, cast + +from bynder_sdk import BynderClient as BynderSDKClient +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class BynderAuthType(str, Enum): + """Authentication types supported by the Bynder connector.""" + + OAUTH = "OAUTH" + TOKEN = "TOKEN" + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class BynderResponse(BaseModel): + """Standardized Bynder API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# SDK wrapper classes +# --------------------------------------------------------------------------- + + +class BynderClientViaPermanentToken: + """Bynder SDK wrapper authenticated via permanent token. + + Args: + domain: Bynder portal domain (e.g. ``portal.getbynder.com``) + permanent_token: The permanent API token + """ + + def __init__(self, domain: str, permanent_token: str) -> None: + self.domain = domain + self.permanent_token = permanent_token + self._sdk: BynderSDKClient | None = None + + def create_client(self) -> BynderSDKClient: + """Create and return the SDK client.""" + self._sdk = BynderSDKClient( + domain=self.domain, + permanent_token=self.permanent_token, + ) + return self._sdk + + def get_sdk(self) -> BynderSDKClient: + """Return the SDK client, creating it lazily if needed.""" + if self._sdk is None: + return self.create_client() + return self._sdk + + def get_domain(self) -> str: + """Get the Bynder domain.""" + return self.domain + + +class BynderClientViaOAuth: + """Bynder SDK wrapper authenticated via OAuth 2.0. + + Args: + domain: Bynder portal domain (e.g. ``portal.getbynder.com``) + redirect_uri: OAuth redirect URI + client_id: OAuth client ID + client_secret: OAuth client secret + token: OAuth token dict (must include ``access_token``) + """ + + def __init__( + self, + domain: str, + redirect_uri: str, + client_id: str, + client_secret: str, + token: dict[str, Any], + ) -> None: + self.domain = domain + self.redirect_uri = redirect_uri + self.client_id = client_id + self.client_secret = client_secret + self.token = token + self._sdk: BynderSDKClient | None = None + + def create_client(self) -> BynderSDKClient: + """Create and return the SDK client.""" + self._sdk = BynderSDKClient( + domain=self.domain, + redirect_uri=self.redirect_uri, + client_id=self.client_id, + client_secret=self.client_secret, + token=self.token, + ) + return self._sdk + + def get_sdk(self) -> BynderSDKClient: + """Return the SDK client, creating it lazily if needed.""" + if self._sdk is None: + return self.create_client() + return self._sdk + + def get_domain(self) -> str: + """Get the Bynder domain.""" + return self.domain + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class BynderPermanentTokenConfig(BaseModel): + """Configuration for Bynder client via permanent token. + + Args: + domain: Bynder portal domain + permanent_token: The permanent API token + """ + + domain: str + permanent_token: str + + def create_client(self) -> BynderClientViaPermanentToken: + return BynderClientViaPermanentToken( + domain=self.domain, + permanent_token=self.permanent_token, + ) + + +class BynderOAuthConfig(BaseModel): + """Configuration for Bynder client via OAuth 2.0. + + Args: + domain: Bynder portal domain + redirect_uri: OAuth redirect URI + client_id: OAuth client ID + client_secret: OAuth client secret + token: OAuth token dict + """ + + domain: str + redirect_uri: str + client_id: str + client_secret: str + token: dict[str, Any] + + def create_client(self) -> BynderClientViaOAuth: + return BynderClientViaOAuth( + domain=self.domain, + redirect_uri=self.redirect_uri, + client_id=self.client_id, + client_secret=self.client_secret, + token=self.token, + ) + + +# Backward-compatible alias +BynderTokenConfig = BynderPermanentTokenConfig + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class BynderAuthConfigModel(BaseModel): + """Auth section of the Bynder connector configuration from etcd.""" + + authType: BynderAuthType = BynderAuthType.TOKEN + token: str | None = None + clientId: str | None = None + clientSecret: str | None = None + redirectUri: str | None = None + oauthConfigId: str | None = None + + class Config: + extra = "allow" + + +class BynderCredentialsConfigModel(BaseModel): + """Credentials section of the Bynder connector configuration.""" + + access_token: str | None = None + refresh_token: str | None = None + + class Config: + extra = "allow" + + +class BynderConnectorConfig(BaseModel): + """Top-level Bynder connector configuration from etcd.""" + + auth: BynderAuthConfigModel = Field( + default_factory=BynderAuthConfigModel + ) + credentials: BynderCredentialsConfigModel = Field( + default_factory=BynderCredentialsConfigModel + ) + domain: str = "" + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class BynderClient(IClient): + """Builder class for Bynder clients using the official SDK. + + Supports: + - Permanent token authentication + - OAuth 2.0 authentication + """ + + def __init__( + self, + client: BynderClientViaPermanentToken | BynderClientViaOAuth, + ) -> None: + """Initialize with a Bynder SDK wrapper.""" + super().__init__() + self.client = client + + @override + def get_client( + self, + ) -> BynderClientViaPermanentToken | BynderClientViaOAuth: + """Return the Bynder SDK wrapper.""" + return self.client + + def get_sdk(self) -> BynderSDKClient: + """Return the underlying Bynder SDK instance.""" + return self.client.get_sdk() + + @property + def domain(self) -> str: + """Return the Bynder domain.""" + return self.client.get_domain() + + @classmethod + def build_with_config( + cls, + config: BynderPermanentTokenConfig | BynderOAuthConfig, + ) -> "BynderClient": + """Build BynderClient with configuration. + + Args: + config: BynderPermanentTokenConfig or BynderOAuthConfig instance + + Returns: + BynderClient instance + """ + wrapper = config.create_client() + wrapper.get_sdk() # eagerly initialize + return cls(wrapper) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "BynderClient": + """Build BynderClient using configuration service. + + Supports two authentication strategies: + 1. OAUTH: For OAuth 2.0 access tokens + 2. TOKEN: For permanent token authentication + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + BynderClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get Bynder connector configuration" + ) + + connector_config = BynderConnectorConfig.model_validate( + raw_config + ) + + domain = connector_config.domain + if not domain: + raise ValueError("Bynder domain is required") + + if connector_config.auth.authType == BynderAuthType.OAUTH: + access_token = ( + connector_config.credentials.access_token or "" + ) + client_id = connector_config.auth.clientId or "" + client_secret = connector_config.auth.clientSecret or "" + + # Try shared OAuth config if credentials are missing + oauth_config_id = connector_config.auth.oauthConfigId + if oauth_config_id and not (client_id and client_secret): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/bynder", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + if not access_token: + raise ValueError( + "Access token required for OAuth auth type" + ) + + redirect_uri = connector_config.auth.redirectUri or "" + oauth_cfg = BynderOAuthConfig( + domain=domain, + redirect_uri=redirect_uri, + client_id=client_id, + client_secret=client_secret, + token={"access_token": access_token}, + ) + wrapper = oauth_cfg.create_client() + wrapper.get_sdk() + return cls(wrapper) + + elif connector_config.auth.authType == BynderAuthType.TOKEN: + token = connector_config.auth.token or "" + if not token: + raise ValueError( + "Token required for TOKEN auth type" + ) + + token_config = BynderPermanentTokenConfig( + domain=domain, permanent_token=token + ) + wrapper = token_config.create_client() + wrapper.get_sdk() + return cls(wrapper) + + else: + raise ValueError( + f"Invalid auth type: {connector_config.auth.authType}" + ) + + except Exception as e: + logger.error( + f"Failed to build Bynder client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "BynderClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service + + Returns: + BynderClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], + toolset_config.get("credentials", {}) or {}, + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + domain: str = str(toolset_config.get("domain", "")) + + if not domain: + raise ValueError("Bynder domain not found in toolset config") + + access_token: str = str(credentials.get("access_token", "")) + if not access_token: + raise ValueError( + "Access token not found in toolset config" + ) + + client_id: str = str(auth_config.get("clientId", "")) + client_secret: str = str(auth_config.get("clientSecret", "")) + + # Try shared OAuth config + oauth_config_id: str | None = cast( + str | None, auth_config.get("oauthConfigId") + ) + if oauth_config_id and config_service and not ( + client_id and client_secret + ): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/bynder", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + redirect_uri: str = str(auth_config.get("redirectUri", "")) + oauth_cfg = BynderOAuthConfig( + domain=domain, + redirect_uri=redirect_uri, + client_id=client_id, + client_secret=client_secret, + token={"access_token": access_token}, + ) + wrapper = oauth_cfg.create_client() + wrapper.get_sdk() + return cls(wrapper) + + except Exception as e: + logger.error( + f"Failed to build Bynder client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Bynder.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get Bynder connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error( + f"Failed to get Bynder connector config: {e}" + ) + raise ValueError( + f"Failed to get Bynder connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/canva/canva.py b/backend/python/app/sources/client/canva/canva.py new file mode 100644 index 000000000..06b92c0cd --- /dev/null +++ b/backend/python/app/sources/client/canva/canva.py @@ -0,0 +1,492 @@ +"""Canva client implementation. + +This module provides clients for interacting with the Canva Connect API using either: +1. OAuth 2.0 access token authentication (authorization code flow with PKCE) +2. Pre-generated Bearer token authentication + +Canva Connect API uses OAuth 2.0 with PKCE for authorization. The API does not +require a client_secret; instead, PKCE code_verifier/code_challenge is used. + +Authentication Reference: https://www.canva.dev/docs/connect/authentication/ +API Reference: https://www.canva.dev/docs/connect/ +""" + +import base64 +import json +import logging +from enum import Enum +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class CanvaAuthType(str, Enum): + """Authentication types supported by the Canva connector.""" + + OAUTH = "OAUTH" + TOKEN = "TOKEN" + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class CanvaResponse(BaseModel): + """Standardized Canva API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client classes +# --------------------------------------------------------------------------- + + +class CanvaRESTClientViaOAuth(HTTPClient): + """Canva REST client via OAuth 2.0 authorization code flow with PKCE. + + OAuth tokens are passed as Bearer tokens in the Authorization header. + Canva uses PKCE (no client_secret required), but client_id is sent + in the POST body during token exchange. + + Args: + access_token: The OAuth access token + client_id: OAuth client ID (for token refresh) + base_url: API base URL (default: https://api.canva.com/rest/v1) + """ + + def __init__( + self, + access_token: str, + client_id: str | None = None, + base_url: str = "https://api.canva.com/rest/v1", + ) -> None: + super().__init__(access_token, "Bearer") + self.base_url = base_url + self.access_token = access_token + self.client_id = client_id + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +class CanvaRESTClientViaToken(HTTPClient): + """Canva REST client via pre-generated Bearer token. + + Simple authentication using a pre-generated token passed directly + in the Authorization header. + + Args: + token: The pre-generated Bearer token + base_url: API base URL (default: https://api.canva.com/rest/v1) + """ + + def __init__( + self, + token: str, + base_url: str = "https://api.canva.com/rest/v1", + ) -> None: + super().__init__(token, token_type="Bearer") + self.base_url = base_url + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class CanvaOAuthConfig(BaseModel): + """Configuration for Canva client via OAuth 2.0 (PKCE). + + Args: + access_token: The OAuth access token + client_id: OAuth client ID + base_url: API base URL (default: https://api.canva.com/rest/v1) + """ + + access_token: str + client_id: str | None = None + base_url: str = "https://api.canva.com/rest/v1" + + def create_client(self) -> CanvaRESTClientViaOAuth: + return CanvaRESTClientViaOAuth( + self.access_token, + self.client_id, + self.base_url, + ) + + +class CanvaTokenConfig(BaseModel): + """Configuration for Canva client via pre-generated Bearer token. + + Args: + token: The pre-generated Bearer token + base_url: API base URL (default: https://api.canva.com/rest/v1) + """ + + token: str + base_url: str = "https://api.canva.com/rest/v1" + + def create_client(self) -> CanvaRESTClientViaToken: + return CanvaRESTClientViaToken(self.token, self.base_url) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class CanvaAuthConfig(BaseModel): + """Auth section of the Canva connector configuration from etcd.""" + + authType: CanvaAuthType = CanvaAuthType.OAUTH + clientId: str | None = None + redirectUri: str | None = None + token: str | None = None + oauthConfigId: str | None = None + + class Config: + extra = "allow" + + +class CanvaCredentialsConfig(BaseModel): + """Credentials section of the Canva connector configuration.""" + + access_token: str | None = None + refresh_token: str | None = None + + class Config: + extra = "allow" + + +class CanvaConnectorConfig(BaseModel): + """Top-level Canva connector configuration from etcd.""" + + auth: CanvaAuthConfig = Field(default_factory=CanvaAuthConfig) + credentials: CanvaCredentialsConfig = Field( + default_factory=CanvaCredentialsConfig + ) + + class Config: + extra = "allow" + + +class CanvaSharedOAuthConfigEntry(BaseModel): + """A single entry from the shared OAuth config list in etcd. + + Handles both camelCase and snake_case key variants from the config store. + """ + + entry_id: str | None = Field(default=None, alias="_id") + clientId: str | None = None + client_id: str | None = None + redirectUri: str | None = None + redirect_uri: str | None = None + + class Config: + extra = "allow" + populate_by_name = True + + def resolved_client_id(self, fallback: str = "") -> str: + return self.clientId or self.client_id or fallback + + def resolved_redirect_uri(self, fallback: str = "") -> str: + return self.redirectUri or self.redirect_uri or fallback + + +class CanvaSharedOAuthWrapper(BaseModel): + """Wrapper for a shared OAuth config entry with nested config.""" + + entry_id: str | None = Field(default=None, alias="_id") + config: CanvaSharedOAuthConfigEntry = Field( + default_factory=CanvaSharedOAuthConfigEntry + ) + + class Config: + extra = "allow" + populate_by_name = True + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class CanvaClient(IClient): + """Builder class for Canva clients with different authentication methods. + + Supports: + - OAuth 2.0 authorization code flow with PKCE + - Pre-generated Bearer token + """ + + def __init__( + self, + client: CanvaRESTClientViaOAuth | CanvaRESTClientViaToken, + ) -> None: + """Initialize with a Canva client object.""" + super().__init__() + self.client = client + + @override + def get_client( + self, + ) -> CanvaRESTClientViaOAuth | CanvaRESTClientViaToken: + """Return the Canva client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: CanvaOAuthConfig | CanvaTokenConfig, + ) -> "CanvaClient": + """Build CanvaClient with configuration. + + Args: + config: CanvaOAuthConfig or CanvaTokenConfig instance + + Returns: + CanvaClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "CanvaClient": + """Build CanvaClient using configuration service. + + Supports two authentication strategies: + 1. OAUTH: OAuth 2.0 authorization code flow with PKCE + 2. TOKEN: Pre-generated Bearer token + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + CanvaClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError("Failed to get Canva connector configuration") + + connector_config = CanvaConnectorConfig.model_validate(raw_config) + + if connector_config.auth.authType == CanvaAuthType.OAUTH: + access_token = connector_config.credentials.access_token or "" + client_id = connector_config.auth.clientId or "" + + # Try shared OAuth config if credentials are missing + oauth_config_id = connector_config.auth.oauthConfigId + if oauth_config_id and not client_id: + shared_cfg = await cls._find_shared_oauth_config( + config_service, oauth_config_id, logger + ) + if shared_cfg: + client_id = shared_cfg.resolved_client_id(client_id) + + if not access_token: + raise ValueError( + "Access token required for OAuth auth type" + ) + + oauth_cfg = CanvaOAuthConfig( + access_token=access_token, + client_id=client_id, + ) + return cls(oauth_cfg.create_client()) + + elif connector_config.auth.authType == CanvaAuthType.TOKEN: + token = connector_config.auth.token or "" + if not token: + raise ValueError( + "Token required for TOKEN auth type" + ) + + token_config = CanvaTokenConfig(token=token) + return cls(token_config.create_client()) + + else: + raise ValueError( + f"Invalid auth type: {connector_config.auth.authType}" + ) + + except Exception as e: + logger.error( + f"Failed to build Canva client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "CanvaClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service for shared OAuth config + + Returns: + CanvaClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("credentials", {}) or {} + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + access_token: str = str(credentials.get("access_token", "")) + if not access_token: + raise ValueError("Access token not found in toolset config") + + client_id: str = str(auth_config.get("clientId", "")) + + # Try shared OAuth config + oauth_config_id: str | None = cast( + str | None, auth_config.get("oauthConfigId") + ) + if oauth_config_id and config_service and not client_id: + shared_cfg = await cls._find_shared_oauth_config( + config_service, oauth_config_id, logger + ) + if shared_cfg: + client_id = shared_cfg.resolved_client_id(client_id) + + oauth_cfg = CanvaOAuthConfig( + access_token=access_token, + client_id=client_id, + ) + return cls(oauth_cfg.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Canva client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _find_shared_oauth_config( + config_service: ConfigurationService, + oauth_config_id: str, + logger: logging.Logger, + ) -> CanvaSharedOAuthConfigEntry | None: + """Look up shared OAuth config by ID from the config store. + + Args: + config_service: Configuration service instance + oauth_config_id: The shared OAuth config ID to match + logger: Logger instance + + Returns: + Matched CanvaSharedOAuthConfigEntry or None + """ + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/canva", default=[] + ) + entries: list[object] = list(raw) if isinstance(raw, list) else [] # type: ignore[reportUnknownArgumentType] + for entry in entries: + wrapper = CanvaSharedOAuthWrapper.model_validate(entry) + if wrapper.entry_id == oauth_config_id: + return wrapper.config + except Exception as e: + logger.warning(f"Failed to fetch shared OAuth config: {e}") + return None + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Canva.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get Canva connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error(f"Failed to get Canva connector config: {e}") + raise ValueError( + f"Failed to get Canva connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/coda/coda.py b/backend/python/app/sources/client/coda/coda.py new file mode 100644 index 000000000..3b06a1aa6 --- /dev/null +++ b/backend/python/app/sources/client/coda/coda.py @@ -0,0 +1,522 @@ +"""Coda client implementation. + +This module provides clients for interacting with the Coda API using either: +1. OAuth 2.0 authorization code flow +2. Pre-generated API Token (Bearer token) + +Authentication Reference: https://coda.io/developers/apis/v1#section/Authentication +OAuth Reference: https://coda.io/developers/apis/v1#section/OAuth +API Reference: https://coda.io/developers/apis/v1 +""" + +import base64 +import json +import logging +from enum import Enum +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class CodaAuthType(str, Enum): + """Authentication types supported by the Coda connector.""" + + OAUTH = "OAUTH" + TOKEN = "TOKEN" + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class CodaResponse(BaseModel): + """Standardized Coda API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client classes +# --------------------------------------------------------------------------- + + +class CodaRESTClientViaOAuth(HTTPClient): + """Coda REST client via OAuth 2.0 authorization code flow. + + OAuth tokens are passed as Bearer tokens in the Authorization header. + Supports token refresh via client_id and client_secret. + + Args: + access_token: The OAuth access token + client_id: OAuth client ID (for token refresh) + client_secret: OAuth client secret (for token refresh) + base_url: API base URL (default: https://coda.io/apis/v1) + """ + + def __init__( + self, + access_token: str, + client_id: str | None = None, + client_secret: str | None = None, + base_url: str = "https://coda.io/apis/v1", + ) -> None: + super().__init__(access_token, "Bearer") + self.base_url = base_url + self.access_token = access_token + self.client_id = client_id + self.client_secret = client_secret + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +class CodaRESTClientViaToken(HTTPClient): + """Coda REST client via pre-generated API Token. + + API tokens are passed as Bearer tokens in the Authorization header. + + Args: + token: The pre-generated API token + base_url: API base URL (default: https://coda.io/apis/v1) + """ + + def __init__( + self, + token: str, + base_url: str = "https://coda.io/apis/v1", + ) -> None: + super().__init__(token, token_type="Bearer") + self.base_url = base_url + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class CodaOAuthConfig(BaseModel): + """Configuration for Coda client via OAuth 2.0 authorization code flow. + + Args: + access_token: The OAuth access token + client_id: OAuth client ID + client_secret: OAuth client secret + base_url: API base URL (default: https://coda.io/apis/v1) + """ + + access_token: str + client_id: str | None = None + client_secret: str | None = None + base_url: str = "https://coda.io/apis/v1" + + def create_client(self) -> CodaRESTClientViaOAuth: + return CodaRESTClientViaOAuth( + self.access_token, + self.client_id, + self.client_secret, + self.base_url, + ) + + +class CodaTokenConfig(BaseModel): + """Configuration for Coda client via pre-generated API Token. + + Args: + token: The pre-generated API token + base_url: API base URL (default: https://coda.io/apis/v1) + """ + + token: str + base_url: str = "https://coda.io/apis/v1" + + def create_client(self) -> CodaRESTClientViaToken: + return CodaRESTClientViaToken(self.token, self.base_url) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class CodaAuthConfig(BaseModel): + """Auth section of the Coda connector configuration from etcd.""" + + authType: CodaAuthType = CodaAuthType.TOKEN + apiToken: str | None = None + token: str | None = None + clientId: str | None = None + clientSecret: str | None = None + redirectUri: str | None = None + oauthConfigId: str | None = None + + class Config: + extra = "allow" + + +class CodaCredentialsConfig(BaseModel): + """Credentials section of the Coda connector configuration.""" + + access_token: str | None = None + refresh_token: str | None = None + + class Config: + extra = "allow" + + +class CodaConnectorConfig(BaseModel): + """Top-level Coda connector configuration from etcd.""" + + auth: CodaAuthConfig = Field(default_factory=CodaAuthConfig) + credentials: CodaCredentialsConfig = Field( + default_factory=CodaCredentialsConfig + ) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Shared OAuth configuration models +# --------------------------------------------------------------------------- + + +class CodaSharedOAuthConfigEntry(BaseModel): + """A single entry from the shared OAuth config list in etcd. + + Handles both camelCase and snake_case key variants from the config store. + """ + + entry_id: str | None = Field(default=None, alias="_id") + clientId: str | None = None + client_id: str | None = None + clientSecret: str | None = None + client_secret: str | None = None + redirectUri: str | None = None + redirect_uri: str | None = None + + class Config: + extra = "allow" + populate_by_name = True + + def resolved_client_id(self, fallback: str = "") -> str: + return self.clientId or self.client_id or fallback + + def resolved_client_secret(self, fallback: str = "") -> str: + return self.clientSecret or self.client_secret or fallback + + def resolved_redirect_uri(self, fallback: str = "") -> str: + return self.redirectUri or self.redirect_uri or fallback + + +class CodaSharedOAuthWrapper(BaseModel): + """Wrapper for a shared OAuth config entry with nested config.""" + + entry_id: str | None = Field(default=None, alias="_id") + config: CodaSharedOAuthConfigEntry = Field( + default_factory=CodaSharedOAuthConfigEntry + ) + + class Config: + extra = "allow" + populate_by_name = True + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class CodaClient(IClient): + """Builder class for Coda clients with different authentication methods. + + Supports: + - OAuth 2.0 authorization code flow + - Pre-generated API Token (Bearer token) + """ + + def __init__( + self, + client: CodaRESTClientViaOAuth | CodaRESTClientViaToken, + ) -> None: + """Initialize with a Coda client object.""" + super().__init__() + self.client = client + + @override + def get_client( + self, + ) -> CodaRESTClientViaOAuth | CodaRESTClientViaToken: + """Return the Coda client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: CodaOAuthConfig | CodaTokenConfig, + ) -> "CodaClient": + """Build CodaClient with configuration. + + Args: + config: CodaOAuthConfig or CodaTokenConfig instance + + Returns: + CodaClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "CodaClient": + """Build CodaClient using configuration service. + + Supports two authentication strategies: + 1. OAUTH: OAuth 2.0 authorization code flow with access token + 2. TOKEN: Pre-generated API Token + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + CodaClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError("Failed to get Coda connector configuration") + + connector_config = CodaConnectorConfig.model_validate(raw_config) + + if connector_config.auth.authType == CodaAuthType.OAUTH: + access_token = connector_config.credentials.access_token or "" + client_id = connector_config.auth.clientId or "" + client_secret = connector_config.auth.clientSecret or "" + + # Try shared OAuth config if credentials are missing + oauth_config_id = connector_config.auth.oauthConfigId + if oauth_config_id and not (client_id and client_secret): + shared_cfg = await cls._find_shared_oauth_config( + config_service, oauth_config_id, logger + ) + if shared_cfg: + client_id = shared_cfg.resolved_client_id(client_id) + client_secret = shared_cfg.resolved_client_secret( + client_secret + ) + + if not access_token: + raise ValueError( + "Access token required for OAuth auth type" + ) + + oauth_cfg = CodaOAuthConfig( + access_token=access_token, + client_id=client_id, + client_secret=client_secret, + ) + return cls(oauth_cfg.create_client()) + + elif connector_config.auth.authType == CodaAuthType.TOKEN: + token = ( + connector_config.auth.apiToken + or connector_config.auth.token + or "" + ) + if not token: + raise ValueError( + "API token required for TOKEN auth type" + ) + + token_config = CodaTokenConfig(token=token) + return cls(token_config.create_client()) + + else: + raise ValueError( + f"Invalid auth type: {connector_config.auth.authType}" + ) + + except Exception as e: + logger.error( + f"Failed to build Coda client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "CodaClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service for shared OAuth config + + Returns: + CodaClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("credentials", {}) or {} + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + access_token: str = str(credentials.get("access_token", "")) + if not access_token: + raise ValueError("Access token not found in toolset config") + + client_id: str = str(auth_config.get("clientId", "")) + client_secret: str = str(auth_config.get("clientSecret", "")) + + # Try shared OAuth config + oauth_config_id: str | None = cast( + str | None, auth_config.get("oauthConfigId") + ) + if oauth_config_id and config_service and not ( + client_id and client_secret + ): + shared_cfg = await cls._find_shared_oauth_config( + config_service, oauth_config_id, logger + ) + if shared_cfg: + client_id = shared_cfg.resolved_client_id(client_id) + client_secret = shared_cfg.resolved_client_secret( + client_secret + ) + + oauth_cfg = CodaOAuthConfig( + access_token=access_token, + client_id=client_id, + client_secret=client_secret, + ) + return cls(oauth_cfg.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Coda client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _find_shared_oauth_config( + config_service: ConfigurationService, + oauth_config_id: str, + logger: logging.Logger, + ) -> CodaSharedOAuthConfigEntry | None: + """Look up shared OAuth config by ID from the config store. + + Args: + config_service: Configuration service instance + oauth_config_id: The shared OAuth config ID to match + logger: Logger instance + + Returns: + Matched CodaSharedOAuthConfigEntry or None + """ + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/coda", default=[] + ) + entries: list[object] = list(raw) if isinstance(raw, list) else [] # type: ignore[reportUnknownArgumentType] + for entry in entries: + wrapper = CodaSharedOAuthWrapper.model_validate(entry) + if wrapper.entry_id == oauth_config_id: + return wrapper.config + except Exception as e: + logger.warning(f"Failed to fetch shared OAuth config: {e}") + return None + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Coda.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get Coda connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error(f"Failed to get Coda connector config: {e}") + raise ValueError( + f"Failed to get Coda connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/coupa/coupa.py b/backend/python/app/sources/client/coupa/coupa.py new file mode 100644 index 000000000..c30b51e21 --- /dev/null +++ b/backend/python/app/sources/client/coupa/coupa.py @@ -0,0 +1,454 @@ +"""Coupa client implementation. + +This module provides clients for interacting with the Coupa API using either: +1. API Key authentication (X-COUPA-API-KEY header) +2. OAuth 2.0 client_credentials flow + +Token Endpoint: https://{instance}.coupahost.com/oauth2/token +API Base URL: https://{instance}.coupahost.com/api +""" + +import base64 +import json +import logging +from enum import Enum +from typing import Any, cast + +import httpx # type: ignore +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class CoupaAuthType(str, Enum): + """Authentication types supported by the Coupa connector.""" + + API_KEY = "API_KEY" + OAUTH = "OAUTH" + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class CoupaResponse(BaseModel): + """Standardized Coupa API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client classes +# --------------------------------------------------------------------------- + + +class CoupaRESTClientViaApiKey(HTTPClient): + """Coupa REST client via API Key (X-COUPA-API-KEY header). + + Args: + api_key: The Coupa API key + instance: The Coupa instance name (e.g. 'mycompany') + """ + + def __init__(self, api_key: str, instance: str) -> None: + # Initialize with empty token; we use custom header + super().__init__("", token_type="Bearer") + self.base_url = f"https://{instance}.coupahost.com/api" + self.instance = instance + # Remove default Authorization header, use X-COUPA-API-KEY instead + _ = self.headers.pop("Authorization", None) + self.headers["X-COUPA-API-KEY"] = api_key + self.headers["Content-Type"] = "application/json" + self.headers["Accept"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +class CoupaRESTClientViaOAuth(HTTPClient): + """Coupa REST client via OAuth 2.0 client_credentials. + + Automatically fetches an access token from the Coupa token endpoint. + + Args: + client_id: OAuth client ID + client_secret: OAuth client secret + instance: The Coupa instance name (e.g. 'mycompany') + """ + + def __init__( + self, + client_id: str, + client_secret: str, + instance: str, + ) -> None: + super().__init__("", token_type="Bearer") + self.base_url = f"https://{instance}.coupahost.com/api" + self.instance = instance + self.client_id = client_id + self.client_secret = client_secret + self.token_endpoint = ( + f"https://{instance}.coupahost.com/oauth2/token" + ) + self._access_token: str | None = None + self.headers["Content-Type"] = "application/json" + self.headers["Accept"] = "application/json" + + async def _fetch_token(self) -> str: + """Fetch an access token using client_credentials grant. + + Returns: + Access token string. + """ + async with httpx.AsyncClient() as client: # type: ignore[reportUnknownMemberType] + response = await client.post( # type: ignore[reportUnknownMemberType] + self.token_endpoint, + data={ + "grant_type": "client_credentials", + "client_id": self.client_id, + "client_secret": self.client_secret, + "scope": "core.common.read", + }, + headers={ + "Content-Type": "application/x-www-form-urlencoded", + }, + ) + response.raise_for_status() # type: ignore[reportUnknownMemberType] + token_data: dict[str, Any] = response.json() # type: ignore[reportUnknownMemberType] + access_token: str = str(token_data.get("access_token", "")) # type: ignore[reportUnknownMemberType, reportUnknownArgumentType] + if not access_token: + raise ValueError("No access_token in token response") + return access_token + + async def ensure_token(self) -> None: + """Ensure a valid access token is set in headers.""" + if not self._access_token: + self._access_token = await self._fetch_token() + self.headers["Authorization"] = f"Bearer {self._access_token}" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class CoupaApiKeyConfig(BaseModel): + """Configuration for Coupa client via API Key. + + Args: + api_key: The Coupa API key + instance: The Coupa instance name + """ + + api_key: str + instance: str + + def create_client(self) -> CoupaRESTClientViaApiKey: + return CoupaRESTClientViaApiKey(self.api_key, self.instance) + + +class CoupaOAuthConfig(BaseModel): + """Configuration for Coupa client via OAuth 2.0 client_credentials. + + Args: + client_id: OAuth client ID + client_secret: OAuth client secret + instance: The Coupa instance name + """ + + client_id: str + client_secret: str + instance: str + + def create_client(self) -> CoupaRESTClientViaOAuth: + return CoupaRESTClientViaOAuth( + self.client_id, + self.client_secret, + self.instance, + ) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class CoupaAuthConfigModel(BaseModel): + """Auth section of the Coupa connector configuration from etcd.""" + + authType: CoupaAuthType = CoupaAuthType.API_KEY + apiKey: str | None = None + clientId: str | None = None + clientSecret: str | None = None + instance: str | None = None + + class Config: + extra = "allow" + + +class CoupaConnectorConfig(BaseModel): + """Top-level Coupa connector configuration from etcd.""" + + auth: CoupaAuthConfigModel = Field(default_factory=CoupaAuthConfigModel) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class CoupaClient(IClient): + """Builder class for Coupa clients with different authentication methods. + + Supports: + - API Key authentication (X-COUPA-API-KEY header) + - OAuth 2.0 client_credentials flow + """ + + def __init__( + self, + client: CoupaRESTClientViaApiKey | CoupaRESTClientViaOAuth, + ) -> None: + """Initialize with a Coupa client object.""" + super().__init__() + self.client = client + + @override + def get_client( + self, + ) -> CoupaRESTClientViaApiKey | CoupaRESTClientViaOAuth: + """Return the Coupa client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: CoupaApiKeyConfig | CoupaOAuthConfig, + ) -> "CoupaClient": + """Build CoupaClient with configuration. + + Args: + config: CoupaApiKeyConfig or CoupaOAuthConfig instance + + Returns: + CoupaClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "CoupaClient": + """Build CoupaClient using configuration service. + + Supports two authentication strategies: + 1. API_KEY: API Key via X-COUPA-API-KEY header + 2. OAUTH: OAuth 2.0 client_credentials + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + CoupaClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get Coupa connector configuration" + ) + + connector_config = CoupaConnectorConfig.model_validate(raw_config) + + instance = connector_config.auth.instance or "" + if not instance: + raise ValueError("Coupa instance name is required") + + if connector_config.auth.authType == CoupaAuthType.API_KEY: + api_key = connector_config.auth.apiKey or "" + if not api_key: + raise ValueError( + "API key required for API_KEY auth type" + ) + + config = CoupaApiKeyConfig( + api_key=api_key, + instance=instance, + ) + return cls(config.create_client()) + + elif connector_config.auth.authType == CoupaAuthType.OAUTH: + client_id = connector_config.auth.clientId or "" + client_secret = connector_config.auth.clientSecret or "" + + if not (client_id and client_secret): + raise ValueError( + "client_id and client_secret required for OAuth auth type" + ) + + oauth_config = CoupaOAuthConfig( + client_id=client_id, + client_secret=client_secret, + instance=instance, + ) + return cls(oauth_config.create_client()) + + else: + raise ValueError( + f"Invalid auth type: {connector_config.auth.authType}" + ) + + except Exception as e: + logger.error( + f"Failed to build Coupa client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "CoupaClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service (unused for Coupa) + + Returns: + CoupaClient instance + """ + try: + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + instance: str = str(auth_config.get("instance", "")) + if not instance: + raise ValueError( + "Coupa instance name not found in toolset config" + ) + + auth_type = str(auth_config.get("authType", "API_KEY")) + + if auth_type == "API_KEY": + api_key: str = str(auth_config.get("apiKey", "")) + if not api_key: + raise ValueError( + "API key not found in toolset config" + ) + config: CoupaApiKeyConfig | CoupaOAuthConfig = ( + CoupaApiKeyConfig(api_key=api_key, instance=instance) + ) + else: + client_id: str = str(auth_config.get("clientId", "")) + client_secret: str = str(auth_config.get("clientSecret", "")) + if not (client_id and client_secret): + raise ValueError( + "client_id and client_secret not found in toolset config" + ) + config = CoupaOAuthConfig( + client_id=client_id, + client_secret=client_secret, + instance=instance, + ) + + return cls(config.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Coupa client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Coupa.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get Coupa connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error(f"Failed to get Coupa connector config: {e}") + raise ValueError( + f"Failed to get Coupa connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/darwinbox/darwinbox.py b/backend/python/app/sources/client/darwinbox/darwinbox.py new file mode 100644 index 000000000..b0da960b4 --- /dev/null +++ b/backend/python/app/sources/client/darwinbox/darwinbox.py @@ -0,0 +1,536 @@ +"""DarwinBox client implementation. + +This module provides clients for interacting with the DarwinBox API using either: +1. Basic Auth (api_key:api_secret) +2. OAuth 2.0 access token authentication + +The base URL is constructed from the domain: https://{domain}.darwinbox.in/api + +API Reference: https://developer.darwinbox.com/ +""" + +import base64 +import json +import logging +from enum import Enum +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class DarwinBoxAuthType(str, Enum): + """Authentication types supported by the DarwinBox connector.""" + + BASIC = "BASIC" + OAUTH = "OAUTH" + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class DarwinBoxResponse(BaseModel): + """Standardized DarwinBox API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client classes +# --------------------------------------------------------------------------- + + +class DarwinBoxRESTClientViaBasicAuth(HTTPClient): + """DarwinBox REST client via Basic Auth (api_key:api_secret). + + Args: + api_key: DarwinBox API key + api_secret: DarwinBox API secret + domain: DarwinBox domain (e.g. "yourcompany") + """ + + def __init__( + self, + api_key: str, + api_secret: str, + domain: str, + ) -> None: + super().__init__("", token_type="Basic") + self.base_url = f"https://{domain}.darwinbox.in/api" + self.domain = domain + self.api_key = api_key + self.api_secret = api_secret + credentials = base64.b64encode( + f"{api_key}:{api_secret}".encode() + ).decode("utf-8") + self.headers["Authorization"] = f"Basic {credentials}" + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + def get_domain(self) -> str: + """Get the domain.""" + return self.domain + + +class DarwinBoxRESTClientViaToken(HTTPClient): + """DarwinBox REST client via OAuth 2.0 access token. + + OAuth tokens are passed as Bearer tokens in the Authorization header. + + Args: + access_token: The OAuth access token + domain: DarwinBox domain (e.g. "yourcompany") + client_id: OAuth client ID (for token refresh) + client_secret: OAuth client secret (for token refresh) + """ + + def __init__( + self, + access_token: str, + domain: str, + client_id: str | None = None, + client_secret: str | None = None, + ) -> None: + super().__init__(access_token, "Bearer") + self.base_url = f"https://{domain}.darwinbox.in/api" + self.domain = domain + self.access_token = access_token + self.client_id = client_id + self.client_secret = client_secret + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + def get_domain(self) -> str: + """Get the domain.""" + return self.domain + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class DarwinBoxBasicAuthConfig(BaseModel): + """Configuration for DarwinBox client via Basic Auth. + + Args: + api_key: DarwinBox API key + api_secret: DarwinBox API secret + domain: DarwinBox domain (e.g. "yourcompany") + """ + + api_key: str + api_secret: str + domain: str + + def create_client(self) -> DarwinBoxRESTClientViaBasicAuth: + return DarwinBoxRESTClientViaBasicAuth( + self.api_key, self.api_secret, self.domain + ) + + +class DarwinBoxOAuthConfig(BaseModel): + """Configuration for DarwinBox client via OAuth 2.0. + + Args: + access_token: The OAuth access token + domain: DarwinBox domain (e.g. "yourcompany") + client_id: OAuth client ID + client_secret: OAuth client secret + """ + + access_token: str + domain: str + client_id: str | None = None + client_secret: str | None = None + + def create_client(self) -> DarwinBoxRESTClientViaToken: + return DarwinBoxRESTClientViaToken( + self.access_token, + self.domain, + self.client_id, + self.client_secret, + ) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class DarwinBoxAuthConfigModel(BaseModel): + """Auth section of the DarwinBox connector configuration from etcd.""" + + authType: DarwinBoxAuthType = DarwinBoxAuthType.BASIC + domain: str | None = None + apiKey: str | None = None + apiSecret: str | None = None + token: str | None = None + clientId: str | None = None + clientSecret: str | None = None + redirectUri: str | None = None + oauthConfigId: str | None = None + + class Config: + extra = "allow" + + +class DarwinBoxCredentialsConfig(BaseModel): + """Credentials section of the DarwinBox connector configuration.""" + + access_token: str | None = None + refresh_token: str | None = None + + class Config: + extra = "allow" + + +class DarwinBoxConnectorConfig(BaseModel): + """Top-level DarwinBox connector configuration from etcd.""" + + auth: DarwinBoxAuthConfigModel = Field( + default_factory=DarwinBoxAuthConfigModel + ) + credentials: DarwinBoxCredentialsConfig = Field( + default_factory=DarwinBoxCredentialsConfig + ) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class DarwinBoxClient(IClient): + """Builder class for DarwinBox clients with different authentication methods. + + Supports: + - Basic Auth (api_key:api_secret) + - OAuth 2.0 access token authentication + """ + + def __init__( + self, + client: DarwinBoxRESTClientViaBasicAuth | DarwinBoxRESTClientViaToken, + ) -> None: + """Initialize with a DarwinBox client object.""" + super().__init__() + self.client = client + + @override + def get_client( + self, + ) -> DarwinBoxRESTClientViaBasicAuth | DarwinBoxRESTClientViaToken: + """Return the DarwinBox client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: DarwinBoxBasicAuthConfig | DarwinBoxOAuthConfig, + ) -> "DarwinBoxClient": + """Build DarwinBoxClient with configuration. + + Args: + config: DarwinBoxBasicAuthConfig or DarwinBoxOAuthConfig instance + + Returns: + DarwinBoxClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "DarwinBoxClient": + """Build DarwinBoxClient using configuration service. + + Supports two authentication strategies: + 1. BASIC: Basic Auth with api_key and api_secret + 2. OAUTH: OAuth 2.0 access token + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + DarwinBoxClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get DarwinBox connector configuration" + ) + + connector_config = DarwinBoxConnectorConfig.model_validate( + raw_config + ) + + domain = connector_config.auth.domain or "" + if not domain: + raise ValueError("Domain is required") + + if connector_config.auth.authType == DarwinBoxAuthType.OAUTH: + access_token = ( + connector_config.credentials.access_token or "" + ) + client_id = connector_config.auth.clientId or "" + client_secret = connector_config.auth.clientSecret or "" + + # Try shared OAuth config if credentials are missing + oauth_config_id = connector_config.auth.oauthConfigId + if oauth_config_id and not (client_id and client_secret): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/darwinbox", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + if not access_token: + raise ValueError( + "Access token required for OAuth auth type" + ) + + oauth_cfg = DarwinBoxOAuthConfig( + access_token=access_token, + domain=domain, + client_id=client_id, + client_secret=client_secret, + ) + return cls(oauth_cfg.create_client()) + + elif connector_config.auth.authType == DarwinBoxAuthType.BASIC: + api_key = connector_config.auth.apiKey or "" + api_secret = connector_config.auth.apiSecret or "" + + if not (api_key and api_secret): + raise ValueError( + "API key and secret required for Basic auth type" + ) + + basic_cfg = DarwinBoxBasicAuthConfig( + api_key=api_key, + api_secret=api_secret, + domain=domain, + ) + return cls(basic_cfg.create_client()) + + else: + raise ValueError( + f"Invalid auth type: {connector_config.auth.authType}" + ) + + except Exception as e: + logger.error( + f"Failed to build DarwinBox client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "DarwinBoxClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service for shared OAuth config + + Returns: + DarwinBoxClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("credentials", {}) or {} + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + domain: str = str(auth_config.get("domain", "")) + if not domain: + raise ValueError("Domain not found in toolset config") + + access_token: str = str(credentials.get("access_token", "")) + if not access_token: + raise ValueError( + "Access token not found in toolset config" + ) + + client_id: str = str(auth_config.get("clientId", "")) + client_secret: str = str(auth_config.get("clientSecret", "")) + + # Try shared OAuth config + oauth_config_id: str | None = cast( + str | None, auth_config.get("oauthConfigId") + ) + if oauth_config_id and config_service and not ( + client_id and client_secret + ): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/darwinbox", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + oauth_cfg = DarwinBoxOAuthConfig( + access_token=access_token, + domain=domain, + client_id=client_id, + client_secret=client_secret, + ) + return cls(oauth_cfg.create_client()) + + except Exception as e: + logger.error( + f"Failed to build DarwinBox client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for DarwinBox.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get DarwinBox connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error( + f"Failed to get DarwinBox connector config: {e}" + ) + raise ValueError( + f"Failed to get DarwinBox connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/datadog/datadog.py b/backend/python/app/sources/client/datadog/datadog.py new file mode 100644 index 000000000..11e7fb0d9 --- /dev/null +++ b/backend/python/app/sources/client/datadog/datadog.py @@ -0,0 +1,350 @@ +"""Datadog client implementation using the official datadog-api-client SDK. + +This module provides a client for interacting with the Datadog API using +API Key + Application Key authentication via the official Python SDK. + +Authentication Reference: https://docs.datadoghq.com/api/latest/authentication/ +SDK Reference: https://github.com/DataDog/datadog-api-client-python + +Datadog authenticates via two keys set on the Configuration object: +- apiKeyAuth: The API key +- appKeyAuth: The application key + +The site is set via server_variables["site"] on the Configuration. +""" + +import logging +from typing import Any, cast + +from datadog_api_client import ( # type: ignore[reportMissingImports] + ApiClient, # type: ignore[reportUnknownVariableType] + Configuration, # type: ignore[reportUnknownVariableType] +) +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class DatadogResponse(BaseModel): + """Standardized Datadog API response wrapper.""" + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = None + error: str | None = Field( + default=None, description="Error message if failed" + ) + message: str | None = Field( + default=None, description="Additional message information" + ) + + def to_dict(self) -> dict[str, Any]: + """Convert response to dictionary.""" + return self.model_dump(exclude_none=True) + + +# --------------------------------------------------------------------------- +# SDK client class +# --------------------------------------------------------------------------- + + +class DatadogClientViaApiKey: + """Datadog SDK client via API Key + Application Key. + + Wraps the official ``datadog-api-client`` SDK. Stores the + ``Configuration`` object and creates ``ApiClient`` instances on + demand (the SDK uses a context-manager pattern). + + Args: + api_key: Datadog API key + app_key: Datadog application key + site: Datadog site domain (default: datadoghq.com) + """ + + def __init__( + self, + api_key: str, + app_key: str, + site: str = "datadoghq.com", + ) -> None: + super().__init__() + self.api_key = api_key + self.app_key = app_key + self.site = site + + self._configuration: Any = Configuration() # type: ignore[reportUnknownVariableType] + self._configuration.api_key["apiKeyAuth"] = api_key # type: ignore[reportUnknownMemberType] + self._configuration.api_key["appKeyAuth"] = app_key # type: ignore[reportUnknownMemberType] + self._configuration.server_variables["site"] = site # type: ignore[reportUnknownMemberType] + + def get_sdk(self) -> Any: # Configuration + """Return the SDK ``Configuration`` object. + + Callers should use it with ``ApiClient(configuration)`` as a + context manager to obtain an ``ApiClient`` instance:: + + with ApiClient(config.get_sdk()) as api_client: + api = DashboardsApi(api_client) + dashboards = api.list_dashboards() + """ + return self._configuration # type: ignore[reportUnknownVariableType] + + def get_api_client(self) -> Any: # ApiClient + """Return a new ``ApiClient`` instance. + + The caller is responsible for closing it (or using it as a + context manager). + """ + return ApiClient(self._configuration) # type: ignore[reportUnknownVariableType] + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class DatadogApiKeyConfig(BaseModel): + """Configuration for Datadog client via API Key + Application Key. + + Args: + api_key: Datadog API key + app_key: Datadog application key + site: Datadog site domain (default: datadoghq.com) + """ + + api_key: str + app_key: str + site: str = "datadoghq.com" + + def create_client(self) -> DatadogClientViaApiKey: + return DatadogClientViaApiKey( + self.api_key, + self.app_key, + self.site, + ) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class DatadogAuthConfig(BaseModel): + """Auth section of the Datadog connector configuration from etcd.""" + + authType: str = "API_KEY" + apiKey: str | None = None + applicationKey: str | None = None + site: str | None = None + + class Config: + extra = "allow" + + +class DatadogCredentialsConfig(BaseModel): + """Credentials section of the Datadog connector configuration.""" + + api_key: str | None = None + application_key: str | None = None + + class Config: + extra = "allow" + + +class DatadogConnectorConfig(BaseModel): + """Top-level Datadog connector configuration from etcd.""" + + auth: DatadogAuthConfig = Field(default_factory=DatadogAuthConfig) + credentials: DatadogCredentialsConfig = Field( + default_factory=DatadogCredentialsConfig + ) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class DatadogClient(IClient): + """Builder class for Datadog clients. + + Supports: + - API Key + Application Key authentication + """ + + def __init__(self, client: DatadogClientViaApiKey) -> None: + """Initialize with a Datadog SDK client wrapper.""" + super().__init__() + self.client = client + + @override + def get_client(self) -> DatadogClientViaApiKey: + """Return the Datadog SDK client wrapper.""" + return self.client + + def get_sdk(self) -> Any: # Configuration + """Convenience: return the SDK Configuration.""" + return self.client.get_sdk() # type: ignore[reportUnknownVariableType,reportUnknownMemberType] + + @classmethod + def build_with_config( + cls, + config: DatadogApiKeyConfig, + ) -> "DatadogClient": + """Build DatadogClient with configuration. + + Args: + config: DatadogApiKeyConfig instance + + Returns: + DatadogClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "DatadogClient": + """Build DatadogClient using configuration service. + + Reads API key and application key from the config service (etcd). + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + DatadogClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get Datadog connector configuration" + ) + + connector_config = DatadogConnectorConfig.model_validate( + raw_config + ) + + api_key = ( + connector_config.credentials.api_key + or connector_config.auth.apiKey + or "" + ) + app_key = ( + connector_config.credentials.application_key + or connector_config.auth.applicationKey + or "" + ) + site = connector_config.auth.site or "datadoghq.com" + + if not (api_key and app_key): + raise ValueError( + "api_key and app_key are required " + "for Datadog API_KEY auth type" + ) + + api_key_config = DatadogApiKeyConfig( + api_key=api_key, + app_key=app_key, + site=site, + ) + return cls(api_key_config.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Datadog client from services: {e!s}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "DatadogClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service (unused for Datadog) + + Returns: + DatadogClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("credentials", {}) or {} + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + api_key: str = str( + credentials.get("api_key") + or auth_config.get("apiKey", "") + ) + app_key: str = str( + credentials.get("application_key") + or auth_config.get("applicationKey", "") + ) + site: str = str(auth_config.get("site", "datadoghq.com")) + + if not (api_key and app_key): + raise ValueError( + "API key and application key not found in toolset config" + ) + + api_key_config = DatadogApiKeyConfig( + api_key=api_key, + app_key=app_key, + site=site, + ) + return cls(api_key_config.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Datadog client from toolset: {e!s}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Datadog.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get Datadog connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error(f"Failed to get Datadog connector config: {e}") + raise ValueError( + f"Failed to get Datadog connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/datastax/datastax.py b/backend/python/app/sources/client/datastax/datastax.py new file mode 100644 index 000000000..27bd9ca68 --- /dev/null +++ b/backend/python/app/sources/client/datastax/datastax.py @@ -0,0 +1,415 @@ +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownParameterType=false +"""DataStax Astra DB client implementation. + +This module provides a client for interacting with DataStax Astra DB using the +official ``astrapy`` Python package (Data API). + +Authentication: + - Application Token: Passed to ``DataAPIClient`` + +SDK Reference: https://github.com/datastax/astrapy +""" + +import base64 +import json +import logging +from enum import Enum +from typing import Any, cast + +from astrapy import DataAPIClient +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class DataStaxAuthType(str, Enum): + """Authentication types supported by the DataStax connector.""" + + TOKEN = "TOKEN" + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class DataStaxResponse(BaseModel): + """Standardized DataStax API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# SDK wrapper class +# --------------------------------------------------------------------------- + + +class DataStaxClientViaToken: + """DataStax Astra DB SDK wrapper authenticated via application token. + + Wraps the official ``astrapy`` ``DataAPIClient``. + + Args: + token: The Astra DB application token (e.g. ``AstraCS:...``) + api_endpoint: The database API endpoint URL + """ + + def __init__(self, token: str, api_endpoint: str) -> None: + self.token = token + self.api_endpoint = api_endpoint.rstrip("/") + self._sdk: DataAPIClient | None = None + + def create_client(self) -> DataAPIClient: + """Create and return the SDK client.""" + self._sdk = DataAPIClient(token=self.token) + return self._sdk + + def get_sdk(self) -> DataAPIClient: + """Return the SDK client, creating it lazily if needed.""" + if self._sdk is None: + return self.create_client() + return self._sdk + + def get_api_endpoint(self) -> str: + """Get the database API endpoint.""" + return self.api_endpoint + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class DataStaxTokenConfig(BaseModel): + """Configuration for DataStax client via Application Token. + + Args: + token: The Astra DB application token + api_endpoint: The database API endpoint URL + """ + + token: str + api_endpoint: str + + def create_client(self) -> DataStaxClientViaToken: + return DataStaxClientViaToken( + token=self.token, + api_endpoint=self.api_endpoint, + ) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class DataStaxAuthConfig(BaseModel): + """Auth section of the DataStax connector configuration from etcd.""" + + authType: DataStaxAuthType = DataStaxAuthType.TOKEN + apiToken: str | None = None + token: str | None = None + apiEndpoint: str | None = None + databaseId: str | None = None + region: str | None = None + + class Config: + extra = "allow" + + +class DataStaxCredentialsConfig(BaseModel): + """Credentials section of the DataStax connector configuration.""" + + token: str | None = None + + class Config: + extra = "allow" + + +class DataStaxConnectorConfig(BaseModel): + """Top-level DataStax connector configuration from etcd.""" + + auth: DataStaxAuthConfig = Field(default_factory=DataStaxAuthConfig) + credentials: DataStaxCredentialsConfig = Field( + default_factory=DataStaxCredentialsConfig + ) + apiEndpoint: str | None = None + databaseId: str | None = None + region: str | None = None + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class DataStaxClient(IClient): + """Builder class for DataStax Astra DB clients using the official SDK. + + Supports: + - Application Token authentication via ``astrapy`` + """ + + def __init__( + self, + client: DataStaxClientViaToken, + ) -> None: + """Initialize with a DataStax SDK wrapper.""" + super().__init__() + self.client = client + + @override + def get_client(self) -> DataStaxClientViaToken: + """Return the DataStax SDK wrapper.""" + return self.client + + def get_sdk(self) -> DataAPIClient: + """Return the underlying astrapy DataAPIClient instance.""" + return self.client.get_sdk() + + def get_api_endpoint(self) -> str: + """Return the database API endpoint.""" + return self.client.get_api_endpoint() + + @classmethod + def build_with_config( + cls, + config: DataStaxTokenConfig, + ) -> "DataStaxClient": + """Build DataStaxClient with configuration. + + Args: + config: DataStaxTokenConfig instance + + Returns: + DataStaxClient instance + """ + wrapper = config.create_client() + wrapper.get_sdk() # eagerly initialize + return cls(wrapper) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "DataStaxClient": + """Build DataStaxClient using configuration service. + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + DataStaxClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get DataStax connector configuration" + ) + + connector_config = DataStaxConnectorConfig.model_validate( + raw_config + ) + + token = ( + connector_config.auth.apiToken + or connector_config.auth.token + or connector_config.credentials.token + or "" + ) + if not token: + raise ValueError( + "Application token required for DataStax auth" + ) + + # Resolve API endpoint + api_endpoint = ( + connector_config.auth.apiEndpoint + or connector_config.apiEndpoint + or "" + ) + if not api_endpoint: + # Build from database_id and region if available + database_id = ( + connector_config.auth.databaseId + or connector_config.databaseId + or "" + ) + region = ( + connector_config.auth.region + or connector_config.region + or "" + ) + if database_id and region: + api_endpoint = ( + f"https://{database_id}-{region}" + f".apps.astra.datastax.com" + ) + if not api_endpoint: + raise ValueError( + "API endpoint (or database ID + region) required for DataStax" + ) + + token_config = DataStaxTokenConfig( + token=token, api_endpoint=api_endpoint + ) + wrapper = token_config.create_client() + wrapper.get_sdk() + return cls(wrapper) + + except Exception as e: + logger.error( + f"Failed to build DataStax client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "DataStaxClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service + + Returns: + DataStaxClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("credentials", {}) or {} + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + token: str = str( + credentials.get("token", "") + or auth_config.get("apiToken", "") + or auth_config.get("token", "") + ) + if not token: + raise ValueError( + "Application token not found in toolset config" + ) + + api_endpoint: str = str( + auth_config.get("apiEndpoint", "") + or toolset_config.get("apiEndpoint", "") + ) + if not api_endpoint: + database_id = str( + auth_config.get("databaseId", "") + or toolset_config.get("databaseId", "") + ) + region = str( + auth_config.get("region", "") + or toolset_config.get("region", "") + ) + if database_id and region: + api_endpoint = ( + f"https://{database_id}-{region}" + f".apps.astra.datastax.com" + ) + if not api_endpoint: + raise ValueError( + "API endpoint not found in toolset config" + ) + + token_config = DataStaxTokenConfig( + token=token, api_endpoint=api_endpoint + ) + wrapper = token_config.create_client() + wrapper.get_sdk() + return cls(wrapper) + + except Exception as e: + logger.error( + f"Failed to build DataStax client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for DataStax.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get DataStax connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error( + f"Failed to get DataStax connector config: {e}" + ) + raise ValueError( + f"Failed to get DataStax connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/docebo/docebo.py b/backend/python/app/sources/client/docebo/docebo.py new file mode 100644 index 000000000..553922b91 --- /dev/null +++ b/backend/python/app/sources/client/docebo/docebo.py @@ -0,0 +1,518 @@ +"""Docebo client implementation. + +This module provides clients for interacting with the Docebo API using either: +1. OAuth2 client_credentials grant (auto-fetches token) +2. Pre-generated Bearer Token + +Authentication Reference: https://www.docebo.com/knowledge-base/docebo-api-authentication/ +API Reference: https://www.docebo.com/knowledge-base/docebo-api/ +""" + +import base64 +import json +import logging +from enum import Enum +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.http.http_request import HTTPRequest +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class DoceboAuthType(str, Enum): + """Authentication types supported by the Docebo connector.""" + + CLIENT_CREDENTIALS = "CLIENT_CREDENTIALS" + TOKEN = "TOKEN" + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class DoceboResponse(BaseModel): + """Standardized Docebo API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client classes +# --------------------------------------------------------------------------- + + +class DoceboRESTClientViaClientCredentials(HTTPClient): + """Docebo REST client via OAuth2 client_credentials grant. + + Automatically fetches an access token from the Docebo OAuth2 token + endpoint on first use via ensure_authenticated(). + + Args: + client_id: OAuth2 client ID + client_secret: OAuth2 client secret + domain: The Docebo domain (e.g., "mycompany" for + mycompany.docebosaas.com) + base_url: Optional full base URL override + """ + + def __init__( + self, + client_id: str, + client_secret: str, + domain: str, + base_url: str | None = None, + ) -> None: + # Initialize with empty token; will be set after authentication + super().__init__("", token_type="Bearer") + self.base_url = base_url or f"https://{domain}.docebosaas.com/api" + self.domain = domain + self.client_id = client_id + self.client_secret = client_secret + self._authenticated = False + self.token_endpoint = ( + f"https://{domain}.docebosaas.com/oauth2/token" + ) + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + async def ensure_authenticated(self) -> None: + """Fetch an access token via client_credentials grant. + + Posts to the Docebo token endpoint with grant_type=client_credentials + and client_id/client_secret in the request body. + """ + if self._authenticated: + return + + token_request = HTTPRequest( + url=self.token_endpoint, + method="POST", + headers={ + "Content-Type": "application/x-www-form-urlencoded", + }, + body={ + "grant_type": "client_credentials", + "client_id": self.client_id, + "client_secret": self.client_secret, + }, + ) + + response = await self.execute(token_request) # type: ignore[reportUnknownMemberType] + response_data = response.json() + + access_token = response_data.get("access_token") + if not access_token: + raise ValueError( + "Failed to obtain access token from Docebo OAuth2: " + f"{response_data}" + ) + + self.headers["Authorization"] = f"Bearer {access_token}" + self._authenticated = True + + +class DoceboRESTClientViaToken(HTTPClient): + """Docebo REST client via pre-generated Bearer Token. + + Simple authentication using a pre-generated token passed directly + in the Authorization header. + + Args: + token: The Bearer token + domain: The Docebo domain (e.g., "mycompany" for + mycompany.docebosaas.com) + base_url: Optional full base URL override + """ + + def __init__( + self, + token: str, + domain: str, + base_url: str | None = None, + ) -> None: + super().__init__(token, token_type="Bearer") + self.base_url = base_url or f"https://{domain}.docebosaas.com/api" + self.domain = domain + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class DoceboClientCredentialsConfig(BaseModel): + """Configuration for Docebo client via OAuth2 client_credentials. + + Args: + client_id: OAuth2 client ID + client_secret: OAuth2 client secret + domain: The Docebo domain + base_url: Optional full base URL override + """ + + client_id: str + client_secret: str + domain: str + base_url: str | None = None + + def create_client(self) -> DoceboRESTClientViaClientCredentials: + return DoceboRESTClientViaClientCredentials( + self.client_id, + self.client_secret, + self.domain, + self.base_url, + ) + + +class DoceboTokenConfig(BaseModel): + """Configuration for Docebo client via Bearer Token. + + Args: + token: The Bearer token + domain: The Docebo domain + base_url: Optional full base URL override + """ + + token: str + domain: str + base_url: str | None = None + + def create_client(self) -> DoceboRESTClientViaToken: + return DoceboRESTClientViaToken( + self.token, self.domain, self.base_url + ) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class DoceboAuthConfig(BaseModel): + """Auth section of the Docebo connector configuration from etcd.""" + + authType: DoceboAuthType = DoceboAuthType.CLIENT_CREDENTIALS + clientId: str | None = None + clientSecret: str | None = None + token: str | None = None + domain: str | None = None + baseUrl: str | None = None + + class Config: + extra = "allow" + + +class DoceboCredentialsConfig(BaseModel): + """Credentials section of the Docebo connector configuration.""" + + access_token: str | None = None + client_id: str | None = None + client_secret: str | None = None + + class Config: + extra = "allow" + + +class DoceboConnectorConfig(BaseModel): + """Top-level Docebo connector configuration from etcd.""" + + auth: DoceboAuthConfig = Field(default_factory=DoceboAuthConfig) + credentials: DoceboCredentialsConfig = Field( + default_factory=DoceboCredentialsConfig + ) + domain: str = "" + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class DoceboClient(IClient): + """Builder class for Docebo clients with different authentication methods. + + Supports: + - OAuth2 client_credentials grant (auto-fetches token) + - Pre-generated Bearer Token + """ + + def __init__( + self, + client: ( + DoceboRESTClientViaClientCredentials | DoceboRESTClientViaToken + ), + ) -> None: + """Initialize with a Docebo client object.""" + super().__init__() + self.client = client + + @override + def get_client( + self, + ) -> DoceboRESTClientViaClientCredentials | DoceboRESTClientViaToken: + """Return the Docebo client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: DoceboClientCredentialsConfig | DoceboTokenConfig, + ) -> "DoceboClient": + """Build DoceboClient with configuration. + + Args: + config: DoceboClientCredentialsConfig or DoceboTokenConfig instance + + Returns: + DoceboClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "DoceboClient": + """Build DoceboClient using configuration service. + + Supports two authentication strategies: + 1. CLIENT_CREDENTIALS: For OAuth2 client_credentials grant + 2. TOKEN: For pre-generated Bearer token + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + DoceboClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get Docebo connector configuration" + ) + + connector_config = DoceboConnectorConfig.model_validate( + raw_config + ) + domain = ( + connector_config.auth.domain or connector_config.domain or "" + ) + base_url = connector_config.auth.baseUrl or None + + if not domain and not base_url: + raise ValueError("Docebo domain or base URL is required") + + if connector_config.auth.authType == DoceboAuthType.TOKEN: + token = ( + connector_config.credentials.access_token + or connector_config.auth.token + or "" + ) + if not token: + raise ValueError( + "Token required for TOKEN auth type" + ) + + token_config = DoceboTokenConfig( + token=token, domain=domain, base_url=base_url + ) + return cls(token_config.create_client()) + + else: + # Default: CLIENT_CREDENTIALS + client_id = ( + connector_config.credentials.client_id + or connector_config.auth.clientId + or "" + ) + client_secret = ( + connector_config.credentials.client_secret + or connector_config.auth.clientSecret + or "" + ) + + if not client_id or not client_secret: + raise ValueError( + "Client ID and secret required for " + "CLIENT_CREDENTIALS auth type" + ) + + cc_config = DoceboClientCredentialsConfig( + client_id=client_id, + client_secret=client_secret, + domain=domain, + base_url=base_url, + ) + return cls(cc_config.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Docebo client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "DoceboClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service + + Returns: + DoceboClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], + toolset_config.get("credentials", {}) or {}, + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + domain: str = str(toolset_config.get("domain", "")) + base_url: str | None = cast( + str | None, toolset_config.get("baseUrl") + ) + auth_type = auth_config.get("authType", "CLIENT_CREDENTIALS") + + if auth_type == "TOKEN": + token = str( + credentials.get("access_token", "") + or auth_config.get("token", "") + ) + if not token: + raise ValueError( + "Token not found in toolset config" + ) + token_cfg = DoceboTokenConfig( + token=token, domain=domain, base_url=base_url + ) + return cls(token_cfg.create_client()) + + else: + # Default: CLIENT_CREDENTIALS + client_id = str( + credentials.get("client_id", "") + or auth_config.get("clientId", "") + ) + client_secret = str( + credentials.get("client_secret", "") + or auth_config.get("clientSecret", "") + ) + if not client_id or not client_secret: + raise ValueError( + "Client ID and secret not found in toolset config" + ) + cc_cfg = DoceboClientCredentialsConfig( + client_id=client_id, + client_secret=client_secret, + domain=domain, + base_url=base_url, + ) + return cls(cc_cfg.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Docebo client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Docebo.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get Docebo connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error(f"Failed to get Docebo connector config: {e}") + raise ValueError( + f"Failed to get Docebo connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/docusign/docusign.py b/backend/python/app/sources/client/docusign/docusign.py new file mode 100644 index 000000000..acd656826 --- /dev/null +++ b/backend/python/app/sources/client/docusign/docusign.py @@ -0,0 +1,607 @@ +"""DocuSign client implementation. + +Uses the official docusign-esign SDK for eSignature API, and HTTP client +for Admin, Rooms, Click, Monitor, and WebForms REST APIs. + +Authentication modes: +1. OAuth 2.0 authorization code flow (access_token + account_id) +2. Pre-generated Bearer token (token + account_id) + +SDK Reference: https://pypi.org/project/docusign-esign/ +API Reference: https://developers.docusign.com/docs/esign-rest-api/reference/ +""" + +import logging +from enum import Enum +from typing import Any, cast + +import docusign_esign # type: ignore[reportMissingImports] +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class DocuSignAuthType(str, Enum): + """Authentication types supported by the DocuSign connector.""" + + OAUTH = "OAUTH" + TOKEN = "TOKEN" + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class DocuSignResponse(BaseModel): + """Standardized DocuSign API response wrapper.""" + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data from the SDK or HTTP" + ) + error: str | None = Field( + default=None, description="Error message if failed" + ) + message: str | None = Field( + default=None, description="Additional message information" + ) + + def to_dict(self) -> dict[str, Any]: + """Convert response to dictionary.""" + return self.model_dump(exclude_none=True) + + +# --------------------------------------------------------------------------- +# SDK + HTTP client classes +# --------------------------------------------------------------------------- + + +class DocuSignClientViaOAuth: + """DocuSign client via OAuth 2.0 authorization code flow. + + Creates a ``docusign_esign.ApiClient`` for eSignature operations and + lazy ``HTTPClient`` instances for Admin, Rooms, Click, Monitor, and + WebForms REST APIs. + + Args: + access_token: The OAuth access token + account_id: DocuSign account ID (used in API calls) + base_path: eSign API base path (default: demo environment) + """ + + def __init__( + self, + access_token: str, + account_id: str, + base_path: str = "https://demo.docusign.net/restapi", + ) -> None: + super().__init__() + self.access_token = access_token + self.account_id = account_id + self.base_path = base_path + + self._sdk: Any = None # docusign_esign.ApiClient + self._http_clients: dict[str, HTTPClient] = {} + + def create_client(self) -> Any: # docusign_esign.ApiClient + """Create and configure the SDK ApiClient.""" + self._sdk = docusign_esign.ApiClient(base_path=self.base_path) # type: ignore[reportUnknownMemberType] + self._sdk.set_default_header( # type: ignore[reportUnknownMemberType] + "Authorization", f"Bearer {self.access_token}" + ) + return self._sdk # type: ignore[reportUnknownMemberType,reportUnknownVariableType] + + def get_sdk(self) -> Any: # docusign_esign.ApiClient + """Return the SDK ApiClient, lazily creating it if needed.""" + if self._sdk is None: + return self.create_client() # type: ignore[reportUnknownVariableType] + return self._sdk # type: ignore[reportUnknownVariableType] + + def get_http_client(self, base_url: str) -> HTTPClient: + """Return an HTTPClient configured for the given base URL. + + Clients are cached per base_url so repeated calls return the + same instance. + + Args: + base_url: The base URL for the target REST API. + + Returns: + HTTPClient ready to execute requests. + """ + if base_url not in self._http_clients: + client = HTTPClient(self.access_token, token_type="Bearer") + client.base_url = base_url # type: ignore[attr-defined] + client.headers["Content-Type"] = "application/json" + self._http_clients[base_url] = client + return self._http_clients[base_url] + + def get_access_token(self) -> str: + """Return the access token.""" + return self.access_token + + def get_account_id(self) -> str: + """Return the account ID.""" + return self.account_id + + +class DocuSignClientViaToken: + """DocuSign client via pre-generated Bearer token. + + Functionally identical to the OAuth variant but semantically distinct + for configuration clarity. + + Args: + token: The pre-generated Bearer token + account_id: DocuSign account ID (used in API calls) + base_path: eSign API base path (default: demo environment) + """ + + def __init__( + self, + token: str, + account_id: str, + base_path: str = "https://demo.docusign.net/restapi", + ) -> None: + super().__init__() + self.token = token + self.account_id = account_id + self.base_path = base_path + + self._sdk: Any = None # docusign_esign.ApiClient + self._http_clients: dict[str, HTTPClient] = {} + + def create_client(self) -> Any: # docusign_esign.ApiClient + """Create and configure the SDK ApiClient.""" + self._sdk = docusign_esign.ApiClient(base_path=self.base_path) # type: ignore[reportUnknownMemberType] + self._sdk.set_default_header( # type: ignore[reportUnknownMemberType] + "Authorization", f"Bearer {self.token}" + ) + return self._sdk # type: ignore[reportUnknownMemberType,reportUnknownVariableType] + + def get_sdk(self) -> Any: # docusign_esign.ApiClient + """Return the SDK ApiClient, lazily creating it if needed.""" + if self._sdk is None: + return self.create_client() # type: ignore[reportUnknownVariableType] + return self._sdk # type: ignore[reportUnknownVariableType] + + def get_http_client(self, base_url: str) -> HTTPClient: + """Return an HTTPClient configured for the given base URL. + + Clients are cached per base_url so repeated calls return the + same instance. + + Args: + base_url: The base URL for the target REST API. + + Returns: + HTTPClient ready to execute requests. + """ + if base_url not in self._http_clients: + client = HTTPClient(self.token, token_type="Bearer") + client.base_url = base_url # type: ignore[attr-defined] + client.headers["Content-Type"] = "application/json" + self._http_clients[base_url] = client + return self._http_clients[base_url] + + def get_access_token(self) -> str: + """Return the token.""" + return self.token + + def get_account_id(self) -> str: + """Return the account ID.""" + return self.account_id + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class DocuSignOAuthConfig(BaseModel): + """Configuration for DocuSign client via OAuth 2.0. + + Args: + access_token: The OAuth access token + account_id: DocuSign account ID + base_path: API base path (default: demo environment) + """ + + access_token: str + account_id: str + base_path: str = "https://demo.docusign.net/restapi" + + def create_client(self) -> DocuSignClientViaOAuth: + return DocuSignClientViaOAuth( + self.access_token, + self.account_id, + self.base_path, + ) + + +class DocuSignTokenConfig(BaseModel): + """Configuration for DocuSign client via pre-generated Bearer token. + + Args: + token: The pre-generated Bearer token + account_id: DocuSign account ID + base_path: API base path (default: demo environment) + """ + + token: str + account_id: str + base_path: str = "https://demo.docusign.net/restapi" + + def create_client(self) -> DocuSignClientViaToken: + return DocuSignClientViaToken( + self.token, self.account_id, self.base_path + ) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class DocuSignAuthConfig(BaseModel): + """Auth section of the DocuSign connector configuration from etcd.""" + + authType: DocuSignAuthType = DocuSignAuthType.OAUTH + clientId: str | None = None + clientSecret: str | None = None + redirectUri: str | None = None + token: str | None = None + accountId: str | None = None + oauthConfigId: str | None = None + + class Config: + extra = "allow" + + +class DocuSignCredentialsConfig(BaseModel): + """Credentials section of the DocuSign connector configuration.""" + + access_token: str | None = None + refresh_token: str | None = None + + class Config: + extra = "allow" + + +class DocuSignConnectorConfig(BaseModel): + """Top-level DocuSign connector configuration from etcd.""" + + auth: DocuSignAuthConfig = Field(default_factory=DocuSignAuthConfig) + credentials: DocuSignCredentialsConfig = Field( + default_factory=DocuSignCredentialsConfig + ) + accountId: str | None = None + baseUrl: str | None = None + + class Config: + extra = "allow" + + +class DocuSignSharedOAuthConfigEntry(BaseModel): + """A single entry from the shared OAuth config list in etcd. + + Handles both camelCase and snake_case key variants from the config store. + """ + + entry_id: str | None = Field(default=None, alias="_id") + clientId: str | None = None + client_id: str | None = None + clientSecret: str | None = None + client_secret: str | None = None + redirectUri: str | None = None + redirect_uri: str | None = None + + class Config: + extra = "allow" + populate_by_name = True + + def resolved_client_id(self, fallback: str = "") -> str: + return self.clientId or self.client_id or fallback + + def resolved_client_secret(self, fallback: str = "") -> str: + return self.clientSecret or self.client_secret or fallback + + def resolved_redirect_uri(self, fallback: str = "") -> str: + return self.redirectUri or self.redirect_uri or fallback + + +class DocuSignSharedOAuthWrapper(BaseModel): + """Wrapper for a shared OAuth config entry with nested config.""" + + entry_id: str | None = Field(default=None, alias="_id") + config: DocuSignSharedOAuthConfigEntry = Field( + default_factory=DocuSignSharedOAuthConfigEntry + ) + + class Config: + extra = "allow" + populate_by_name = True + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class DocuSignClient(IClient): + """Builder class for DocuSign clients with different authentication methods. + + Supports: + - OAuth 2.0 authorization code flow + - Pre-generated Bearer token + """ + + def __init__( + self, + client: DocuSignClientViaOAuth | DocuSignClientViaToken, + ) -> None: + """Initialize with a DocuSign client wrapper.""" + super().__init__() + self.client = client + + @override + def get_client( + self, + ) -> DocuSignClientViaOAuth | DocuSignClientViaToken: + """Return the DocuSign client wrapper.""" + return self.client + + def get_sdk(self) -> Any: # docusign_esign.ApiClient + """Return the underlying SDK ApiClient.""" + return self.client.get_sdk() + + def get_account_id(self) -> str: + """Return the account ID.""" + return self.client.get_account_id() + + @classmethod + def build_with_config( + cls, + config: DocuSignOAuthConfig | DocuSignTokenConfig, + ) -> "DocuSignClient": + """Build DocuSignClient with configuration. + + Args: + config: DocuSignOAuthConfig or DocuSignTokenConfig instance + + Returns: + DocuSignClient instance + """ + client = config.create_client() + client.get_sdk() # eagerly initialise the SDK + return cls(client) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "DocuSignClient": + """Build DocuSignClient using configuration service. + + Supports two authentication strategies: + 1. OAUTH: OAuth 2.0 authorization code flow with access token + 2. TOKEN: Pre-generated Bearer token + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + DocuSignClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get DocuSign connector configuration" + ) + + connector_config = DocuSignConnectorConfig.model_validate( + raw_config + ) + + # Resolve account ID from auth config or top-level + account_id = ( + connector_config.auth.accountId + or connector_config.accountId + or "" + ) + base_path = ( + connector_config.baseUrl + or "https://demo.docusign.net/restapi" + ) + + if not account_id: + raise ValueError( + "account_id is required for DocuSign connector" + ) + + if connector_config.auth.authType == DocuSignAuthType.OAUTH: + access_token = connector_config.credentials.access_token or "" + + # Try shared OAuth config if credentials are missing + oauth_config_id = connector_config.auth.oauthConfigId + if oauth_config_id: + shared_cfg = await cls._find_shared_oauth_config( + config_service, oauth_config_id, logger + ) + if shared_cfg: + logger.debug( + "Resolved shared OAuth config for DocuSign" + ) + + if not access_token: + raise ValueError( + "Access token required for OAuth auth type" + ) + + oauth_cfg = DocuSignOAuthConfig( + access_token=access_token, + account_id=account_id, + base_path=base_path, + ) + return cls.build_with_config(oauth_cfg) + + elif connector_config.auth.authType == DocuSignAuthType.TOKEN: + token = connector_config.auth.token or "" + if not token: + raise ValueError( + "Token required for TOKEN auth type" + ) + + token_config = DocuSignTokenConfig( + token=token, + account_id=account_id, + base_path=base_path, + ) + return cls.build_with_config(token_config) + + else: + raise ValueError( + f"Invalid auth type: {connector_config.auth.authType}" + ) + + except Exception as e: + logger.error( + f"Failed to build DocuSign client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "DocuSignClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service for shared OAuth config + + Returns: + DocuSignClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("credentials", {}) or {} + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + access_token: str = str(credentials.get("access_token", "")) + if not access_token: + raise ValueError("Access token not found in toolset config") + + account_id: str = str( + toolset_config.get("accountId", "") + or auth_config.get("accountId", "") + ) + if not account_id: + raise ValueError("Account ID not found in toolset config") + + base_path: str = str( + toolset_config.get( + "baseUrl", "https://demo.docusign.net/restapi" + ) + ) + + # Try shared OAuth config + oauth_config_id: str | None = cast( + str | None, auth_config.get("oauthConfigId") + ) + if oauth_config_id and config_service: + shared_cfg = await cls._find_shared_oauth_config( + config_service, oauth_config_id, logger + ) + if shared_cfg: + logger.debug( + "Resolved shared OAuth config for DocuSign toolset" + ) + + oauth_cfg = DocuSignOAuthConfig( + access_token=access_token, + account_id=account_id, + base_path=base_path, + ) + return cls.build_with_config(oauth_cfg) + + except Exception as e: + logger.error( + f"Failed to build DocuSign client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _find_shared_oauth_config( + config_service: ConfigurationService, + oauth_config_id: str, + logger: logging.Logger, + ) -> DocuSignSharedOAuthConfigEntry | None: + """Look up shared OAuth config by ID from the config store. + + Args: + config_service: Configuration service instance + oauth_config_id: The shared OAuth config ID to match + logger: Logger instance + + Returns: + Matched DocuSignSharedOAuthConfigEntry or None + """ + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/docusign", default=[] + ) + entries: list[object] = list(raw) if isinstance(raw, list) else [] # type: ignore[reportUnknownArgumentType] + for entry in entries: + wrapper = DocuSignSharedOAuthWrapper.model_validate(entry) + if wrapper.entry_id == oauth_config_id: + return wrapper.config + except Exception as e: + logger.warning(f"Failed to fetch shared OAuth config: {e}") + return None + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for DocuSign.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get DocuSign connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error(f"Failed to get DocuSign connector config: {e}") + raise ValueError( + f"Failed to get DocuSign connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/dokuwiki/dokuwiki.py b/backend/python/app/sources/client/dokuwiki/dokuwiki.py new file mode 100644 index 000000000..4da371638 --- /dev/null +++ b/backend/python/app/sources/client/dokuwiki/dokuwiki.py @@ -0,0 +1,327 @@ +"""DokuWiki client implementation. + +This module provides a client for interacting with DokuWiki via XML-RPC. +DokuWiki exposes its API through XML-RPC at /lib/exe/xmlrpc.php. + +Authentication is done via Basic Auth embedded in the XML-RPC transport URL. +This client does NOT extend HTTPClient since it uses xmlrpc.client.ServerProxy. + +API Reference: https://www.dokuwiki.org/devel:xmlrpc +""" + +import logging +import xmlrpc.client +from typing import Any, cast +from urllib.parse import quote + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class DokuWikiResponse(BaseModel): + """Standardized DokuWiki API response wrapper. + + The data field supports various response types from the XML-RPC API. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | str | int | bool | None = Field( + default=None, description="Response data from XML-RPC call" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary.""" + return self.model_dump(exclude_none=True) + + def to_json(self) -> str: + """Convert response to JSON string.""" + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# XML-RPC client class +# --------------------------------------------------------------------------- + + +class DokuWikiClientViaBasicAuth: + """DokuWiki XML-RPC client via Basic Auth. + + Credentials are embedded in the XML-RPC URL for transport-level + authentication. Uses Python's xmlrpc.client.ServerProxy. + + Args: + instance_url: DokuWiki instance domain (e.g. "wiki.example.com") + username: DokuWiki username + password: DokuWiki password + """ + + def __init__( + self, + instance_url: str, + username: str, + password: str, + ) -> None: + self.instance_url = instance_url + self.username = username + # URL-encode credentials to handle special characters + encoded_user = quote(username, safe="") + encoded_pass = quote(password, safe="") + url = ( + f"https://{encoded_user}:{encoded_pass}" + f"@{instance_url}/lib/exe/xmlrpc.php" + ) + self._server = xmlrpc.client.ServerProxy(url) + + def get_sdk(self) -> xmlrpc.client.ServerProxy: + """Return the XML-RPC server proxy.""" + return self._server + + def get_instance_url(self) -> str: + """Get the instance URL.""" + return self.instance_url + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class DokuWikiBasicAuthConfig(BaseModel): + """Configuration for DokuWiki client via Basic Auth. + + Args: + instance_url: DokuWiki instance domain (e.g. "wiki.example.com") + username: DokuWiki username + password: DokuWiki password + """ + + instance_url: str + username: str + password: str + + def create_client(self) -> DokuWikiClientViaBasicAuth: + return DokuWikiClientViaBasicAuth( + self.instance_url, self.username, self.password + ) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class DokuWikiAuthConfigModel(BaseModel): + """Auth section of the DokuWiki connector configuration from etcd.""" + + instanceUrl: str | None = None + username: str | None = None + password: str | None = None + + class Config: + extra = "allow" + + +class DokuWikiConnectorConfig(BaseModel): + """Top-level DokuWiki connector configuration from etcd.""" + + auth: DokuWikiAuthConfigModel = Field( + default_factory=DokuWikiAuthConfigModel + ) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class DokuWikiClient(IClient): + """Builder class for DokuWiki clients. + + Supports: + - Basic Auth (username + password) via XML-RPC transport + """ + + def __init__( + self, + client: DokuWikiClientViaBasicAuth, + ) -> None: + """Initialize with a DokuWiki client object.""" + super().__init__() + self.client = client + + @override + def get_client(self) -> DokuWikiClientViaBasicAuth: + """Return the DokuWiki client object.""" + return self.client + + def get_sdk(self) -> xmlrpc.client.ServerProxy: + """Return the XML-RPC server proxy for direct method calls.""" + return self.client.get_sdk() + + def get_instance_url(self) -> str: + """Return the instance URL.""" + return self.client.get_instance_url() + + @classmethod + def build_with_config( + cls, + config: DokuWikiBasicAuthConfig, + ) -> "DokuWikiClient": + """Build DokuWikiClient with configuration. + + Args: + config: DokuWikiBasicAuthConfig instance + + Returns: + DokuWikiClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "DokuWikiClient": + """Build DokuWikiClient using configuration service. + + Supports Basic Auth via XML-RPC transport. + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + DokuWikiClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get DokuWiki connector configuration" + ) + + connector_config = DokuWikiConnectorConfig.model_validate( + raw_config + ) + + instance_url = connector_config.auth.instanceUrl or "" + username = connector_config.auth.username or "" + password = connector_config.auth.password or "" + + if not instance_url: + raise ValueError("Instance URL is required") + if not (username and password): + raise ValueError( + "Username and password are required" + ) + + basic_cfg = DokuWikiBasicAuthConfig( + instance_url=instance_url, + username=username, + password=password, + ) + return cls(basic_cfg.create_client()) + + except Exception as e: + logger.error( + f"Failed to build DokuWiki client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "DokuWikiClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service + + Returns: + DokuWikiClient instance + """ + try: + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + instance_url: str = str(auth_config.get("instanceUrl", "")) + if not instance_url: + raise ValueError( + "Instance URL not found in toolset config" + ) + + username: str = str(auth_config.get("username", "")) + password: str = str(auth_config.get("password", "")) + if not (username and password): + raise ValueError( + "Username and password not found in toolset config" + ) + + basic_cfg = DokuWikiBasicAuthConfig( + instance_url=instance_url, + username=username, + password=password, + ) + return cls(basic_cfg.create_client()) + + except Exception as e: + logger.error( + f"Failed to build DokuWiki client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for DokuWiki.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get DokuWiki connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error( + f"Failed to get DokuWiki connector config: {e}" + ) + raise ValueError( + f"Failed to get DokuWiki connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/egnyte/egnyte.py b/backend/python/app/sources/client/egnyte/egnyte.py new file mode 100644 index 000000000..af4a53ff4 --- /dev/null +++ b/backend/python/app/sources/client/egnyte/egnyte.py @@ -0,0 +1,453 @@ +"""Egnyte client implementation. + +This module provides clients for interacting with the Egnyte API using either: +1. OAuth 2.0 access token authentication +2. Pre-generated Access Token (Bearer) + +Authentication Reference: https://developers.egnyte.com/docs +API Base URL: https://{domain}.egnyte.com/pubapi/v1 +OAuth Token Endpoint: https://{domain}.egnyte.com/puboauth/token +""" + +import base64 +import json +import logging +from enum import Enum +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class EgnyteAuthType(str, Enum): + """Authentication types supported by the Egnyte connector.""" + + OAUTH = "OAUTH" + ACCESS_TOKEN = "ACCESS_TOKEN" + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class EgnyteResponse(BaseModel): + """Standardized Egnyte API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field( + ..., description="Whether the request was successful" + ) + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, + description="Response data (JSON) or file content (bytes)", + ) + error: str | None = Field( + default=None, description="Error message if failed" + ) + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client classes +# --------------------------------------------------------------------------- + + +class EgnyteRESTClientViaToken(HTTPClient): + """Egnyte REST client via pre-generated Access Token. + + Args: + token: The access token (Bearer) + domain: Egnyte domain (e.g. 'mycompany' for mycompany.egnyte.com) + timeout: Request timeout in seconds + """ + + def __init__( + self, + token: str, + domain: str, + timeout: float = 30.0, + ) -> None: + super().__init__(token, token_type="Bearer", timeout=timeout) + self.domain = domain + self.base_url = f"https://{domain}.egnyte.com/pubapi/v1" + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + def get_domain(self) -> str: + """Get the Egnyte domain.""" + return self.domain + + +class EgnyteRESTClientViaOAuth(HTTPClient): + """Egnyte REST client via OAuth 2.0 access token. + + OAuth tokens are passed as Bearer tokens in the Authorization header. + + Args: + access_token: The OAuth access token + domain: Egnyte domain (e.g. 'mycompany' for mycompany.egnyte.com) + client_id: OAuth client ID (for reference / token refresh) + client_secret: OAuth client secret (for reference / token refresh) + timeout: Request timeout in seconds + """ + + def __init__( + self, + access_token: str, + domain: str, + client_id: str | None = None, + client_secret: str | None = None, + timeout: float = 30.0, + ) -> None: + super().__init__(access_token, "Bearer", timeout=timeout) + self.domain = domain + self.base_url = f"https://{domain}.egnyte.com/pubapi/v1" + self.access_token = access_token + self.client_id = client_id + self.client_secret = client_secret + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + def get_domain(self) -> str: + """Get the Egnyte domain.""" + return self.domain + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class EgnyteTokenConfig(BaseModel): + """Configuration for Egnyte client via Access Token. + + Args: + token: The access token + domain: Egnyte domain name (e.g. 'mycompany') + """ + + token: str + domain: str + + def create_client(self) -> EgnyteRESTClientViaToken: + return EgnyteRESTClientViaToken(self.token, self.domain) + + +class EgnyteOAuthConfig(BaseModel): + """Configuration for Egnyte client via OAuth 2.0. + + Args: + access_token: The OAuth access token + domain: Egnyte domain name (e.g. 'mycompany') + client_id: OAuth client ID + client_secret: OAuth client secret + """ + + access_token: str + domain: str + client_id: str | None = None + client_secret: str | None = None + + def create_client(self) -> EgnyteRESTClientViaOAuth: + return EgnyteRESTClientViaOAuth( + self.access_token, + self.domain, + self.client_id, + self.client_secret, + ) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class EgnyteAuthConfig(BaseModel): + """Auth section of the Egnyte connector configuration from etcd.""" + + authType: EgnyteAuthType = EgnyteAuthType.ACCESS_TOKEN + apiToken: str | None = None + token: str | None = None + domain: str | None = None + clientId: str | None = None + clientSecret: str | None = None + redirectUri: str | None = None + oauthConfigId: str | None = None + + class Config: + extra = "allow" + + +class EgnyteCredentialsConfig(BaseModel): + """Credentials section of the Egnyte connector configuration.""" + + access_token: str | None = None + refresh_token: str | None = None + + class Config: + extra = "allow" + + +class EgnyteConnectorConfig(BaseModel): + """Top-level Egnyte connector configuration from etcd.""" + + auth: EgnyteAuthConfig = Field(default_factory=EgnyteAuthConfig) + credentials: EgnyteCredentialsConfig = Field( + default_factory=EgnyteCredentialsConfig + ) + domain: str | None = None + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class EgnyteClient(IClient): + """Builder class for Egnyte clients with different authentication methods. + + Supports: + - Access Token authentication + - OAuth 2.0 access token authentication + """ + + def __init__( + self, + client: EgnyteRESTClientViaToken | EgnyteRESTClientViaOAuth, + ) -> None: + """Initialize with an Egnyte client object.""" + super().__init__() + self.client = client + + @override + def get_client( + self, + ) -> EgnyteRESTClientViaToken | EgnyteRESTClientViaOAuth: + """Return the Egnyte client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @property + def domain(self) -> str: + """Return the Egnyte domain.""" + return self.client.get_domain() + + @classmethod + def build_with_config( + cls, + config: EgnyteTokenConfig | EgnyteOAuthConfig, + ) -> "EgnyteClient": + """Build EgnyteClient with configuration. + + Args: + config: EgnyteTokenConfig or EgnyteOAuthConfig instance + + Returns: + EgnyteClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "EgnyteClient": + """Build EgnyteClient using configuration service. + + Supports two authentication strategies: + 1. ACCESS_TOKEN: For pre-generated access tokens + 2. OAUTH: For OAuth 2.0 access tokens + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + EgnyteClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get Egnyte connector configuration" + ) + + connector_config = EgnyteConnectorConfig.model_validate( + raw_config + ) + + domain = ( + connector_config.domain + or connector_config.auth.domain + or "" + ) + if not domain: + raise ValueError( + "Egnyte domain is required in configuration" + ) + + if connector_config.auth.authType == EgnyteAuthType.OAUTH: + access_token = ( + connector_config.credentials.access_token or "" + ) + client_id = connector_config.auth.clientId or "" + client_secret = connector_config.auth.clientSecret or "" + + # Try shared OAuth config if credentials are missing + oauth_config_id = connector_config.auth.oauthConfigId + if oauth_config_id and not (client_id and client_secret): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/egnyte", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + if not access_token: + raise ValueError( + "Access token required for OAuth auth type" + ) + + oauth_cfg = EgnyteOAuthConfig( + access_token=access_token, + domain=domain, + client_id=client_id, + client_secret=client_secret, + ) + return cls(oauth_cfg.create_client()) + + elif ( + connector_config.auth.authType + == EgnyteAuthType.ACCESS_TOKEN + ): + token = ( + connector_config.auth.apiToken + or connector_config.auth.token + or "" + ) + if not token: + raise ValueError( + "Access token required for ACCESS_TOKEN auth type" + ) + + token_config = EgnyteTokenConfig( + token=token, domain=domain + ) + return cls(token_config.create_client()) + + else: + raise ValueError( + f"Invalid auth type: {connector_config.auth.authType}" + ) + + except Exception as e: + logger.error( + f"Failed to build Egnyte client from services: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Egnyte.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get Egnyte connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error(f"Failed to get Egnyte connector config: {e}") + raise ValueError( + f"Failed to get Egnyte connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/elasticsearch_db/elasticsearch_db.py b/backend/python/app/sources/client/elasticsearch_db/elasticsearch_db.py new file mode 100644 index 000000000..742314c9d --- /dev/null +++ b/backend/python/app/sources/client/elasticsearch_db/elasticsearch_db.py @@ -0,0 +1,356 @@ +import logging +from typing import Any + +from elasticsearch import Elasticsearch # type: ignore[reportMissingImports] +from pydantic import BaseModel, Field +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.iclient import IClient + + +class ElasticsearchResponse(BaseModel): + success: bool + data: Any | None = None + error: str | None = None + message: str | None = None + + def to_dict(self) -> dict[str, Any]: + return self.model_dump() + + +class ElasticsearchClientViaApiKey: + def __init__( + self, + hosts: list[str], + api_key_id: str, + api_key_secret: str, + *, + verify_certs: bool = True, + ca_certs: str | None = None, + request_timeout: float | None = None, + ) -> None: + super().__init__() + self.hosts = hosts + self.api_key_id = api_key_id + self.api_key_secret = api_key_secret + self.verify_certs = verify_certs + self.ca_certs = ca_certs + self.request_timeout = request_timeout + + self._sdk: Elasticsearch | None = None + + def create_client(self) -> Any: # Elasticsearch + kwargs: dict[str, Any] = { + "hosts": self.hosts, + "api_key": (self.api_key_id, self.api_key_secret), + "verify_certs": self.verify_certs, + } + if self.ca_certs is not None: + kwargs["ca_certs"] = self.ca_certs + if self.request_timeout is not None: + kwargs["request_timeout"] = self.request_timeout + + self._sdk = Elasticsearch(**kwargs) # type: ignore[no-untyped-call] + try: + self._sdk.info() # type: ignore[no-untyped-call] + except Exception as e: + raise RuntimeError("Elasticsearch authentication failed") from e + + return self._sdk # type: ignore[reportUnknownVariableType] + + def get_sdk(self) -> Any: # Elasticsearch + if self._sdk is None: # type: ignore[reportUnknownMemberType] + return self.create_client() # type: ignore[reportUnknownVariableType] + return self._sdk # type: ignore[reportUnknownVariableType] + + def get_base_url(self) -> str: + return self.hosts[0] if self.hosts else "" + + +class ElasticsearchClientViaBasicAuth: + def __init__( + self, + hosts: list[str], + username: str, + password: str, + *, + verify_certs: bool = True, + ca_certs: str | None = None, + request_timeout: float | None = None, + ) -> None: + super().__init__() + self.hosts = hosts + self.username = username + self.password = password + self.verify_certs = verify_certs + self.ca_certs = ca_certs + self.request_timeout = request_timeout + + self._sdk: Elasticsearch | None = None + + def create_client(self) -> Any: # Elasticsearch + kwargs: dict[str, Any] = { + "hosts": self.hosts, + "basic_auth": (self.username, self.password), + "verify_certs": self.verify_certs, + } + if self.ca_certs is not None: + kwargs["ca_certs"] = self.ca_certs + if self.request_timeout is not None: + kwargs["request_timeout"] = self.request_timeout + + self._sdk = Elasticsearch(**kwargs) # type: ignore[no-untyped-call] + try: + self._sdk.info() # type: ignore[no-untyped-call] + except Exception as e: + raise RuntimeError("Elasticsearch authentication failed") from e + + return self._sdk # type: ignore[reportUnknownVariableType] + + def get_sdk(self) -> Any: # Elasticsearch + if self._sdk is None: # type: ignore[reportUnknownMemberType] + return self.create_client() # type: ignore[reportUnknownVariableType] + return self._sdk # type: ignore[reportUnknownVariableType] + + def get_base_url(self) -> str: + return self.hosts[0] if self.hosts else "" + + +class ElasticsearchClientViaToken: + def __init__( + self, + hosts: list[str], + token: str, + *, + verify_certs: bool = True, + ca_certs: str | None = None, + request_timeout: float | None = None, + ) -> None: + super().__init__() + self.hosts = hosts + self.token = token + self.verify_certs = verify_certs + self.ca_certs = ca_certs + self.request_timeout = request_timeout + + self._sdk: Elasticsearch | None = None + + def create_client(self) -> Any: # Elasticsearch + kwargs: dict[str, Any] = { + "hosts": self.hosts, + "bearer_auth": self.token, + "verify_certs": self.verify_certs, + } + if self.ca_certs is not None: + kwargs["ca_certs"] = self.ca_certs + if self.request_timeout is not None: + kwargs["request_timeout"] = self.request_timeout + + self._sdk = Elasticsearch(**kwargs) # type: ignore[no-untyped-call] + try: + self._sdk.info() # type: ignore[no-untyped-call] + except Exception as e: + raise RuntimeError("Elasticsearch authentication failed") from e + + return self._sdk # type: ignore[reportUnknownVariableType] + + def get_sdk(self) -> Any: # Elasticsearch + if self._sdk is None: # type: ignore[reportUnknownMemberType] + return self.create_client() # type: ignore[reportUnknownVariableType] + return self._sdk # type: ignore[reportUnknownVariableType] + + def get_base_url(self) -> str: + return self.hosts[0] if self.hosts else "" + + +class ElasticsearchApiKeyConfig(BaseModel): + hosts: list[str] = Field(..., description="Elasticsearch host URLs") + api_key_id: str = Field(..., description="API key ID") + api_key_secret: str = Field(..., description="API key secret") + verify_certs: bool = Field(default=True, description="Verify TLS certificates") + ca_certs: str | None = Field( + default=None, description="Path to CA certificate bundle" + ) + request_timeout: float | None = None + + def create_client( + self, + ) -> ElasticsearchClientViaApiKey: + return ElasticsearchClientViaApiKey( + hosts=self.hosts, + api_key_id=self.api_key_id, + api_key_secret=self.api_key_secret, + verify_certs=self.verify_certs, + ca_certs=self.ca_certs, + request_timeout=self.request_timeout, + ) + + +class ElasticsearchBasicAuthConfig(BaseModel): + hosts: list[str] = Field(..., description="Elasticsearch host URLs") + username: str = Field(..., description="Username") + password: str = Field(..., description="Password") + verify_certs: bool = Field(default=True, description="Verify TLS certificates") + ca_certs: str | None = Field( + default=None, description="Path to CA certificate bundle" + ) + request_timeout: float | None = None + + def create_client( + self, + ) -> ElasticsearchClientViaBasicAuth: + return ElasticsearchClientViaBasicAuth( + hosts=self.hosts, + username=self.username, + password=self.password, + verify_certs=self.verify_certs, + ca_certs=self.ca_certs, + request_timeout=self.request_timeout, + ) + + +class ElasticsearchTokenConfig(BaseModel): + hosts: list[str] = Field(..., description="Elasticsearch host URLs") + token: str = Field(..., description="Bearer token") + verify_certs: bool = Field(default=True, description="Verify TLS certificates") + ca_certs: str | None = Field( + default=None, description="Path to CA certificate bundle" + ) + request_timeout: float | None = None + + def create_client( + self, + ) -> ElasticsearchClientViaToken: + return ElasticsearchClientViaToken( + hosts=self.hosts, + token=self.token, + verify_certs=self.verify_certs, + ca_certs=self.ca_certs, + request_timeout=self.request_timeout, + ) + + +# Union type for all client wrapper flavors +ElasticsearchClientWrapper = ( + ElasticsearchClientViaApiKey + | ElasticsearchClientViaBasicAuth + | ElasticsearchClientViaToken +) + + +class ElasticsearchClient(IClient): + def __init__(self, client: ElasticsearchClientWrapper) -> None: + super().__init__() + self.client = client + + @override + def get_client(self) -> ElasticsearchClientWrapper: + return self.client + + def get_sdk(self) -> Any: # Elasticsearch + return self.client.get_sdk() # type: ignore[reportUnknownMemberType] + + @classmethod + def build_with_config( + cls, + config: ElasticsearchApiKeyConfig + | ElasticsearchBasicAuthConfig + | ElasticsearchTokenConfig, + ) -> "ElasticsearchClient": + client = config.create_client() + _ = client.get_sdk() # type: ignore[reportUnknownMemberType] + return cls(client) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "ElasticsearchClient": + """Build ElasticsearchClient using configuration service.""" + config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not config: + raise ValueError( + "Failed to get Elasticsearch connector configuration" + ) + auth_config = config.get("auth", {}) + auth_type = auth_config.get("authType", "API_KEY") + hosts = auth_config.get("hosts", []) + verify_certs = auth_config.get("verifyCerts", True) + ca_certs = auth_config.get("caCerts") + request_timeout = auth_config.get("requestTimeout") + + if auth_type == "API_KEY": + api_key_id = auth_config.get("apiKeyId", "") + api_key_secret = auth_config.get("apiKeySecret", "") + if not api_key_id or not api_key_secret: + raise ValueError( + "apiKeyId and apiKeySecret required for API_KEY auth" + ) + wrapper: ElasticsearchClientWrapper = ElasticsearchClientViaApiKey( + hosts=hosts, + api_key_id=api_key_id, + api_key_secret=api_key_secret, + verify_certs=verify_certs, + ca_certs=ca_certs, + request_timeout=request_timeout, + ) + elif auth_type == "BASIC_AUTH": + username = auth_config.get("username", "") + password = auth_config.get("password", "") + if not username or not password: + raise ValueError( + "username and password required for BASIC_AUTH" + ) + wrapper = ElasticsearchClientViaBasicAuth( + hosts=hosts, + username=username, + password=password, + verify_certs=verify_certs, + ca_certs=ca_certs, + request_timeout=request_timeout, + ) + elif auth_type == "BEARER_TOKEN": + token = auth_config.get("token", "") + if not token: + raise ValueError("token required for BEARER_TOKEN auth") + wrapper = ElasticsearchClientViaToken( + hosts=hosts, + token=token, + verify_certs=verify_certs, + ca_certs=ca_certs, + request_timeout=request_timeout, + ) + else: + raise ValueError(f"Invalid auth type: {auth_type}") + + _ = wrapper.create_client() + return cls(wrapper) + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Elasticsearch.""" + try: + config: dict[str, Any] = await config_service.get_config( # type: ignore[assignment] + f"/services/connectors/{connector_instance_id}/config" + ) + if not config: + raise ValueError( + f"Failed to get Elasticsearch connector configuration for instance {connector_instance_id}" + ) + return config + except Exception as e: + logger.error( + "Failed to get Elasticsearch connector config: %s", e + ) + raise ValueError( + f"Failed to get Elasticsearch connector configuration for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/esalesmanager/esalesmanager.py b/backend/python/app/sources/client/esalesmanager/esalesmanager.py new file mode 100644 index 000000000..0a125c6e5 --- /dev/null +++ b/backend/python/app/sources/client/esalesmanager/esalesmanager.py @@ -0,0 +1,330 @@ +"""eSalesManager client implementation. + +This module provides a client for interacting with the eSalesManager API +using API Key (X-API-Key header) authentication. + +API Reference: https://api.esalesmanager.jp/v1 +""" + +import base64 +import json +import logging +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class ESalesManagerResponse(BaseModel): + """Standardized eSalesManager API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client class +# --------------------------------------------------------------------------- + + +class ESalesManagerRESTClientViaApiKey(HTTPClient): + """eSalesManager REST client via API Key (X-API-Key header). + + The API key is sent in the X-API-Key header instead of the standard + Authorization header. + + Args: + api_key: The API key + base_url: API base URL (default: https://api.esalesmanager.jp/v1) + """ + + def __init__( + self, + api_key: str, + base_url: str = "https://api.esalesmanager.jp/v1", + ) -> None: + # Initialize with empty token; we set X-API-Key header instead + super().__init__("", token_type="Bearer") + self.base_url = base_url + self.api_key = api_key + # Remove the default Authorization header and set X-API-Key + _ = self.headers.pop("Authorization", None) + self.headers["X-API-Key"] = api_key + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class ESalesManagerApiKeyConfig(BaseModel): + """Configuration for eSalesManager client via API Key. + + Args: + api_key: The API key + base_url: API base URL (default: https://api.esalesmanager.jp/v1) + """ + + api_key: str + base_url: str = "https://api.esalesmanager.jp/v1" + + def create_client(self) -> ESalesManagerRESTClientViaApiKey: + return ESalesManagerRESTClientViaApiKey(self.api_key, self.base_url) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class ESalesManagerAuthConfig(BaseModel): + """Auth section of the eSalesManager connector configuration from etcd.""" + + apiKey: str | None = None + apiToken: str | None = None + token: str | None = None + + class Config: + extra = "allow" + + +class ESalesManagerCredentialsConfig(BaseModel): + """Credentials section of the eSalesManager connector configuration.""" + + api_key: str | None = None + + class Config: + extra = "allow" + + +class ESalesManagerConnectorConfig(BaseModel): + """Top-level eSalesManager connector configuration from etcd.""" + + auth: ESalesManagerAuthConfig = Field( + default_factory=ESalesManagerAuthConfig + ) + credentials: ESalesManagerCredentialsConfig = Field( + default_factory=ESalesManagerCredentialsConfig + ) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class ESalesManagerClient(IClient): + """Builder class for eSalesManager clients. + + Supports: + - API Key (X-API-Key header) authentication + """ + + def __init__( + self, + client: ESalesManagerRESTClientViaApiKey, + ) -> None: + """Initialize with an eSalesManager client object.""" + super().__init__() + self.client = client + + @override + def get_client(self) -> ESalesManagerRESTClientViaApiKey: + """Return the eSalesManager client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: ESalesManagerApiKeyConfig, + ) -> "ESalesManagerClient": + """Build ESalesManagerClient with configuration. + + Args: + config: ESalesManagerApiKeyConfig instance + + Returns: + ESalesManagerClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "ESalesManagerClient": + """Build ESalesManagerClient using configuration service. + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + ESalesManagerClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get eSalesManager connector configuration" + ) + + connector_config = ESalesManagerConnectorConfig.model_validate( + raw_config + ) + + api_key = ( + connector_config.auth.apiKey + or connector_config.auth.apiToken + or connector_config.auth.token + or connector_config.credentials.api_key + or "" + ) + if not api_key: + raise ValueError( + "API key required for eSalesManager authentication" + ) + + api_key_config = ESalesManagerApiKeyConfig(api_key=api_key) + return cls(api_key_config.create_client()) + + except Exception as e: + logger.error( + f"Failed to build eSalesManager client from services: " + f"{str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "ESalesManagerClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service + + Returns: + ESalesManagerClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("credentials", {}) or {} + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + api_key: str = str( + credentials.get("api_key", "") + or auth_config.get("apiKey", "") + or auth_config.get("apiToken", "") + or auth_config.get("token", "") + ) + if not api_key: + raise ValueError( + "API key not found in toolset config" + ) + + api_key_config = ESalesManagerApiKeyConfig(api_key=api_key) + return cls(api_key_config.create_client()) + + except Exception as e: + logger.error( + f"Failed to build eSalesManager client from toolset: " + f"{str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for eSalesManager.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get eSalesManager connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error( + f"Failed to get eSalesManager connector config: {e}" + ) + raise ValueError( + f"Failed to get eSalesManager connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/fellow/fellow.py b/backend/python/app/sources/client/fellow/fellow.py new file mode 100644 index 000000000..9745c3a88 --- /dev/null +++ b/backend/python/app/sources/client/fellow/fellow.py @@ -0,0 +1,482 @@ +"""Fellow client implementation. + +This module provides clients for interacting with the Fellow API using either: +1. OAuth 2.0 access token authentication +2. API Key (Bearer) authentication + +OAuth Auth Endpoint: https://fellow.app/oauth/authorize +OAuth Token Endpoint: https://fellow.app/oauth/token +API Reference: https://api.fellow.app/v2 +""" + +import base64 +import json +import logging +from enum import Enum +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class FellowAuthType(str, Enum): + """Authentication types supported by the Fellow connector.""" + + OAUTH = "OAUTH" + TOKEN = "TOKEN" + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class FellowResponse(BaseModel): + """Standardized Fellow API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client classes +# --------------------------------------------------------------------------- + + +class FellowRESTClientViaOAuth(HTTPClient): + """Fellow REST client via OAuth 2.0 access token. + + OAuth tokens are passed as Bearer tokens in the Authorization header. + + Args: + access_token: The OAuth access token + client_id: OAuth client ID (for token refresh) + client_secret: OAuth client secret (for token refresh) + """ + + def __init__( + self, + access_token: str, + client_id: str | None = None, + client_secret: str | None = None, + ) -> None: + super().__init__(access_token, "Bearer") + self.base_url = "https://api.fellow.app/v2" + self.access_token = access_token + self.client_id = client_id + self.client_secret = client_secret + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +class FellowRESTClientViaToken(HTTPClient): + """Fellow REST client via API Key (Bearer). + + API keys are passed as Bearer tokens in the Authorization header. + + Args: + token: The API key + """ + + def __init__(self, token: str) -> None: + super().__init__(token, "Bearer") + self.base_url = "https://api.fellow.app/v2" + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class FellowOAuthConfig(BaseModel): + """Configuration for Fellow client via OAuth 2.0. + + Args: + access_token: The OAuth access token + client_id: OAuth client ID + client_secret: OAuth client secret + """ + + access_token: str + client_id: str | None = None + client_secret: str | None = None + + def create_client(self) -> FellowRESTClientViaOAuth: + return FellowRESTClientViaOAuth( + self.access_token, + self.client_id, + self.client_secret, + ) + + +class FellowTokenConfig(BaseModel): + """Configuration for Fellow client via API Key. + + Args: + token: The API key + """ + + token: str + + def create_client(self) -> FellowRESTClientViaToken: + return FellowRESTClientViaToken(self.token) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class FellowAuthConfigModel(BaseModel): + """Auth section of the Fellow connector configuration from etcd.""" + + authType: FellowAuthType = FellowAuthType.OAUTH + apiToken: str | None = None + token: str | None = None + clientId: str | None = None + clientSecret: str | None = None + redirectUri: str | None = None + oauthConfigId: str | None = None + + class Config: + extra = "allow" + + +class FellowCredentialsConfig(BaseModel): + """Credentials section of the Fellow connector configuration.""" + + access_token: str | None = None + refresh_token: str | None = None + + class Config: + extra = "allow" + + +class FellowConnectorConfig(BaseModel): + """Top-level Fellow connector configuration from etcd.""" + + auth: FellowAuthConfigModel = Field(default_factory=FellowAuthConfigModel) + credentials: FellowCredentialsConfig = Field( + default_factory=FellowCredentialsConfig + ) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class FellowClient(IClient): + """Builder class for Fellow clients with different authentication methods. + + Supports: + - OAuth 2.0 access token authentication + - API Key (Bearer) authentication + """ + + def __init__( + self, + client: FellowRESTClientViaOAuth | FellowRESTClientViaToken, + ) -> None: + """Initialize with a Fellow client object.""" + super().__init__() + self.client = client + + @override + def get_client( + self, + ) -> FellowRESTClientViaOAuth | FellowRESTClientViaToken: + """Return the Fellow client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: FellowOAuthConfig | FellowTokenConfig, + ) -> "FellowClient": + """Build FellowClient with configuration. + + Args: + config: FellowOAuthConfig or FellowTokenConfig instance + + Returns: + FellowClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "FellowClient": + """Build FellowClient using configuration service. + + Supports two authentication strategies: + 1. OAUTH: OAuth 2.0 access token + 2. TOKEN: API Key (Bearer) + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + FellowClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get Fellow connector configuration" + ) + + connector_config = FellowConnectorConfig.model_validate(raw_config) + + if connector_config.auth.authType == FellowAuthType.OAUTH: + access_token = connector_config.credentials.access_token or "" + client_id = connector_config.auth.clientId or "" + client_secret = connector_config.auth.clientSecret or "" + + # Try shared OAuth config if credentials are missing + oauth_config_id = connector_config.auth.oauthConfigId + if oauth_config_id and not (client_id and client_secret): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/fellow", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + if not access_token: + raise ValueError( + "Access token required for OAuth auth type" + ) + + oauth_cfg = FellowOAuthConfig( + access_token=access_token, + client_id=client_id, + client_secret=client_secret, + ) + return cls(oauth_cfg.create_client()) + + elif connector_config.auth.authType == FellowAuthType.TOKEN: + token = ( + connector_config.auth.apiToken + or connector_config.auth.token + or "" + ) + if not token: + raise ValueError( + "API key required for TOKEN auth type" + ) + + token_config = FellowTokenConfig(token=token) + return cls(token_config.create_client()) + + else: + raise ValueError( + f"Invalid auth type: {connector_config.auth.authType}" + ) + + except Exception as e: + logger.error( + f"Failed to build Fellow client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "FellowClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service for shared OAuth config + + Returns: + FellowClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("credentials", {}) or {} + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + access_token: str = str(credentials.get("access_token", "")) + if not access_token: + raise ValueError( + "Access token not found in toolset config" + ) + + client_id: str = str(auth_config.get("clientId", "")) + client_secret: str = str(auth_config.get("clientSecret", "")) + + # Try shared OAuth config + oauth_config_id: str | None = cast( + str | None, auth_config.get("oauthConfigId") + ) + if oauth_config_id and config_service and not ( + client_id and client_secret + ): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/fellow", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + oauth_cfg = FellowOAuthConfig( + access_token=access_token, + client_id=client_id, + client_secret=client_secret, + ) + return cls(oauth_cfg.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Fellow client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Fellow.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get Fellow connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error(f"Failed to get Fellow connector config: {e}") + raise ValueError( + f"Failed to get Fellow connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/fifteenfive/fifteenfive.py b/backend/python/app/sources/client/fifteenfive/fifteenfive.py new file mode 100644 index 000000000..c5a1bb312 --- /dev/null +++ b/backend/python/app/sources/client/fifteenfive/fifteenfive.py @@ -0,0 +1,320 @@ +"""15Five client implementation. + +This module provides a client for interacting with the 15Five API using +API Key (Bearer token) authentication. + +Note: Class names use FifteenFive since Python identifiers cannot start +with a digit. + +API Reference: https://my.15five.com/api/public +""" + +import base64 +import json +import logging +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class FifteenFiveResponse(BaseModel): + """Standardized 15Five API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client class +# --------------------------------------------------------------------------- + + +class FifteenFiveRESTClientViaToken(HTTPClient): + """15Five REST client via API Key (Bearer token). + + Args: + token: The API key (Bearer token) + base_url: API base URL (default: https://my.15five.com/api/public) + """ + + def __init__( + self, + token: str, + base_url: str = "https://my.15five.com/api/public", + ) -> None: + super().__init__(token, token_type="Bearer") + self.base_url = base_url + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class FifteenFiveTokenConfig(BaseModel): + """Configuration for 15Five client via API Key. + + Args: + token: The API key (Bearer token) + base_url: API base URL (default: https://my.15five.com/api/public) + """ + + token: str + base_url: str = "https://my.15five.com/api/public" + + def create_client(self) -> FifteenFiveRESTClientViaToken: + return FifteenFiveRESTClientViaToken(self.token, self.base_url) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class FifteenFiveAuthConfig(BaseModel): + """Auth section of the 15Five connector configuration from etcd.""" + + apiToken: str | None = None + token: str | None = None + + class Config: + extra = "allow" + + +class FifteenFiveCredentialsConfig(BaseModel): + """Credentials section of the 15Five connector configuration.""" + + access_token: str | None = None + + class Config: + extra = "allow" + + +class FifteenFiveConnectorConfig(BaseModel): + """Top-level 15Five connector configuration from etcd.""" + + auth: FifteenFiveAuthConfig = Field( + default_factory=FifteenFiveAuthConfig + ) + credentials: FifteenFiveCredentialsConfig = Field( + default_factory=FifteenFiveCredentialsConfig + ) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class FifteenFiveClient(IClient): + """Builder class for 15Five clients. + + Supports: + - API Key (Bearer token) authentication + """ + + def __init__( + self, + client: FifteenFiveRESTClientViaToken, + ) -> None: + """Initialize with a 15Five client object.""" + super().__init__() + self.client = client + + @override + def get_client(self) -> FifteenFiveRESTClientViaToken: + """Return the 15Five client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: FifteenFiveTokenConfig, + ) -> "FifteenFiveClient": + """Build FifteenFiveClient with configuration. + + Args: + config: FifteenFiveTokenConfig instance + + Returns: + FifteenFiveClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "FifteenFiveClient": + """Build FifteenFiveClient using configuration service. + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + FifteenFiveClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get 15Five connector configuration" + ) + + connector_config = FifteenFiveConnectorConfig.model_validate( + raw_config + ) + + token = ( + connector_config.auth.apiToken + or connector_config.auth.token + or connector_config.credentials.access_token + or "" + ) + if not token: + raise ValueError( + "API token required for 15Five authentication" + ) + + token_config = FifteenFiveTokenConfig(token=token) + return cls(token_config.create_client()) + + except Exception as e: + logger.error( + f"Failed to build 15Five client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "FifteenFiveClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service + + Returns: + FifteenFiveClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("credentials", {}) or {} + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + token: str = str( + credentials.get("access_token", "") + or auth_config.get("apiToken", "") + or auth_config.get("token", "") + ) + if not token: + raise ValueError( + "API token not found in toolset config" + ) + + token_config = FifteenFiveTokenConfig(token=token) + return cls(token_config.create_client()) + + except Exception as e: + logger.error( + f"Failed to build 15Five client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for 15Five.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get 15Five connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error( + f"Failed to get 15Five connector config: {e}" + ) + raise ValueError( + f"Failed to get 15Five connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/figma/figma.py b/backend/python/app/sources/client/figma/figma.py new file mode 100644 index 000000000..a03002187 --- /dev/null +++ b/backend/python/app/sources/client/figma/figma.py @@ -0,0 +1,482 @@ +"""Figma client implementation. + +This module provides clients for interacting with the Figma API using either: +1. OAuth 2.0 access token authentication +2. Personal Access Token (PAT) authentication (Bearer token) + +Figma API Base URL: https://api.figma.com/v1 +OAuth Authorization: https://www.figma.com/oauth +OAuth Token Exchange: https://www.figma.com/api/oauth/token + +Authentication Reference: https://www.figma.com/developers/api#authentication +API Reference: https://www.figma.com/developers/api +""" + +import base64 +import json +import logging +from enum import Enum +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class FigmaAuthType(str, Enum): + """Authentication types supported by the Figma connector.""" + + OAUTH = "OAUTH" + TOKEN = "TOKEN" + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class FigmaResponse(BaseModel): + """Standardized Figma API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client classes +# --------------------------------------------------------------------------- + + +class FigmaRESTClientViaOAuth(HTTPClient): + """Figma REST client via OAuth 2.0 access token. + + OAuth tokens are passed as Bearer tokens in the Authorization header. + + Args: + access_token: The OAuth access token + client_id: OAuth client ID (for reference / token refresh) + client_secret: OAuth client secret (for reference / token refresh) + """ + + def __init__( + self, + access_token: str, + client_id: str | None = None, + client_secret: str | None = None, + ) -> None: + super().__init__(access_token, "Bearer") + self.base_url = "https://api.figma.com/v1" + self.access_token = access_token + self.client_id = client_id + self.client_secret = client_secret + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +class FigmaRESTClientViaToken(HTTPClient): + """Figma REST client via Personal Access Token (PAT). + + Personal access tokens are passed as Bearer tokens in the + Authorization header. + + Args: + token: The personal access token + """ + + def __init__(self, token: str) -> None: + super().__init__(token, "Bearer") + self.base_url = "https://api.figma.com/v1" + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class FigmaTokenConfig(BaseModel): + """Configuration for Figma client via Personal Access Token. + + Args: + token: The personal access token + """ + + token: str + + def create_client(self) -> FigmaRESTClientViaToken: + return FigmaRESTClientViaToken(self.token) + + +class FigmaOAuthConfig(BaseModel): + """Configuration for Figma client via OAuth 2.0. + + Args: + access_token: The OAuth access token + client_id: OAuth client ID + client_secret: OAuth client secret + """ + + access_token: str + client_id: str | None = None + client_secret: str | None = None + + def create_client(self) -> FigmaRESTClientViaOAuth: + return FigmaRESTClientViaOAuth( + self.access_token, + self.client_id, + self.client_secret, + ) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class FigmaAuthConfig(BaseModel): + """Auth section of the Figma connector configuration from etcd.""" + + authType: FigmaAuthType = FigmaAuthType.TOKEN + apiToken: str | None = None + token: str | None = None + clientId: str | None = None + clientSecret: str | None = None + redirectUri: str | None = None + oauthConfigId: str | None = None + + class Config: + extra = "allow" + + +class FigmaCredentialsConfig(BaseModel): + """Credentials section of the Figma connector configuration.""" + + access_token: str | None = None + refresh_token: str | None = None + + class Config: + extra = "allow" + + +class FigmaConnectorConfig(BaseModel): + """Top-level Figma connector configuration from etcd.""" + + auth: FigmaAuthConfig = Field(default_factory=FigmaAuthConfig) + credentials: FigmaCredentialsConfig = Field( + default_factory=FigmaCredentialsConfig + ) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class FigmaClient(IClient): + """Builder class for Figma clients with different authentication methods. + + Supports: + - Personal Access Token (PAT) authentication + - OAuth 2.0 access token authentication + """ + + def __init__( + self, + client: FigmaRESTClientViaToken | FigmaRESTClientViaOAuth, + ) -> None: + """Initialize with a Figma client object.""" + super().__init__() + self.client = client + + @override + def get_client( + self, + ) -> FigmaRESTClientViaToken | FigmaRESTClientViaOAuth: + """Return the Figma client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: FigmaTokenConfig | FigmaOAuthConfig, + ) -> "FigmaClient": + """Build FigmaClient with configuration. + + Args: + config: FigmaTokenConfig or FigmaOAuthConfig instance + + Returns: + FigmaClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "FigmaClient": + """Build FigmaClient using configuration service. + + Supports two authentication strategies: + 1. TOKEN: For personal access tokens + 2. OAUTH: For OAuth 2.0 access tokens + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + FigmaClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError("Failed to get Figma connector configuration") + + connector_config = FigmaConnectorConfig.model_validate(raw_config) + + if connector_config.auth.authType == FigmaAuthType.OAUTH: + access_token = connector_config.credentials.access_token or "" + client_id = connector_config.auth.clientId or "" + client_secret = connector_config.auth.clientSecret or "" + + # Try shared OAuth config if credentials are missing + oauth_config_id = connector_config.auth.oauthConfigId + if oauth_config_id and not (client_id and client_secret): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/figma", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + if not access_token: + raise ValueError( + "Access token required for OAuth auth type" + ) + + oauth_cfg = FigmaOAuthConfig( + access_token=access_token, + client_id=client_id, + client_secret=client_secret, + ) + return cls(oauth_cfg.create_client()) + + elif connector_config.auth.authType == FigmaAuthType.TOKEN: + token = ( + connector_config.auth.apiToken + or connector_config.auth.token + or "" + ) + if not token: + raise ValueError( + "Personal access token required for TOKEN auth type" + ) + + token_config = FigmaTokenConfig(token=token) + return cls(token_config.create_client()) + + else: + raise ValueError( + f"Invalid auth type: {connector_config.auth.authType}" + ) + + except Exception as e: + logger.error( + f"Failed to build Figma client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "FigmaClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service for shared OAuth config + + Returns: + FigmaClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("credentials", {}) or {} + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + access_token: str = str(credentials.get("access_token", "")) + if not access_token: + raise ValueError("Access token not found in toolset config") + + client_id: str = str(auth_config.get("clientId", "")) + client_secret: str = str(auth_config.get("clientSecret", "")) + + # Try shared OAuth config + oauth_config_id: str | None = cast( + str | None, auth_config.get("oauthConfigId") + ) + if oauth_config_id and config_service and not ( + client_id and client_secret + ): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/figma", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + oauth_cfg = FigmaOAuthConfig( + access_token=access_token, + client_id=client_id, + client_secret=client_secret, + ) + return cls(oauth_cfg.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Figma client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Figma.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get Figma connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error(f"Failed to get Figma connector config: {e}") + raise ValueError( + f"Failed to get Figma connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/freshservice/freshservice.py b/backend/python/app/sources/client/freshservice/freshservice.py new file mode 100644 index 000000000..57aee906c --- /dev/null +++ b/backend/python/app/sources/client/freshservice/freshservice.py @@ -0,0 +1,262 @@ +import base64 +import logging +from typing import Any + +from pydantic import BaseModel, Field, field_validator # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +logger = logging.getLogger(__name__) + + +class FreshserviceConfigurationError(Exception): + """Custom exception for Freshservice configuration errors.""" + + def __init__( + self, message: str, details: dict[str, Any] | None = None + ) -> None: + super().__init__(message) + self.details = details or {} + + +class FreshserviceResponse(BaseModel): + """Standardized Freshservice API response wrapper.""" + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, Any] | list[Any] | None = Field( + default=None, description="Response data" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary for JSON serialization.""" + return self.model_dump() + + def to_json(self) -> str: + """Convert to JSON string.""" + return self.model_dump_json() + + +class FreshserviceRESTClientViaApiKey(HTTPClient): + """Freshservice REST client via API key. + + Freshservice uses Basic Authentication with API Key as username + and 'X' as password (same pattern as FreshDesk). + + Args: + domain: The Freshservice domain (e.g., 'company.freshservice.com') + api_key: The API key to use for authentication + """ + + def __init__(self, domain: str, api_key: str) -> None: + # Freshservice uses Basic auth with API key as username, 'X' as password + credentials = f"{api_key}:X" + encoded_credentials = base64.b64encode(credentials.encode()).decode() + + # Initialize HTTPClient with Basic token + super().__init__(encoded_credentials, "Basic") + self.domain = domain + self.base_url = f"https://{domain}/api/v2" + self.api_key = api_key + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + def get_domain(self) -> str: + """Get the Freshservice domain.""" + return self.domain + + +class FreshserviceApiKeyConfig(BaseModel): + """Configuration for Freshservice REST client via API Key. + + Args: + domain: The Freshservice domain (e.g., 'company.freshservice.com') + api_key: The API key for authentication + ssl: Whether to use SSL (default: True) + """ + + domain: str + api_key: str + ssl: bool = True + + @field_validator("domain") + @classmethod + def validate_domain(cls, v: str) -> str: + """Validate domain field.""" + if not v or not v.strip(): + raise ValueError("domain cannot be empty or None") + + if v.startswith(("http://", "https://")): + raise ValueError( + "domain should not include protocol (http:// or https://)" + ) + + return v + + @field_validator("api_key") + @classmethod + def validate_api_key(cls, v: str) -> str: + """Validate api_key field.""" + if not v or not v.strip(): + raise ValueError("api_key cannot be empty or None") + + return v + + def create_client(self) -> FreshserviceRESTClientViaApiKey: + """Create Freshservice REST client.""" + return FreshserviceRESTClientViaApiKey(self.domain, self.api_key) + + def to_dict(self) -> dict[str, Any]: + """Convert the configuration to a dictionary.""" + return { + "domain": self.domain, + "ssl": self.ssl, + "has_api_key": bool(self.api_key), + } + + +class FreshserviceClient(IClient): + """Builder class for Freshservice clients.""" + + def __init__(self, client: FreshserviceRESTClientViaApiKey) -> None: + """Initialize with a Freshservice client object.""" + super().__init__() + self.client = client + + @override + def get_client(self) -> FreshserviceRESTClientViaApiKey: + """Return the Freshservice REST client object.""" + return self.client + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.client.get_base_url() + + def get_domain(self) -> str: + """Get the Freshservice domain.""" + return self.client.get_domain() + + @classmethod + def build_with_config( + cls, + config: FreshserviceApiKeyConfig, + ) -> "FreshserviceClient": + """Build FreshserviceClient with configuration. + + Args: + config: FreshserviceApiKeyConfig instance + Returns: + FreshserviceClient instance + """ + return cls(config.create_client()) + + @classmethod + def build_with_api_key_config( + cls, config: FreshserviceApiKeyConfig + ) -> "FreshserviceClient": + """Build FreshserviceClient with API key configuration. + + Args: + config: FreshserviceApiKeyConfig instance + + Returns: + FreshserviceClient: Configured client instance + """ + return cls.build_with_config(config) + + @classmethod + def build_with_api_key( + cls, + domain: str, + api_key: str, + *, + ssl: bool = True, + ) -> "FreshserviceClient": + """Build FreshserviceClient with API key directly. + + Args: + domain: The Freshservice domain (e.g., 'company.freshservice.com') + api_key: The API key for authentication + ssl: Whether to use SSL (default: True) + + Returns: + FreshserviceClient: Configured client instance + """ + config = FreshserviceApiKeyConfig( + domain=domain, + api_key=api_key, + ssl=ssl, + ) + return cls.build_with_config(config) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "FreshserviceClient": + """Build FreshserviceClient using configuration service. + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + Returns: + FreshserviceClient: Configured client instance + + Raises: + ValueError: If configuration is invalid or missing + """ + config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not config: + raise ValueError( + "Failed to get Freshservice connector configuration" + ) + auth_config = config.get("auth", {}) + auth_type = auth_config.get("authType", "API_KEY") + if auth_type == "API_KEY": + api_key = auth_config.get("apiKey", "") + domain = auth_config.get("domain", "") + if not api_key: + raise ValueError("API key required for API key auth type") + client = FreshserviceApiKeyConfig( + domain=domain, api_key=api_key + ).create_client() + else: + raise ValueError(f"Invalid auth type: {auth_type}") + return cls(client) + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Freshservice.""" + try: + config = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not config: + raise ValueError( + f"Failed to get Freshservice connector configuration " + f"for instance {connector_instance_id}" + ) + return dict(config) # type: ignore[arg-type] + except Exception as e: + logger.error(f"Failed to get Freshservice connector config: {e}") + raise ValueError( + f"Failed to get Freshservice connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/gong/gong.py b/backend/python/app/sources/client/gong/gong.py new file mode 100644 index 000000000..59cfa1ef8 --- /dev/null +++ b/backend/python/app/sources/client/gong/gong.py @@ -0,0 +1,547 @@ +"""Gong client implementation. + +This module provides clients for interacting with the Gong API using either: +1. OAuth2 authorization code flow +2. Basic Auth (access_key:access_key_secret) + +Authentication Reference: https://gong.app.gong.io/settings/api/documentation +OAuth Reference: https://gong.app.gong.io/settings/api/documentation#overview +API Reference: https://gong.app.gong.io/settings/api/documentation#tag/Calls +""" + +import base64 +import json +import logging +from enum import Enum +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class GongAuthType(str, Enum): + """Authentication types supported by the Gong connector.""" + + OAUTH = "OAUTH" + BASIC_AUTH = "BASIC_AUTH" + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class GongResponse(BaseModel): + """Standardized Gong API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client classes +# --------------------------------------------------------------------------- + + +class GongRESTClientViaOAuth(HTTPClient): + """Gong REST client via OAuth 2.0 authorization code flow. + + OAuth tokens are passed as Bearer tokens in the Authorization header. + + Args: + access_token: The OAuth access token + client_id: OAuth client ID (for token refresh) + client_secret: OAuth client secret (for token refresh) + base_url: API base URL (default: https://api.gong.io/v2) + """ + + def __init__( + self, + access_token: str, + client_id: str | None = None, + client_secret: str | None = None, + base_url: str = "https://api.gong.io/v2", + ) -> None: + super().__init__(access_token, "Bearer") + self.base_url = base_url + self.access_token = access_token + self.client_id = client_id + self.client_secret = client_secret + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +class GongRESTClientViaBasicAuth(HTTPClient): + """Gong REST client via Basic Auth (access_key:access_key_secret). + + Credentials are base64-encoded and passed in the Authorization header. + + Args: + access_key: The Gong API access key + access_key_secret: The Gong API access key secret + base_url: API base URL (default: https://api.gong.io/v2) + """ + + def __init__( + self, + access_key: str, + access_key_secret: str, + base_url: str = "https://api.gong.io/v2", + ) -> None: + super().__init__("", token_type="Basic") + self.base_url = base_url + self.access_key = access_key + self.access_key_secret = access_key_secret + credentials = base64.b64encode( + f"{access_key}:{access_key_secret}".encode() + ).decode("utf-8") + self.headers["Authorization"] = f"Basic {credentials}" + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class GongOAuthConfig(BaseModel): + """Configuration for Gong client via OAuth 2.0. + + Args: + access_token: The OAuth access token + client_id: OAuth client ID + client_secret: OAuth client secret + base_url: API base URL (default: https://api.gong.io/v2) + """ + + access_token: str + client_id: str | None = None + client_secret: str | None = None + base_url: str = "https://api.gong.io/v2" + + def create_client(self) -> GongRESTClientViaOAuth: + return GongRESTClientViaOAuth( + self.access_token, + self.client_id, + self.client_secret, + self.base_url, + ) + + +class GongBasicAuthConfig(BaseModel): + """Configuration for Gong client via Basic Auth. + + Args: + access_key: The Gong API access key + access_key_secret: The Gong API access key secret + base_url: API base URL (default: https://api.gong.io/v2) + """ + + access_key: str + access_key_secret: str + base_url: str = "https://api.gong.io/v2" + + def create_client(self) -> GongRESTClientViaBasicAuth: + return GongRESTClientViaBasicAuth( + self.access_key, + self.access_key_secret, + self.base_url, + ) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class GongAuthConfig(BaseModel): + """Auth section of the Gong connector configuration from etcd.""" + + authType: GongAuthType = GongAuthType.BASIC_AUTH + accessKey: str | None = None + accessKeySecret: str | None = None + clientId: str | None = None + clientSecret: str | None = None + redirectUri: str | None = None + oauthConfigId: str | None = None + + class Config: + extra = "allow" + + +class GongCredentialsConfig(BaseModel): + """Credentials section of the Gong connector configuration.""" + + access_token: str | None = None + refresh_token: str | None = None + access_key: str | None = None + access_key_secret: str | None = None + + class Config: + extra = "allow" + + +class GongConnectorConfig(BaseModel): + """Top-level Gong connector configuration from etcd.""" + + auth: GongAuthConfig = Field(default_factory=GongAuthConfig) + credentials: GongCredentialsConfig = Field( + default_factory=GongCredentialsConfig + ) + base_url: str = "https://api.gong.io/v2" + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class GongClient(IClient): + """Builder class for Gong clients with different authentication methods. + + Supports: + - OAuth 2.0 authorization code flow + - Basic Auth (access_key:access_key_secret) + """ + + def __init__( + self, + client: GongRESTClientViaOAuth | GongRESTClientViaBasicAuth, + ) -> None: + """Initialize with a Gong client object.""" + super().__init__() + self.client = client + + @override + def get_client( + self, + ) -> GongRESTClientViaOAuth | GongRESTClientViaBasicAuth: + """Return the Gong client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: GongOAuthConfig | GongBasicAuthConfig, + ) -> "GongClient": + """Build GongClient with configuration. + + Args: + config: GongOAuthConfig or GongBasicAuthConfig instance + + Returns: + GongClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "GongClient": + """Build GongClient using configuration service. + + Supports two authentication strategies: + 1. OAUTH: For OAuth 2.0 access tokens + 2. BASIC_AUTH: For access_key:access_key_secret authentication + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + GongClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get Gong connector configuration" + ) + + connector_config = GongConnectorConfig.model_validate(raw_config) + + if connector_config.auth.authType == GongAuthType.OAUTH: + access_token = connector_config.credentials.access_token or "" + client_id = connector_config.auth.clientId or "" + client_secret = connector_config.auth.clientSecret or "" + + # Try shared OAuth config if credentials are missing + oauth_config_id = connector_config.auth.oauthConfigId + if oauth_config_id and not (client_id and client_secret): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/gong", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + if not access_token: + raise ValueError( + "Access token required for OAuth auth type" + ) + + oauth_cfg = GongOAuthConfig( + access_token=access_token, + client_id=client_id, + client_secret=client_secret, + base_url=connector_config.base_url, + ) + return cls(oauth_cfg.create_client()) + + else: + # Default: BASIC_AUTH + access_key = ( + connector_config.credentials.access_key + or connector_config.auth.accessKey + or "" + ) + access_key_secret = ( + connector_config.credentials.access_key_secret + or connector_config.auth.accessKeySecret + or "" + ) + + if not access_key or not access_key_secret: + raise ValueError( + "Access key and secret required for " + "BASIC_AUTH auth type" + ) + + basic_cfg = GongBasicAuthConfig( + access_key=access_key, + access_key_secret=access_key_secret, + base_url=connector_config.base_url, + ) + return cls(basic_cfg.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Gong client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "GongClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service for shared OAuth + + Returns: + GongClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], + toolset_config.get("credentials", {}) or {}, + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + base_url: str = str( + toolset_config.get("base_url", "https://api.gong.io/v2") + ) + auth_type = auth_config.get("authType", "BASIC_AUTH") + + if auth_type == "OAUTH": + access_token = str(credentials.get("access_token", "")) + if not access_token: + raise ValueError( + "Access token not found in toolset config" + ) + + client_id = str(auth_config.get("clientId", "")) + client_secret = str(auth_config.get("clientSecret", "")) + + # Try shared OAuth config + oauth_config_id: str | None = cast( + str | None, auth_config.get("oauthConfigId") + ) + if oauth_config_id and config_service and not ( + client_id and client_secret + ): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/gong", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + oauth_cfg = GongOAuthConfig( + access_token=access_token, + client_id=client_id, + client_secret=client_secret, + base_url=base_url, + ) + return cls(oauth_cfg.create_client()) + + else: + # Default: BASIC_AUTH + access_key = str( + credentials.get("access_key", "") + or auth_config.get("accessKey", "") + ) + access_key_secret = str( + credentials.get("access_key_secret", "") + or auth_config.get("accessKeySecret", "") + ) + if not access_key or not access_key_secret: + raise ValueError( + "Access key and secret not found in toolset config" + ) + basic_cfg = GongBasicAuthConfig( + access_key=access_key, + access_key_secret=access_key_secret, + base_url=base_url, + ) + return cls(basic_cfg.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Gong client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Gong.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get Gong connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error(f"Failed to get Gong connector config: {e}") + raise ValueError( + f"Failed to get Gong connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/greenhouse/greenhouse.py b/backend/python/app/sources/client/greenhouse/greenhouse.py new file mode 100644 index 000000000..c1418fa80 --- /dev/null +++ b/backend/python/app/sources/client/greenhouse/greenhouse.py @@ -0,0 +1,291 @@ +"""Greenhouse client implementation. + +This module provides clients for interacting with the Greenhouse Harvest API +using API Key authentication via HTTP Basic Auth. + +Auth Reference: https://developers.greenhouse.io/harvest.html#authentication +API Reference: https://developers.greenhouse.io/harvest.html +""" + +import base64 +import json +import logging +from enum import Enum +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class GreenhouseAuthType(str, Enum): + """Authentication types supported by the Greenhouse connector.""" + + API_KEY = "API_KEY" + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class GreenhouseResponse(BaseModel): + """Standardized Greenhouse API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client class +# --------------------------------------------------------------------------- + + +class GreenhouseRESTClientViaApiKey(HTTPClient): + """Greenhouse REST client via API Key. + + Greenhouse uses HTTP Basic Auth with the API key as the username + and an empty string as the password. + + Args: + api_key: The Greenhouse Harvest API key + """ + + def __init__(self, api_key: str) -> None: + # Greenhouse uses Basic auth with API key as username, empty password + credentials = f"{api_key}:" + encoded_credentials = base64.b64encode(credentials.encode()).decode() + + # Initialize HTTPClient with Basic token + super().__init__(encoded_credentials, "Basic") + self.base_url = "https://harvest.greenhouse.io/v1" + self.api_key = api_key + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class GreenhouseApiKeyConfig(BaseModel): + """Configuration for Greenhouse client via API Key. + + Args: + api_key: The Greenhouse Harvest API key for authentication + """ + + api_key: str + + def create_client(self) -> GreenhouseRESTClientViaApiKey: + """Create Greenhouse REST client.""" + return GreenhouseRESTClientViaApiKey(self.api_key) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class GreenhouseAuthConfig(BaseModel): + """Auth section of the Greenhouse connector configuration from etcd.""" + + authType: GreenhouseAuthType = GreenhouseAuthType.API_KEY + apiKey: str | None = None + oauthConfigId: str | None = None + + class Config: + extra = "allow" + + +class GreenhouseCredentialsConfig(BaseModel): + """Credentials section of the Greenhouse connector configuration.""" + + access_token: str | None = None + refresh_token: str | None = None + + class Config: + extra = "allow" + + +class GreenhouseConnectorConfig(BaseModel): + """Top-level Greenhouse connector configuration from etcd.""" + + auth: GreenhouseAuthConfig = Field(default_factory=GreenhouseAuthConfig) + credentials: GreenhouseCredentialsConfig = Field( + default_factory=GreenhouseCredentialsConfig + ) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class GreenhouseClient(IClient): + """Builder class for Greenhouse clients. + + Supports: + - API Key authentication via HTTP Basic Auth + """ + + def __init__( + self, + client: GreenhouseRESTClientViaApiKey, + ) -> None: + """Initialize with a Greenhouse client object.""" + super().__init__() + self.client = client + + @override + def get_client(self) -> GreenhouseRESTClientViaApiKey: + """Return the Greenhouse client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: GreenhouseApiKeyConfig, + ) -> "GreenhouseClient": + """Build GreenhouseClient with configuration. + + Args: + config: GreenhouseApiKeyConfig instance + + Returns: + GreenhouseClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "GreenhouseClient": + """Build GreenhouseClient using configuration service. + + Supports API Key authentication strategy: + 1. API_KEY: API key passed via HTTP Basic Auth + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + GreenhouseClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get Greenhouse connector configuration" + ) + + connector_config = GreenhouseConnectorConfig.model_validate( + raw_config + ) + + if connector_config.auth.authType == GreenhouseAuthType.API_KEY: + api_key = connector_config.auth.apiKey or "" + if not api_key: + raise ValueError( + "API key required for API_KEY auth type" + ) + + api_key_config = GreenhouseApiKeyConfig(api_key=api_key) + return cls(api_key_config.create_client()) + + else: + raise ValueError( + f"Invalid auth type: {connector_config.auth.authType}" + ) + + except Exception as e: + logger.error( + f"Failed to build Greenhouse client from services: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Greenhouse.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get Greenhouse connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error(f"Failed to get Greenhouse connector config: {e}") + raise ValueError( + f"Failed to get Greenhouse connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/guru/guru.py b/backend/python/app/sources/client/guru/guru.py new file mode 100644 index 000000000..d7a67ee90 --- /dev/null +++ b/backend/python/app/sources/client/guru/guru.py @@ -0,0 +1,511 @@ +"""Guru client implementation. + +This module provides clients for interacting with the Guru API using either: +1. Basic Auth (username:api_token) +2. OAuth 2.0 access token authentication + +OAuth Auth Endpoint: https://api.getguru.com/oauth/authorize +OAuth Token Endpoint: https://api.getguru.com/oauth/token +API Reference: https://api.getguru.com/api/v1 +""" + +import base64 +import json +import logging +from enum import Enum +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class GuruAuthType(str, Enum): + """Authentication types supported by the Guru connector.""" + + BASIC = "BASIC" + OAUTH = "OAUTH" + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class GuruResponse(BaseModel): + """Standardized Guru API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client classes +# --------------------------------------------------------------------------- + + +class GuruRESTClientViaBasicAuth(HTTPClient): + """Guru REST client via Basic Auth (username:api_token). + + Args: + username: Guru account email / username + api_token: Guru API token + base_url: API base URL (default: https://api.getguru.com/api/v1) + """ + + def __init__( + self, + username: str, + api_token: str, + base_url: str = "https://api.getguru.com/api/v1", + ) -> None: + # Initialize with empty token; override the header below + super().__init__("", token_type="Basic") + self.base_url = base_url + self.username = username + self.api_token = api_token + credentials = base64.b64encode( + f"{username}:{api_token}".encode() + ).decode("utf-8") + self.headers["Authorization"] = f"Basic {credentials}" + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +class GuruRESTClientViaOAuth(HTTPClient): + """Guru REST client via OAuth 2.0 access token. + + OAuth tokens are passed as Bearer tokens in the Authorization header. + + Args: + access_token: The OAuth access token + client_id: OAuth client ID (for token refresh) + client_secret: OAuth client secret (for token refresh) + base_url: API base URL (default: https://api.getguru.com/api/v1) + """ + + def __init__( + self, + access_token: str, + client_id: str | None = None, + client_secret: str | None = None, + base_url: str = "https://api.getguru.com/api/v1", + ) -> None: + super().__init__(access_token, "Bearer") + self.base_url = base_url + self.access_token = access_token + self.client_id = client_id + self.client_secret = client_secret + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class GuruBasicAuthConfig(BaseModel): + """Configuration for Guru client via Basic Auth. + + Args: + username: Guru account email / username + api_token: Guru API token + base_url: API base URL (default: https://api.getguru.com/api/v1) + """ + + username: str + api_token: str + base_url: str = "https://api.getguru.com/api/v1" + + def create_client(self) -> GuruRESTClientViaBasicAuth: + return GuruRESTClientViaBasicAuth( + self.username, self.api_token, self.base_url + ) + + +class GuruOAuthConfig(BaseModel): + """Configuration for Guru client via OAuth 2.0. + + Args: + access_token: The OAuth access token + client_id: OAuth client ID + client_secret: OAuth client secret + base_url: API base URL (default: https://api.getguru.com/api/v1) + """ + + access_token: str + client_id: str | None = None + client_secret: str | None = None + base_url: str = "https://api.getguru.com/api/v1" + + def create_client(self) -> GuruRESTClientViaOAuth: + return GuruRESTClientViaOAuth( + self.access_token, + self.client_id, + self.client_secret, + self.base_url, + ) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class GuruAuthConfigModel(BaseModel): + """Auth section of the Guru connector configuration from etcd.""" + + authType: GuruAuthType = GuruAuthType.BASIC + username: str | None = None + apiToken: str | None = None + token: str | None = None + clientId: str | None = None + clientSecret: str | None = None + redirectUri: str | None = None + oauthConfigId: str | None = None + + class Config: + extra = "allow" + + +class GuruCredentialsConfig(BaseModel): + """Credentials section of the Guru connector configuration.""" + + access_token: str | None = None + refresh_token: str | None = None + + class Config: + extra = "allow" + + +class GuruConnectorConfig(BaseModel): + """Top-level Guru connector configuration from etcd.""" + + auth: GuruAuthConfigModel = Field(default_factory=GuruAuthConfigModel) + credentials: GuruCredentialsConfig = Field( + default_factory=GuruCredentialsConfig + ) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class GuruClient(IClient): + """Builder class for Guru clients with different authentication methods. + + Supports: + - Basic Auth (username:api_token) + - OAuth 2.0 access token authentication + """ + + def __init__( + self, + client: GuruRESTClientViaBasicAuth | GuruRESTClientViaOAuth, + ) -> None: + """Initialize with a Guru client object.""" + super().__init__() + self.client = client + + @override + def get_client( + self, + ) -> GuruRESTClientViaBasicAuth | GuruRESTClientViaOAuth: + """Return the Guru client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: GuruBasicAuthConfig | GuruOAuthConfig, + ) -> "GuruClient": + """Build GuruClient with configuration. + + Args: + config: GuruBasicAuthConfig or GuruOAuthConfig instance + + Returns: + GuruClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "GuruClient": + """Build GuruClient using configuration service. + + Supports two authentication strategies: + 1. BASIC: Basic Auth with username and api_token + 2. OAUTH: OAuth 2.0 access token + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + GuruClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get Guru connector configuration" + ) + + connector_config = GuruConnectorConfig.model_validate(raw_config) + + if connector_config.auth.authType == GuruAuthType.OAUTH: + access_token = connector_config.credentials.access_token or "" + client_id = connector_config.auth.clientId or "" + client_secret = connector_config.auth.clientSecret or "" + + # Try shared OAuth config if credentials are missing + oauth_config_id = connector_config.auth.oauthConfigId + if oauth_config_id and not (client_id and client_secret): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/guru", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + if not access_token: + raise ValueError( + "Access token required for OAuth auth type" + ) + + oauth_cfg = GuruOAuthConfig( + access_token=access_token, + client_id=client_id, + client_secret=client_secret, + ) + return cls(oauth_cfg.create_client()) + + elif connector_config.auth.authType == GuruAuthType.BASIC: + username = connector_config.auth.username or "" + api_token = ( + connector_config.auth.apiToken + or connector_config.auth.token + or "" + ) + + if not (username and api_token): + raise ValueError( + "Username and API token required for Basic auth type" + ) + + basic_cfg = GuruBasicAuthConfig( + username=username, + api_token=api_token, + ) + return cls(basic_cfg.create_client()) + + else: + raise ValueError( + f"Invalid auth type: {connector_config.auth.authType}" + ) + + except Exception as e: + logger.error( + f"Failed to build Guru client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "GuruClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service for shared OAuth config + + Returns: + GuruClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("credentials", {}) or {} + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + access_token: str = str(credentials.get("access_token", "")) + if not access_token: + raise ValueError( + "Access token not found in toolset config" + ) + + client_id: str = str(auth_config.get("clientId", "")) + client_secret: str = str(auth_config.get("clientSecret", "")) + + # Try shared OAuth config + oauth_config_id: str | None = cast( + str | None, auth_config.get("oauthConfigId") + ) + if oauth_config_id and config_service and not ( + client_id and client_secret + ): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/guru", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + oauth_cfg = GuruOAuthConfig( + access_token=access_token, + client_id=client_id, + client_secret=client_secret, + ) + return cls(oauth_cfg.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Guru client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Guru.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get Guru connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error(f"Failed to get Guru connector config: {e}") + raise ValueError( + f"Failed to get Guru connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/harvest/harvest.py b/backend/python/app/sources/client/harvest/harvest.py new file mode 100644 index 000000000..312dffc73 --- /dev/null +++ b/backend/python/app/sources/client/harvest/harvest.py @@ -0,0 +1,566 @@ +"""Harvest client implementation. + +This module provides clients for interacting with the Harvest API using either: +1. OAuth 2.0 authorization code flow +2. Personal Access Token (Bearer token) + +Harvest requires a Harvest-Account-Id header on all API requests. + +Authentication Reference: https://help.getharvest.com/api-v2/authentication-api/authentication/authentication/ +OAuth Reference: https://id.getharvest.com/oauth2/authorize +API Reference: https://help.getharvest.com/api-v2/ +""" + +import base64 +import json +import logging +from enum import Enum +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class HarvestAuthType(str, Enum): + """Authentication types supported by the Harvest connector.""" + + OAUTH = "OAUTH" + TOKEN = "TOKEN" + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class HarvestResponse(BaseModel): + """Standardized Harvest API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client classes +# --------------------------------------------------------------------------- + + +class HarvestRESTClientViaOAuth(HTTPClient): + """Harvest REST client via OAuth 2.0 authorization code flow. + + OAuth tokens are passed as Bearer tokens in the Authorization header. + The Harvest-Account-Id header is required on all requests. + + Args: + access_token: The OAuth access token + account_id: Harvest account ID (required for all API requests) + client_id: OAuth client ID (for token refresh) + client_secret: OAuth client secret (for token refresh) + base_url: API base URL (default: https://api.harvestapp.com/v2) + """ + + def __init__( + self, + access_token: str, + account_id: str, + client_id: str | None = None, + client_secret: str | None = None, + base_url: str = "https://api.harvestapp.com/v2", + ) -> None: + super().__init__(access_token, "Bearer") + self.base_url = base_url + self.access_token = access_token + self.account_id = account_id + self.client_id = client_id + self.client_secret = client_secret + self.headers["Harvest-Account-Id"] = account_id + self.headers["Content-Type"] = "application/json" + self.headers["User-Agent"] = "PipesHub-Harvest-Connector" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +class HarvestRESTClientViaToken(HTTPClient): + """Harvest REST client via Personal Access Token. + + Personal access tokens are passed as Bearer tokens in the + Authorization header. The Harvest-Account-Id header is required + on all requests. + + Args: + token: The personal access token + account_id: Harvest account ID (required for all API requests) + base_url: API base URL (default: https://api.harvestapp.com/v2) + """ + + def __init__( + self, + token: str, + account_id: str, + base_url: str = "https://api.harvestapp.com/v2", + ) -> None: + super().__init__(token, token_type="Bearer") + self.base_url = base_url + self.headers["Harvest-Account-Id"] = account_id + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class HarvestOAuthConfig(BaseModel): + """Configuration for Harvest client via OAuth 2.0. + + Args: + access_token: The OAuth access token + account_id: Harvest account ID + client_id: OAuth client ID + client_secret: OAuth client secret + base_url: API base URL (default: https://api.harvestapp.com/v2) + """ + + access_token: str + account_id: str + client_id: str | None = None + client_secret: str | None = None + base_url: str = "https://api.harvestapp.com/v2" + + def create_client(self) -> HarvestRESTClientViaOAuth: + return HarvestRESTClientViaOAuth( + self.access_token, + self.account_id, + self.client_id, + self.client_secret, + self.base_url, + ) + + +class HarvestTokenConfig(BaseModel): + """Configuration for Harvest client via Personal Access Token. + + Args: + token: The personal access token + account_id: Harvest account ID + base_url: API base URL (default: https://api.harvestapp.com/v2) + """ + + token: str + account_id: str + base_url: str = "https://api.harvestapp.com/v2" + + def create_client(self) -> HarvestRESTClientViaToken: + return HarvestRESTClientViaToken( + self.token, self.account_id, self.base_url + ) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class HarvestAuthConfig(BaseModel): + """Auth section of the Harvest connector configuration from etcd.""" + + authType: HarvestAuthType = HarvestAuthType.OAUTH + token: str | None = None + clientId: str | None = None + clientSecret: str | None = None + redirectUri: str | None = None + oauthConfigId: str | None = None + accountId: str | None = None + + class Config: + extra = "allow" + + +class HarvestCredentialsConfig(BaseModel): + """Credentials section of the Harvest connector configuration.""" + + access_token: str | None = None + refresh_token: str | None = None + + class Config: + extra = "allow" + + +class HarvestConnectorConfig(BaseModel): + """Top-level Harvest connector configuration from etcd.""" + + auth: HarvestAuthConfig = Field(default_factory=HarvestAuthConfig) + credentials: HarvestCredentialsConfig = Field( + default_factory=HarvestCredentialsConfig + ) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Shared OAuth configuration models +# --------------------------------------------------------------------------- + + +class HarvestSharedOAuthConfigEntry(BaseModel): + """A single entry from the shared OAuth config list in etcd. + + Handles both camelCase and snake_case key variants from the config store. + """ + + entry_id: str | None = Field(default=None, alias="_id") + clientId: str | None = None + client_id: str | None = None + clientSecret: str | None = None + client_secret: str | None = None + redirectUri: str | None = None + redirect_uri: str | None = None + + class Config: + extra = "allow" + populate_by_name = True + + def resolved_client_id(self, fallback: str = "") -> str: + return self.clientId or self.client_id or fallback + + def resolved_client_secret(self, fallback: str = "") -> str: + return self.clientSecret or self.client_secret or fallback + + def resolved_redirect_uri(self, fallback: str = "") -> str: + return self.redirectUri or self.redirect_uri or fallback + + +class HarvestSharedOAuthWrapper(BaseModel): + """Wrapper for a shared OAuth config entry with nested config.""" + + entry_id: str | None = Field(default=None, alias="_id") + config: HarvestSharedOAuthConfigEntry = Field( + default_factory=HarvestSharedOAuthConfigEntry + ) + + class Config: + extra = "allow" + populate_by_name = True + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class HarvestClient(IClient): + """Builder class for Harvest clients with different authentication methods. + + Supports: + - OAuth 2.0 authorization code flow + - Personal Access Token (Bearer token) + + All requests require a Harvest-Account-Id header. + """ + + def __init__( + self, + client: HarvestRESTClientViaOAuth | HarvestRESTClientViaToken, + ) -> None: + """Initialize with a Harvest client object.""" + super().__init__() + self.client = client + + @override + def get_client( + self, + ) -> HarvestRESTClientViaOAuth | HarvestRESTClientViaToken: + """Return the Harvest client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: HarvestOAuthConfig | HarvestTokenConfig, + ) -> "HarvestClient": + """Build HarvestClient with configuration. + + Args: + config: HarvestOAuthConfig or HarvestTokenConfig instance + + Returns: + HarvestClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "HarvestClient": + """Build HarvestClient using configuration service. + + Supports two authentication strategies: + 1. OAUTH: OAuth 2.0 authorization code flow with access token + 2. TOKEN: Personal Access Token + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + HarvestClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get Harvest connector configuration" + ) + + connector_config = HarvestConnectorConfig.model_validate( + raw_config + ) + account_id = connector_config.auth.accountId or "" + + if connector_config.auth.authType == HarvestAuthType.OAUTH: + access_token = ( + connector_config.credentials.access_token or "" + ) + client_id = connector_config.auth.clientId or "" + client_secret = connector_config.auth.clientSecret or "" + + # Try shared OAuth config if credentials are missing + oauth_config_id = connector_config.auth.oauthConfigId + if oauth_config_id and not (client_id and client_secret): + shared_cfg = await cls._find_shared_oauth_config( + config_service, oauth_config_id, logger + ) + if shared_cfg: + client_id = shared_cfg.resolved_client_id(client_id) + client_secret = shared_cfg.resolved_client_secret( + client_secret + ) + + if not access_token: + raise ValueError( + "Access token required for OAuth auth type" + ) + if not account_id: + raise ValueError( + "Account ID required for Harvest API requests" + ) + + oauth_cfg = HarvestOAuthConfig( + access_token=access_token, + account_id=account_id, + client_id=client_id, + client_secret=client_secret, + ) + return cls(oauth_cfg.create_client()) + + elif connector_config.auth.authType == HarvestAuthType.TOKEN: + token = connector_config.auth.token or "" + if not token: + raise ValueError( + "Token required for TOKEN auth type" + ) + if not account_id: + raise ValueError( + "Account ID required for Harvest API requests" + ) + + token_config = HarvestTokenConfig( + token=token, account_id=account_id + ) + return cls(token_config.create_client()) + + else: + raise ValueError( + f"Invalid auth type: {connector_config.auth.authType}" + ) + + except Exception as e: + logger.error( + f"Failed to build Harvest client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "HarvestClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service for shared OAuth config + + Returns: + HarvestClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("credentials", {}) or {} + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + access_token: str = str(credentials.get("access_token", "")) + if not access_token: + raise ValueError( + "Access token not found in toolset config" + ) + + account_id: str = str(auth_config.get("accountId", "")) + if not account_id: + raise ValueError( + "Account ID not found in toolset config" + ) + + client_id: str = str(auth_config.get("clientId", "")) + client_secret: str = str(auth_config.get("clientSecret", "")) + + # Try shared OAuth config + oauth_config_id: str | None = cast( + str | None, auth_config.get("oauthConfigId") + ) + if oauth_config_id and config_service and not ( + client_id and client_secret + ): + shared_cfg = await cls._find_shared_oauth_config( + config_service, oauth_config_id, logger + ) + if shared_cfg: + client_id = shared_cfg.resolved_client_id(client_id) + client_secret = shared_cfg.resolved_client_secret( + client_secret + ) + + oauth_cfg = HarvestOAuthConfig( + access_token=access_token, + account_id=account_id, + client_id=client_id, + client_secret=client_secret, + ) + return cls(oauth_cfg.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Harvest client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _find_shared_oauth_config( + config_service: ConfigurationService, + oauth_config_id: str, + logger: logging.Logger, + ) -> HarvestSharedOAuthConfigEntry | None: + """Look up shared OAuth config by ID from the config store. + + Args: + config_service: Configuration service instance + oauth_config_id: The shared OAuth config ID to match + logger: Logger instance + + Returns: + Matched HarvestSharedOAuthConfigEntry or None + """ + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/harvest", default=[] + ) + entries: list[object] = list(raw) if isinstance(raw, list) else [] # type: ignore[reportUnknownArgumentType] + for entry in entries: + wrapper = HarvestSharedOAuthWrapper.model_validate(entry) + if wrapper.entry_id == oauth_config_id: + return wrapper.config + except Exception as e: + logger.warning(f"Failed to fetch shared OAuth config: {e}") + return None + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Harvest.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get Harvest connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error(f"Failed to get Harvest connector config: {e}") + raise ValueError( + f"Failed to get Harvest connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/haystack/haystack.py b/backend/python/app/sources/client/haystack/haystack.py new file mode 100644 index 000000000..10346f8a5 --- /dev/null +++ b/backend/python/app/sources/client/haystack/haystack.py @@ -0,0 +1,309 @@ +"""Haystack (HaystackApp) client implementation. + +This module provides a client for interacting with the Haystack API using +API Key (Bearer token) authentication. + +API Reference: https://developer.haystackapp.io/ +""" + +import base64 +import json +import logging +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class HaystackResponse(BaseModel): + """Standardized Haystack API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client class +# --------------------------------------------------------------------------- + + +class HaystackRESTClientViaToken(HTTPClient): + """Haystack REST client via API Key (Bearer token). + + Args: + token: API key used as Bearer token + """ + + def __init__(self, token: str) -> None: + super().__init__(token, "Bearer") + self.base_url = "https://api.haystackapp.io/v1" + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class HaystackTokenConfig(BaseModel): + """Configuration for Haystack client via API Key. + + Args: + token: API key (Bearer token) + """ + + token: str + + def create_client(self) -> HaystackRESTClientViaToken: + return HaystackRESTClientViaToken(self.token) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class HaystackAuthConfig(BaseModel): + """Auth section of the Haystack connector configuration from etcd.""" + + apiToken: str | None = None + token: str | None = None + + class Config: + extra = "allow" + + +class HaystackCredentialsConfig(BaseModel): + """Credentials section of the Haystack connector configuration.""" + + access_token: str | None = None + + class Config: + extra = "allow" + + +class HaystackConnectorConfig(BaseModel): + """Top-level Haystack connector configuration from etcd.""" + + auth: HaystackAuthConfig = Field(default_factory=HaystackAuthConfig) + credentials: HaystackCredentialsConfig = Field( + default_factory=HaystackCredentialsConfig + ) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class HaystackClient(IClient): + """Builder class for Haystack clients. + + Supports: + - API Key (Bearer token) authentication + """ + + def __init__( + self, + client: HaystackRESTClientViaToken, + ) -> None: + """Initialize with a Haystack client object.""" + super().__init__() + self.client = client + + @override + def get_client(self) -> HaystackRESTClientViaToken: + """Return the Haystack client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: HaystackTokenConfig, + ) -> "HaystackClient": + """Build HaystackClient with configuration. + + Args: + config: HaystackTokenConfig instance + + Returns: + HaystackClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "HaystackClient": + """Build HaystackClient using configuration service. + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + HaystackClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get Haystack connector configuration" + ) + + connector_config = HaystackConnectorConfig.model_validate( + raw_config + ) + + token = ( + connector_config.auth.apiToken + or connector_config.auth.token + or connector_config.credentials.access_token + or "" + ) + if not token: + raise ValueError( + "API token required for Haystack authentication" + ) + + token_config = HaystackTokenConfig(token=token) + return cls(token_config.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Haystack client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "HaystackClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service + + Returns: + HaystackClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], + toolset_config.get("credentials", {}) or {}, + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + access_token: str = str( + credentials.get("access_token", "") + or auth_config.get("apiToken", "") + or auth_config.get("token", "") + ) + if not access_token: + raise ValueError( + "Access token not found in toolset config" + ) + + token_config = HaystackTokenConfig(token=access_token) + return cls(token_config.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Haystack client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Haystack.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get Haystack connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error( + f"Failed to get Haystack connector config: {e}" + ) + raise ValueError( + f"Failed to get Haystack connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/highspot/highspot.py b/backend/python/app/sources/client/highspot/highspot.py new file mode 100644 index 000000000..c9e9c49a3 --- /dev/null +++ b/backend/python/app/sources/client/highspot/highspot.py @@ -0,0 +1,507 @@ +"""Highspot client implementation. + +This module provides clients for interacting with the Highspot API using either: +1. OAuth 2.0 (authorization code flow) +2. Bearer Token authentication + +OAuth Auth Endpoint: https://app.highspot.com/oauth2/authorize +OAuth Token Endpoint: https://app.highspot.com/oauth2/token +API Reference: https://api.highspot.com/v1 +""" + +import base64 +import json +import logging +from enum import Enum +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class HighspotAuthType(str, Enum): + """Authentication types supported by the Highspot connector.""" + + OAUTH = "OAUTH" + TOKEN = "TOKEN" + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class HighspotResponse(BaseModel): + """Standardized Highspot API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client classes +# --------------------------------------------------------------------------- + + +class HighspotRESTClientViaOAuth(HTTPClient): + """Highspot REST client via OAuth 2.0 authorization code flow. + + OAuth tokens are passed as Bearer tokens in the Authorization header. + + Args: + access_token: The OAuth access token + client_id: OAuth client ID (for token refresh) + client_secret: OAuth client secret (for token refresh) + redirect_uri: OAuth redirect URI + base_url: API base URL (default: https://api.highspot.com/v1) + """ + + def __init__( + self, + access_token: str, + client_id: str | None = None, + client_secret: str | None = None, + redirect_uri: str | None = None, + base_url: str = "https://api.highspot.com/v1", + ) -> None: + super().__init__(access_token, "Bearer") + self.base_url = base_url + self.access_token = access_token + self.client_id = client_id + self.client_secret = client_secret + self.redirect_uri = redirect_uri + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +class HighspotRESTClientViaToken(HTTPClient): + """Highspot REST client via pre-generated Bearer token. + + Simple authentication using a pre-generated token passed directly + in the Authorization header. + + Args: + token: The pre-generated Bearer token + base_url: API base URL (default: https://api.highspot.com/v1) + """ + + def __init__( + self, + token: str, + base_url: str = "https://api.highspot.com/v1", + ) -> None: + super().__init__(token, token_type="Bearer") + self.base_url = base_url + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class HighspotOAuthConfig(BaseModel): + """Configuration for Highspot client via OAuth 2.0. + + Args: + access_token: The OAuth access token + client_id: OAuth client ID + client_secret: OAuth client secret + redirect_uri: OAuth redirect URI + base_url: API base URL (default: https://api.highspot.com/v1) + """ + + access_token: str + client_id: str | None = None + client_secret: str | None = None + redirect_uri: str | None = None + base_url: str = "https://api.highspot.com/v1" + + def create_client(self) -> HighspotRESTClientViaOAuth: + return HighspotRESTClientViaOAuth( + self.access_token, + self.client_id, + self.client_secret, + self.redirect_uri, + self.base_url, + ) + + +class HighspotTokenConfig(BaseModel): + """Configuration for Highspot client via Bearer token. + + Args: + token: The pre-generated Bearer token + base_url: API base URL (default: https://api.highspot.com/v1) + """ + + token: str + base_url: str = "https://api.highspot.com/v1" + + def create_client(self) -> HighspotRESTClientViaToken: + return HighspotRESTClientViaToken(self.token, self.base_url) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class HighspotAuthConfigModel(BaseModel): + """Auth section of the Highspot connector configuration from etcd.""" + + authType: HighspotAuthType = HighspotAuthType.TOKEN + apiToken: str | None = None + token: str | None = None + clientId: str | None = None + clientSecret: str | None = None + redirectUri: str | None = None + oauthConfigId: str | None = None + + class Config: + extra = "allow" + + +class HighspotCredentialsConfig(BaseModel): + """Credentials section of the Highspot connector configuration.""" + + access_token: str | None = None + refresh_token: str | None = None + + class Config: + extra = "allow" + + +class HighspotConnectorConfig(BaseModel): + """Top-level Highspot connector configuration from etcd.""" + + auth: HighspotAuthConfigModel = Field( + default_factory=HighspotAuthConfigModel + ) + credentials: HighspotCredentialsConfig = Field( + default_factory=HighspotCredentialsConfig + ) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class HighspotClient(IClient): + """Builder class for Highspot clients with different authentication methods. + + Supports: + - OAuth 2.0 authorization code flow + - Pre-generated Bearer token + """ + + def __init__( + self, + client: HighspotRESTClientViaOAuth | HighspotRESTClientViaToken, + ) -> None: + """Initialize with a Highspot client object.""" + super().__init__() + self.client = client + + @override + def get_client( + self, + ) -> HighspotRESTClientViaOAuth | HighspotRESTClientViaToken: + """Return the Highspot client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: HighspotOAuthConfig | HighspotTokenConfig, + ) -> "HighspotClient": + """Build HighspotClient with configuration. + + Args: + config: HighspotOAuthConfig or HighspotTokenConfig instance + + Returns: + HighspotClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "HighspotClient": + """Build HighspotClient using configuration service. + + Supports two authentication strategies: + 1. OAUTH: OAuth 2.0 access token + 2. TOKEN: Pre-generated Bearer token + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + HighspotClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get Highspot connector configuration" + ) + + connector_config = HighspotConnectorConfig.model_validate( + raw_config + ) + + if connector_config.auth.authType == HighspotAuthType.OAUTH: + access_token = connector_config.credentials.access_token or "" + client_id = connector_config.auth.clientId or "" + client_secret = connector_config.auth.clientSecret or "" + redirect_uri = connector_config.auth.redirectUri or "" + + # Try shared OAuth config if credentials are missing + oauth_config_id = connector_config.auth.oauthConfigId + if oauth_config_id and not (client_id and client_secret): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/highspot", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + if not access_token: + raise ValueError( + "Access token required for OAuth auth type" + ) + + oauth_cfg = HighspotOAuthConfig( + access_token=access_token, + client_id=client_id, + client_secret=client_secret, + redirect_uri=redirect_uri, + ) + return cls(oauth_cfg.create_client()) + + elif connector_config.auth.authType == HighspotAuthType.TOKEN: + token = ( + connector_config.auth.apiToken + or connector_config.auth.token + or "" + ) + if not token: + raise ValueError( + "Token required for TOKEN auth type" + ) + + token_config = HighspotTokenConfig(token=token) + return cls(token_config.create_client()) + + else: + raise ValueError( + f"Invalid auth type: {connector_config.auth.authType}" + ) + + except Exception as e: + logger.error( + f"Failed to build Highspot client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "HighspotClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service for shared OAuth config + + Returns: + HighspotClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("credentials", {}) or {} + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + access_token: str = str(credentials.get("access_token", "")) + if not access_token: + raise ValueError( + "Access token not found in toolset config" + ) + + client_id: str = str(auth_config.get("clientId", "")) + client_secret: str = str(auth_config.get("clientSecret", "")) + + # Try shared OAuth config + oauth_config_id: str | None = cast( + str | None, auth_config.get("oauthConfigId") + ) + if oauth_config_id and config_service and not ( + client_id and client_secret + ): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/highspot", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + oauth_cfg = HighspotOAuthConfig( + access_token=access_token, + client_id=client_id, + client_secret=client_secret, + ) + return cls(oauth_cfg.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Highspot client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Highspot.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get Highspot connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error(f"Failed to get Highspot connector config: {e}") + raise ValueError( + f"Failed to get Highspot connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/hubspot/hubspot.py b/backend/python/app/sources/client/hubspot/hubspot.py new file mode 100644 index 000000000..d415c2e63 --- /dev/null +++ b/backend/python/app/sources/client/hubspot/hubspot.py @@ -0,0 +1,486 @@ +"""HubSpot client implementation using the official hubspot-api-client SDK. + +This module provides clients for interacting with the HubSpot API using either: +1. OAuth 2.0 authorization code flow +2. Private App Access Token (Bearer token) + +The underlying SDK is ``hubspot-api-client`` (PyPI). All API access is routed +through the ``HubSpot`` object which is created via +``HubSpot(access_token=token)``. + +Authentication Reference: https://developers.hubspot.com/docs/api/working-with-oauth +API Reference: https://developers.hubspot.com/docs/api/overview +""" + +import logging +from enum import Enum +from typing import Any, cast + +from hubspot import HubSpot as HubSpotSDK # type: ignore[import-untyped] +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class HubSpotAuthType(str, Enum): + """Authentication types supported by the HubSpot connector.""" + + OAUTH = "OAUTH" + TOKEN = "TOKEN" + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class HubSpotResponse(BaseModel): + """Standardized HubSpot API response wrapper. + + The data field holds deserialized SDK response objects (dicts, lists, + or any SDK model that has been converted to a plain structure). + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data from the HubSpot SDK" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary.""" + return self.model_dump(exclude_none=True) + + +# --------------------------------------------------------------------------- +# SDK wrapper classes +# --------------------------------------------------------------------------- + + +class HubSpotClientViaOAuth: + """HubSpot SDK wrapper using OAuth 2.0 access token. + + Creates a ``HubSpot`` SDK instance authenticated with an OAuth access + token. Stores client_id / client_secret for potential token-refresh + flows handled at a higher layer. + + Args: + access_token: The OAuth access token. + client_id: OAuth client ID (used for token refresh externally). + client_secret: OAuth client secret (used for token refresh externally). + """ + + def __init__( + self, + access_token: str, + client_id: str | None = None, + client_secret: str | None = None, + ) -> None: + self.access_token = access_token + self.client_id = client_id + self.client_secret = client_secret + self._sdk: HubSpotSDK = HubSpotSDK(access_token=access_token) # type: ignore[reportInvalidTypeForm] + + def get_sdk(self) -> HubSpotSDK: # type: ignore[reportInvalidTypeForm] + """Return the underlying ``HubSpot`` SDK instance.""" + return self._sdk # type: ignore[reportUnknownMemberType,reportUnknownVariableType] + + +class HubSpotClientViaToken: + """HubSpot SDK wrapper using a Private App access token. + + Private-app tokens do not expire and do not need refresh. + + Args: + token: The Private App access token. + """ + + def __init__(self, token: str) -> None: + self.token = token + self._sdk: HubSpotSDK = HubSpotSDK(access_token=token) # type: ignore[reportInvalidTypeForm] + + def get_sdk(self) -> HubSpotSDK: # type: ignore[reportInvalidTypeForm] + """Return the underlying ``HubSpot`` SDK instance.""" + return self._sdk # type: ignore[reportUnknownMemberType,reportUnknownVariableType] + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class HubSpotOAuthConfig(BaseModel): + """Configuration for HubSpot client via OAuth 2.0. + + Args: + access_token: The OAuth access token. + client_id: OAuth client ID. + client_secret: OAuth client secret. + """ + + access_token: str + client_id: str | None = None + client_secret: str | None = None + + def create_client(self) -> HubSpotClientViaOAuth: + return HubSpotClientViaOAuth( + self.access_token, + self.client_id, + self.client_secret, + ) + + +class HubSpotTokenConfig(BaseModel): + """Configuration for HubSpot client via Private App Access Token. + + Args: + token: The Private App access token. + """ + + token: str + + def create_client(self) -> HubSpotClientViaToken: + return HubSpotClientViaToken(self.token) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class HubSpotAuthConfig(BaseModel): + """Auth section of the HubSpot connector configuration from etcd.""" + + authType: HubSpotAuthType = HubSpotAuthType.TOKEN + apiToken: str | None = None + token: str | None = None + clientId: str | None = None + clientSecret: str | None = None + redirectUri: str | None = None + oauthConfigId: str | None = None + + class Config: + extra = "allow" + + +class HubSpotCredentialsConfig(BaseModel): + """Credentials section of the HubSpot connector configuration.""" + + access_token: str | None = None + refresh_token: str | None = None + + class Config: + extra = "allow" + + +class HubSpotConnectorConfig(BaseModel): + """Top-level HubSpot connector configuration from etcd.""" + + auth: HubSpotAuthConfig = Field(default_factory=HubSpotAuthConfig) + credentials: HubSpotCredentialsConfig = Field( + default_factory=HubSpotCredentialsConfig + ) + + class Config: + extra = "allow" + + +class HubSpotSharedOAuthConfigEntry(BaseModel): + """A single entry from the shared OAuth config list in etcd. + + Handles both camelCase and snake_case key variants from the config store. + """ + + entry_id: str | None = Field(default=None, alias="_id") + clientId: str | None = None + client_id: str | None = None + clientSecret: str | None = None + client_secret: str | None = None + redirectUri: str | None = None + redirect_uri: str | None = None + + class Config: + extra = "allow" + populate_by_name = True + + def resolved_client_id(self, fallback: str = "") -> str: + return self.clientId or self.client_id or fallback + + def resolved_client_secret(self, fallback: str = "") -> str: + return self.clientSecret or self.client_secret or fallback + + def resolved_redirect_uri(self, fallback: str = "") -> str: + return self.redirectUri or self.redirect_uri or fallback + + +class HubSpotSharedOAuthWrapper(BaseModel): + """Wrapper for a shared OAuth config entry with nested config.""" + + entry_id: str | None = Field(default=None, alias="_id") + config: HubSpotSharedOAuthConfigEntry = Field( + default_factory=HubSpotSharedOAuthConfigEntry + ) + + class Config: + extra = "allow" + populate_by_name = True + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class HubSpotClient(IClient): + """Builder class for HubSpot clients with different authentication methods. + + Supports: + - OAuth 2.0 authorization code flow + - Private App Access Token (Bearer token) + """ + + def __init__( + self, + client: HubSpotClientViaOAuth | HubSpotClientViaToken, + ) -> None: + """Initialize with a HubSpot SDK wrapper.""" + super().__init__() + self.client = client + + @override + def get_client( + self, + ) -> HubSpotClientViaOAuth | HubSpotClientViaToken: + """Return the HubSpot SDK wrapper.""" + return self.client + + def get_sdk(self) -> HubSpotSDK: # type: ignore[reportInvalidTypeForm] + """Return the underlying ``HubSpot`` SDK instance.""" + return self.client.get_sdk() # type: ignore[reportUnknownMemberType,reportUnknownVariableType] + + @classmethod + def build_with_config( + cls, + config: HubSpotOAuthConfig | HubSpotTokenConfig, + ) -> "HubSpotClient": + """Build HubSpotClient with configuration. + + Args: + config: HubSpotOAuthConfig or HubSpotTokenConfig instance + + Returns: + HubSpotClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "HubSpotClient": + """Build HubSpotClient using configuration service. + + Supports two authentication strategies: + 1. OAUTH: OAuth 2.0 authorization code flow with access token + 2. TOKEN: Private App access token + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + HubSpotClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError("Failed to get HubSpot connector configuration") + + connector_config = HubSpotConnectorConfig.model_validate(raw_config) + + if connector_config.auth.authType == HubSpotAuthType.OAUTH: + access_token = connector_config.credentials.access_token or "" + client_id = connector_config.auth.clientId or "" + client_secret = connector_config.auth.clientSecret or "" + + # Try shared OAuth config if credentials are missing + oauth_config_id = connector_config.auth.oauthConfigId + if oauth_config_id and not (client_id and client_secret): + shared_cfg = await cls._find_shared_oauth_config( + config_service, oauth_config_id, logger + ) + if shared_cfg: + client_id = shared_cfg.resolved_client_id(client_id) + client_secret = shared_cfg.resolved_client_secret( + client_secret + ) + + if not access_token: + raise ValueError( + "Access token required for OAuth auth type" + ) + + oauth_cfg = HubSpotOAuthConfig( + access_token=access_token, + client_id=client_id, + client_secret=client_secret, + ) + return cls(oauth_cfg.create_client()) + + elif connector_config.auth.authType == HubSpotAuthType.TOKEN: + token = ( + connector_config.auth.apiToken + or connector_config.auth.token + or "" + ) + if not token: + raise ValueError( + "Token required for TOKEN auth type" + ) + + token_config = HubSpotTokenConfig(token=token) + return cls(token_config.create_client()) + + else: + raise ValueError( + f"Invalid auth type: {connector_config.auth.authType}" + ) + + except Exception as e: + logger.error( + f"Failed to build HubSpot client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "HubSpotClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service for shared OAuth config + + Returns: + HubSpotClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("credentials", {}) or {} + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + access_token: str = str(credentials.get("access_token", "")) + if not access_token: + raise ValueError("Access token not found in toolset config") + + client_id: str = str(auth_config.get("clientId", "")) + client_secret: str = str(auth_config.get("clientSecret", "")) + + # Try shared OAuth config + oauth_config_id: str | None = cast( + str | None, auth_config.get("oauthConfigId") + ) + if oauth_config_id and config_service and not ( + client_id and client_secret + ): + shared_cfg = await cls._find_shared_oauth_config( + config_service, oauth_config_id, logger + ) + if shared_cfg: + client_id = shared_cfg.resolved_client_id(client_id) + client_secret = shared_cfg.resolved_client_secret( + client_secret + ) + + oauth_cfg = HubSpotOAuthConfig( + access_token=access_token, + client_id=client_id, + client_secret=client_secret, + ) + return cls(oauth_cfg.create_client()) + + except Exception as e: + logger.error( + f"Failed to build HubSpot client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _find_shared_oauth_config( + config_service: ConfigurationService, + oauth_config_id: str, + logger: logging.Logger, + ) -> HubSpotSharedOAuthConfigEntry | None: + """Look up shared OAuth config by ID from the config store. + + Args: + config_service: Configuration service instance + oauth_config_id: The shared OAuth config ID to match + logger: Logger instance + + Returns: + Matched HubSpotSharedOAuthConfigEntry or None + """ + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/hubspot", default=[] + ) + entries: list[object] = list(raw) if isinstance(raw, list) else [] # type: ignore[reportUnknownArgumentType] + for entry in entries: + wrapper = HubSpotSharedOAuthWrapper.model_validate(entry) + if wrapper.entry_id == oauth_config_id: + return wrapper.config + except Exception as e: + logger.warning(f"Failed to fetch shared OAuth config: {e}") + return None + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for HubSpot.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get HubSpot connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error(f"Failed to get HubSpot connector config: {e}") + raise ValueError( + f"Failed to get HubSpot connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/iapsso/iapsso.py b/backend/python/app/sources/client/iapsso/iapsso.py new file mode 100644 index 000000000..6c9ff1e08 --- /dev/null +++ b/backend/python/app/sources/client/iapsso/iapsso.py @@ -0,0 +1,515 @@ +"""IAP SSO (Google Cloud Identity-Aware Proxy) client implementation. + +This module provides clients for interacting with the Google Cloud IAP API +using either: +1. OAuth2 (Google OAuth authorization code flow) +2. Pre-generated Bearer token (e.g. from a Service Account) + +OAuth Auth Endpoint: https://accounts.google.com/o/oauth2/v2/auth +Token Endpoint: https://oauth2.googleapis.com/token +Auth Method: body +Scopes: https://www.googleapis.com/auth/cloud-platform +API Base URL: https://iap.googleapis.com/v1 +""" + +import base64 +import json +import logging +from enum import Enum +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class IAPSSOAuthType(str, Enum): + """Authentication types supported by the IAP SSO connector.""" + + OAUTH = "OAUTH" + TOKEN = "TOKEN" + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class IAPSSOResponse(BaseModel): + """Standardized IAP SSO API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client classes +# --------------------------------------------------------------------------- + +_BASE_URL = "https://iap.googleapis.com/v1" + + +class IAPSSORESTClientViaOAuth(HTTPClient): + """IAP SSO REST client via OAuth 2.0 authorization code flow. + + OAuth tokens are passed as Bearer tokens in the Authorization header. + Supports token refresh via client_id and client_secret. + + Args: + access_token: The OAuth access token + client_id: OAuth client ID (for token refresh) + client_secret: OAuth client secret (for token refresh) + redirect_uri: OAuth redirect URI + """ + + def __init__( + self, + access_token: str, + client_id: str | None = None, + client_secret: str | None = None, + redirect_uri: str | None = None, + ) -> None: + super().__init__(access_token, "Bearer") + self.base_url = _BASE_URL + self.access_token = access_token + self.client_id = client_id + self.client_secret = client_secret + self.redirect_uri = redirect_uri + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +class IAPSSORESTClientViaToken(HTTPClient): + """IAP SSO REST client via pre-generated Bearer token. + + Simple authentication using a pre-generated token (e.g. from a + Service Account) passed directly in the Authorization header. + + Args: + token: The pre-generated Bearer token + """ + + def __init__( + self, + token: str, + ) -> None: + super().__init__(token, token_type="Bearer") + self.base_url = _BASE_URL + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class IAPSSOOAuthConfig(BaseModel): + """Configuration for IAP SSO client via OAuth 2.0. + + Args: + access_token: The OAuth access token + client_id: OAuth client ID + client_secret: OAuth client secret + redirect_uri: OAuth redirect URI + """ + + access_token: str + client_id: str | None = None + client_secret: str | None = None + redirect_uri: str | None = None + + def create_client(self) -> IAPSSORESTClientViaOAuth: + return IAPSSORESTClientViaOAuth( + self.access_token, + self.client_id, + self.client_secret, + self.redirect_uri, + ) + + +class IAPSSOTokenConfig(BaseModel): + """Configuration for IAP SSO client via Bearer token. + + Args: + token: The pre-generated Bearer token + """ + + token: str + + def create_client(self) -> IAPSSORESTClientViaToken: + return IAPSSORESTClientViaToken(self.token) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class IAPSSOAuthConfig(BaseModel): + """Auth section of the IAP SSO connector configuration from etcd.""" + + authType: IAPSSOAuthType = IAPSSOAuthType.OAUTH + clientId: str | None = None + clientSecret: str | None = None + redirectUri: str | None = None + token: str | None = None + oauthConfigId: str | None = None + + class Config: + extra = "allow" + + +class IAPSSOCredentialsConfig(BaseModel): + """Credentials section of the IAP SSO connector configuration.""" + + access_token: str | None = None + refresh_token: str | None = None + + class Config: + extra = "allow" + + +class IAPSSOConnectorConfig(BaseModel): + """Top-level IAP SSO connector configuration from etcd.""" + + auth: IAPSSOAuthConfig = Field(default_factory=IAPSSOAuthConfig) + credentials: IAPSSOCredentialsConfig = Field( + default_factory=IAPSSOCredentialsConfig + ) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class IAPSSOClient(IClient): + """Builder class for IAP SSO clients. + + Supports: + - OAuth 2.0 authorization code flow + - Pre-generated Bearer token (Service Account) + """ + + def __init__( + self, + client: IAPSSORESTClientViaOAuth | IAPSSORESTClientViaToken, + ) -> None: + """Initialize with an IAP SSO client object.""" + super().__init__() + self.client = client + + @override + def get_client( + self, + ) -> IAPSSORESTClientViaOAuth | IAPSSORESTClientViaToken: + """Return the IAP SSO client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: IAPSSOOAuthConfig | IAPSSOTokenConfig, + ) -> "IAPSSOClient": + """Build IAPSSOClient with configuration. + + Args: + config: IAPSSOOAuthConfig or IAPSSOTokenConfig instance + + Returns: + IAPSSOClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "IAPSSOClient": + """Build IAPSSOClient using configuration service. + + Supports two authentication strategies: + 1. OAUTH: OAuth 2.0 authorization code flow with access token + 2. TOKEN: Pre-generated Bearer token + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + IAPSSOClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get IAP SSO connector configuration" + ) + + connector_config = IAPSSOConnectorConfig.model_validate( + raw_config + ) + + if connector_config.auth.authType == IAPSSOAuthType.OAUTH: + access_token = ( + connector_config.credentials.access_token or "" + ) + client_id = connector_config.auth.clientId or "" + client_secret = connector_config.auth.clientSecret or "" + redirect_uri = connector_config.auth.redirectUri or "" + + # Try shared OAuth config if credentials are missing + oauth_config_id = connector_config.auth.oauthConfigId + if oauth_config_id and not (client_id and client_secret): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/iapsso", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + redirect_uri = str( + shared.get("redirectUri") + or shared.get("redirect_uri") + or redirect_uri + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + if not access_token: + raise ValueError( + "Access token required for OAuth auth type" + ) + + oauth_cfg = IAPSSOOAuthConfig( + access_token=access_token, + client_id=client_id, + client_secret=client_secret, + redirect_uri=redirect_uri, + ) + return cls(oauth_cfg.create_client()) + + elif connector_config.auth.authType == IAPSSOAuthType.TOKEN: + token = connector_config.auth.token or "" + if not token: + raise ValueError( + "Token required for TOKEN auth type" + ) + + token_config = IAPSSOTokenConfig(token=token) + return cls(token_config.create_client()) + + else: + raise ValueError( + f"Invalid auth type: {connector_config.auth.authType}" + ) + + except Exception as e: + logger.error( + "Failed to build IAP SSO client from services: " + f"{str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "IAPSSOClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service for shared OAuth + + Returns: + IAPSSOClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("credentials", {}) or {} + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + access_token: str = str(credentials.get("access_token", "")) + if not access_token: + raise ValueError( + "Access token not found in toolset config" + ) + + client_id: str = str(auth_config.get("clientId", "")) + client_secret: str = str(auth_config.get("clientSecret", "")) + redirect_uri: str = str(auth_config.get("redirectUri", "")) + + # Try shared OAuth config + oauth_config_id: str | None = cast( + str | None, auth_config.get("oauthConfigId") + ) + if oauth_config_id and config_service and not ( + client_id and client_secret + ): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/iapsso", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + redirect_uri = str( + shared.get("redirectUri") + or shared.get("redirect_uri") + or redirect_uri + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + oauth_cfg = IAPSSOOAuthConfig( + access_token=access_token, + client_id=client_id, + client_secret=client_secret, + redirect_uri=redirect_uri, + ) + return cls(oauth_cfg.create_client()) + + except Exception as e: + logger.error( + "Failed to build IAP SSO client from toolset: " + f"{str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for IAP SSO.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + "Failed to get IAP SSO connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error( + f"Failed to get IAP SSO connector config: {e}" + ) + raise ValueError( + "Failed to get IAP SSO connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/insided/insided.py b/backend/python/app/sources/client/insided/insided.py new file mode 100644 index 000000000..4f7891eb0 --- /dev/null +++ b/backend/python/app/sources/client/insided/insided.py @@ -0,0 +1,434 @@ +"""InSided (Gainsight Customer Communities) client implementation. + +This module provides clients for interacting with the InSided API using either: +1. OAuth2 client_credentials authentication +2. Bearer token authentication + +API Reference: https://api.insided.com/docs +""" + +import base64 +import json +import logging +from enum import Enum +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class InSidedAuthType(str, Enum): + """Authentication types supported by the InSided connector.""" + + CLIENT_CREDENTIALS = "CLIENT_CREDENTIALS" + TOKEN = "TOKEN" + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class InSidedResponse(BaseModel): + """Standardized InSided API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client classes +# --------------------------------------------------------------------------- + + +class InSidedRESTClientViaClientCredentials(HTTPClient): + """InSided REST client via OAuth2 client_credentials flow. + + Fetches a token from the InSided OAuth2 token endpoint using + client_id and client_secret, then uses that token for API calls. + + Args: + client_id: OAuth2 client ID + client_secret: OAuth2 client secret + token_endpoint: Token endpoint URL + """ + + def __init__( + self, + client_id: str, + client_secret: str, + token_endpoint: str = "https://api.insided.com/oauth2/token", + ) -> None: + # Initialize with empty token; will be set after fetching + super().__init__("", token_type="Bearer") + self.base_url = "https://api.insided.com/v2" + self.client_id = client_id + self.client_secret = client_secret + self.token_endpoint = token_endpoint + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +class InSidedRESTClientViaToken(HTTPClient): + """InSided REST client via Bearer token. + + Args: + token: Bearer token for authentication + """ + + def __init__(self, token: str) -> None: + super().__init__(token, "Bearer") + self.base_url = "https://api.insided.com/v2" + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class InSidedClientCredentialsConfig(BaseModel): + """Configuration for InSided client via client_credentials. + + Args: + client_id: OAuth2 client ID + client_secret: OAuth2 client secret + token_endpoint: Token endpoint URL + """ + + client_id: str + client_secret: str + token_endpoint: str = "https://api.insided.com/oauth2/token" + + def create_client(self) -> InSidedRESTClientViaClientCredentials: + return InSidedRESTClientViaClientCredentials( + self.client_id, self.client_secret, self.token_endpoint + ) + + +class InSidedTokenConfig(BaseModel): + """Configuration for InSided client via Bearer token. + + Args: + token: Bearer token + """ + + token: str + + def create_client(self) -> InSidedRESTClientViaToken: + return InSidedRESTClientViaToken(self.token) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class InSidedAuthConfig(BaseModel): + """Auth section of the InSided connector configuration from etcd.""" + + authType: InSidedAuthType = InSidedAuthType.TOKEN + clientId: str | None = None + clientSecret: str | None = None + token: str | None = None + oauthConfigId: str | None = None + + class Config: + extra = "allow" + + +class InSidedCredentialsConfig(BaseModel): + """Credentials section of the InSided connector configuration.""" + + access_token: str | None = None + + class Config: + extra = "allow" + + +class InSidedConnectorConfig(BaseModel): + """Top-level InSided connector configuration from etcd.""" + + auth: InSidedAuthConfig = Field(default_factory=InSidedAuthConfig) + credentials: InSidedCredentialsConfig = Field( + default_factory=InSidedCredentialsConfig + ) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class InSidedClient(IClient): + """Builder class for InSided clients with different authentication methods. + + Supports: + - OAuth2 client_credentials authentication + - Bearer token authentication + """ + + def __init__( + self, + client: InSidedRESTClientViaClientCredentials | InSidedRESTClientViaToken, + ) -> None: + """Initialize with an InSided client object.""" + super().__init__() + self.client = client + + @override + def get_client( + self, + ) -> InSidedRESTClientViaClientCredentials | InSidedRESTClientViaToken: + """Return the InSided client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: InSidedClientCredentialsConfig | InSidedTokenConfig, + ) -> "InSidedClient": + """Build InSidedClient with configuration. + + Args: + config: InSidedClientCredentialsConfig or InSidedTokenConfig instance + + Returns: + InSidedClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "InSidedClient": + """Build InSidedClient using configuration service. + + Supports two authentication strategies: + 1. CLIENT_CREDENTIALS: For OAuth2 client_credentials flow + 2. TOKEN: For Bearer token authentication + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + InSidedClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get InSided connector configuration" + ) + + connector_config = InSidedConnectorConfig.model_validate( + raw_config + ) + + if ( + connector_config.auth.authType + == InSidedAuthType.CLIENT_CREDENTIALS + ): + client_id = connector_config.auth.clientId or "" + client_secret = connector_config.auth.clientSecret or "" + + # Try shared OAuth config if credentials are missing + oauth_config_id = connector_config.auth.oauthConfigId + if oauth_config_id and not (client_id and client_secret): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/insided", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + if not (client_id and client_secret): + raise ValueError( + "client_id and client_secret required for " + "CLIENT_CREDENTIALS auth type" + ) + + cc_config = InSidedClientCredentialsConfig( + client_id=client_id, + client_secret=client_secret, + ) + return cls(cc_config.create_client()) + + elif connector_config.auth.authType == InSidedAuthType.TOKEN: + token = ( + connector_config.auth.token + or connector_config.credentials.access_token + or "" + ) + if not token: + raise ValueError( + "Token required for TOKEN auth type" + ) + + token_config = InSidedTokenConfig(token=token) + return cls(token_config.create_client()) + + else: + raise ValueError( + f"Invalid auth type: {connector_config.auth.authType}" + ) + + except Exception as e: + logger.error( + f"Failed to build InSided client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "InSidedClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service + + Returns: + InSidedClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], + toolset_config.get("credentials", {}) or {}, + ) + + access_token: str = str(credentials.get("access_token", "")) + if not access_token: + raise ValueError( + "Access token not found in toolset config" + ) + + token_config = InSidedTokenConfig(token=access_token) + return cls(token_config.create_client()) + + except Exception as e: + logger.error( + f"Failed to build InSided client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for InSided.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get InSided connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error( + f"Failed to get InSided connector config: {e}" + ) + raise ValueError( + f"Failed to get InSided connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/interact/interact.py b/backend/python/app/sources/client/interact/interact.py new file mode 100644 index 000000000..7c6b06497 --- /dev/null +++ b/backend/python/app/sources/client/interact/interact.py @@ -0,0 +1,487 @@ +"""Interact (Interact Intranet) client implementation. + +This module provides clients for interacting with the Interact API using either: +1. OAuth2 (authorization code) authentication +2. API Key (Bearer token) authentication + +API Reference: https://developer.interact-intranet.com/ +""" + +import base64 +import json +import logging +from enum import Enum +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class InteractAuthType(str, Enum): + """Authentication types supported by the Interact connector.""" + + OAUTH = "OAUTH" + TOKEN = "TOKEN" + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class InteractResponse(BaseModel): + """Standardized Interact API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client classes +# --------------------------------------------------------------------------- + + +class InteractRESTClientViaOAuth(HTTPClient): + """Interact REST client via OAuth 2.0 access token. + + OAuth tokens are passed as Bearer tokens in the Authorization header. + + Args: + access_token: The OAuth access token + client_id: OAuth client ID (for reference / token refresh) + client_secret: OAuth client secret (for reference / token refresh) + """ + + def __init__( + self, + access_token: str, + client_id: str | None = None, + client_secret: str | None = None, + ) -> None: + super().__init__(access_token, "Bearer") + self.base_url = "https://api.interact-intranet.com/v1" + self.access_token = access_token + self.client_id = client_id + self.client_secret = client_secret + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +class InteractRESTClientViaToken(HTTPClient): + """Interact REST client via API Key (Bearer token). + + Args: + token: API key used as Bearer token + """ + + def __init__(self, token: str) -> None: + super().__init__(token, "Bearer") + self.base_url = "https://api.interact-intranet.com/v1" + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class InteractOAuthConfig(BaseModel): + """Configuration for Interact client via OAuth 2.0. + + Args: + access_token: The OAuth access token + client_id: OAuth client ID + client_secret: OAuth client secret + """ + + access_token: str + client_id: str | None = None + client_secret: str | None = None + + def create_client(self) -> InteractRESTClientViaOAuth: + return InteractRESTClientViaOAuth( + self.access_token, + self.client_id, + self.client_secret, + ) + + +class InteractTokenConfig(BaseModel): + """Configuration for Interact client via API Key. + + Args: + token: API key (Bearer token) + """ + + token: str + + def create_client(self) -> InteractRESTClientViaToken: + return InteractRESTClientViaToken(self.token) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class InteractAuthConfigModel(BaseModel): + """Auth section of the Interact connector configuration from etcd.""" + + authType: InteractAuthType = InteractAuthType.TOKEN + apiToken: str | None = None + token: str | None = None + clientId: str | None = None + clientSecret: str | None = None + redirectUri: str | None = None + oauthConfigId: str | None = None + + class Config: + extra = "allow" + + +class InteractCredentialsConfigModel(BaseModel): + """Credentials section of the Interact connector configuration.""" + + access_token: str | None = None + refresh_token: str | None = None + + class Config: + extra = "allow" + + +class InteractConnectorConfig(BaseModel): + """Top-level Interact connector configuration from etcd.""" + + auth: InteractAuthConfigModel = Field( + default_factory=InteractAuthConfigModel + ) + credentials: InteractCredentialsConfigModel = Field( + default_factory=InteractCredentialsConfigModel + ) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class InteractClient(IClient): + """Builder class for Interact clients with different authentication methods. + + Supports: + - OAuth 2.0 access token authentication + - API Key (Bearer token) authentication + """ + + def __init__( + self, + client: InteractRESTClientViaOAuth | InteractRESTClientViaToken, + ) -> None: + """Initialize with an Interact client object.""" + super().__init__() + self.client = client + + @override + def get_client( + self, + ) -> InteractRESTClientViaOAuth | InteractRESTClientViaToken: + """Return the Interact client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: InteractOAuthConfig | InteractTokenConfig, + ) -> "InteractClient": + """Build InteractClient with configuration. + + Args: + config: InteractOAuthConfig or InteractTokenConfig instance + + Returns: + InteractClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "InteractClient": + """Build InteractClient using configuration service. + + Supports two authentication strategies: + 1. OAUTH: For OAuth 2.0 access tokens + 2. TOKEN: For API Key (Bearer token) authentication + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + InteractClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get Interact connector configuration" + ) + + connector_config = InteractConnectorConfig.model_validate( + raw_config + ) + + if connector_config.auth.authType == InteractAuthType.OAUTH: + access_token = ( + connector_config.credentials.access_token or "" + ) + client_id = connector_config.auth.clientId or "" + client_secret = connector_config.auth.clientSecret or "" + + # Try shared OAuth config if credentials are missing + oauth_config_id = connector_config.auth.oauthConfigId + if oauth_config_id and not (client_id and client_secret): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/interact", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + if not access_token: + raise ValueError( + "Access token required for OAuth auth type" + ) + + oauth_cfg = InteractOAuthConfig( + access_token=access_token, + client_id=client_id, + client_secret=client_secret, + ) + return cls(oauth_cfg.create_client()) + + elif connector_config.auth.authType == InteractAuthType.TOKEN: + token = ( + connector_config.auth.apiToken + or connector_config.auth.token + or "" + ) + if not token: + raise ValueError( + "Token required for TOKEN auth type" + ) + + token_config = InteractTokenConfig(token=token) + return cls(token_config.create_client()) + + else: + raise ValueError( + f"Invalid auth type: {connector_config.auth.authType}" + ) + + except Exception as e: + logger.error( + f"Failed to build Interact client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "InteractClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service + + Returns: + InteractClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], + toolset_config.get("credentials", {}) or {}, + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + access_token: str = str(credentials.get("access_token", "")) + if not access_token: + raise ValueError( + "Access token not found in toolset config" + ) + + client_id: str = str(auth_config.get("clientId", "")) + client_secret: str = str(auth_config.get("clientSecret", "")) + + # Try shared OAuth config + oauth_config_id: str | None = cast( + str | None, auth_config.get("oauthConfigId") + ) + if oauth_config_id and config_service and not ( + client_id and client_secret + ): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/interact", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + oauth_cfg = InteractOAuthConfig( + access_token=access_token, + client_id=client_id, + client_secret=client_secret, + ) + return cls(oauth_cfg.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Interact client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Interact.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get Interact connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error( + f"Failed to get Interact connector config: {e}" + ) + raise ValueError( + f"Failed to get Interact connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/intercom/intercom.py b/backend/python/app/sources/client/intercom/intercom.py new file mode 100644 index 000000000..0ff25e4f7 --- /dev/null +++ b/backend/python/app/sources/client/intercom/intercom.py @@ -0,0 +1,446 @@ +"""Intercom client implementation. + +This module provides clients for interacting with the Intercom API using either: +1. OAuth 2.0 access token authentication +2. Access Token (Bearer) authentication + +Authentication Reference: https://developers.intercom.com/docs/build-an-integration/learn-more/authentication/ +API Reference: https://developers.intercom.com/docs/references/rest-api/api.intercom.io/ +""" + +import logging +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +logger = logging.getLogger(__name__) + + +class IntercomResponse(BaseModel): + """Standardized Intercom API response wrapper.""" + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | None = Field( + default=None, description="Response data" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + def to_dict(self) -> dict[str, object]: + """Convert to dictionary for JSON serialization.""" + return self.model_dump(exclude_none=True) + + def to_json(self) -> str: + """Convert to JSON string.""" + return self.model_dump_json(exclude_none=True) + + +class IntercomRESTClientViaOAuth(HTTPClient): + """Intercom REST client via OAuth 2.0 access token. + + OAuth tokens are passed as Bearer tokens in the Authorization header. + + Args: + access_token: The OAuth access token + client_id: OAuth client ID (for reference / token refresh) + client_secret: OAuth client secret (for reference / token refresh) + """ + + def __init__( + self, + access_token: str, + client_id: str | None = None, + client_secret: str | None = None, + ) -> None: + super().__init__(access_token, "Bearer") + self.base_url = "https://api.intercom.io" + self.access_token = access_token + self.client_id = client_id + self.client_secret = client_secret + self.headers["Content-Type"] = "application/json" + self.headers["Accept"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +class IntercomRESTClientViaToken(HTTPClient): + """Intercom REST client via Access Token. + + Access tokens are passed as Bearer tokens in the Authorization header. + + Args: + access_token: The access token + """ + + def __init__(self, access_token: str) -> None: + super().__init__(access_token, "Bearer") + self.base_url = "https://api.intercom.io" + self.headers["Content-Type"] = "application/json" + self.headers["Accept"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class IntercomOAuthConfig(BaseModel): + """Configuration for Intercom client via OAuth 2.0. + + Args: + access_token: The OAuth access token + client_id: OAuth client ID + client_secret: OAuth client secret + """ + + access_token: str + client_id: str | None = None + client_secret: str | None = None + + def create_client(self) -> IntercomRESTClientViaOAuth: + """Create an Intercom OAuth REST client.""" + return IntercomRESTClientViaOAuth( + self.access_token, + self.client_id, + self.client_secret, + ) + + +class IntercomTokenConfig(BaseModel): + """Configuration for Intercom client via Access Token. + + Args: + access_token: The access token + """ + + access_token: str + + def create_client(self) -> IntercomRESTClientViaToken: + """Create an Intercom Token REST client.""" + return IntercomRESTClientViaToken(self.access_token) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class IntercomAuthConfig(BaseModel): + """Auth section of the Intercom connector configuration from etcd.""" + + authType: str = "OAUTH" + apiToken: str | None = None + token: str | None = None + clientId: str | None = None + clientSecret: str | None = None + redirectUri: str | None = None + oauthConfigId: str | None = None + + class Config: + extra = "allow" + + +class IntercomCredentialsConfig(BaseModel): + """Credentials section of the Intercom connector configuration.""" + + access_token: str | None = None + refresh_token: str | None = None + + class Config: + extra = "allow" + + +class IntercomConnectorConfig(BaseModel): + """Top-level Intercom connector configuration from etcd.""" + + auth: IntercomAuthConfig = Field(default_factory=IntercomAuthConfig) + credentials: IntercomCredentialsConfig = Field( + default_factory=IntercomCredentialsConfig + ) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class IntercomClient(IClient): + """Builder class for Intercom clients with different authentication methods. + + Supports: + - OAuth 2.0 access token authentication + - Access Token (Bearer) authentication + """ + + def __init__( + self, + client: IntercomRESTClientViaOAuth | IntercomRESTClientViaToken, + ) -> None: + """Initialize with an Intercom client object.""" + super().__init__() + self.client = client + + @override + def get_client( + self, + ) -> IntercomRESTClientViaOAuth | IntercomRESTClientViaToken: + """Return the Intercom client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: IntercomOAuthConfig | IntercomTokenConfig, + ) -> "IntercomClient": + """Build IntercomClient with configuration. + + Args: + config: IntercomOAuthConfig or IntercomTokenConfig instance + + Returns: + IntercomClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "IntercomClient": + """Build IntercomClient using configuration service. + + Supports two authentication strategies: + 1. OAUTH: For OAuth 2.0 access tokens + 2. ACCESS_TOKEN: For direct access tokens + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + IntercomClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get Intercom connector configuration" + ) + + connector_config = IntercomConnectorConfig.model_validate( + raw_config + ) + + if connector_config.auth.authType == "OAUTH": + access_token = ( + connector_config.credentials.access_token or "" + ) + client_id = connector_config.auth.clientId or "" + client_secret = connector_config.auth.clientSecret or "" + + # Try shared OAuth config if credentials are missing + oauth_config_id = connector_config.auth.oauthConfigId + if oauth_config_id and not (client_id and client_secret): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/intercom", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + if not access_token: + raise ValueError( + "Access token required for OAuth auth type" + ) + + oauth_cfg = IntercomOAuthConfig( + access_token=access_token, + client_id=client_id, + client_secret=client_secret, + ) + return cls(oauth_cfg.create_client()) + + elif connector_config.auth.authType == "ACCESS_TOKEN": + token = ( + connector_config.auth.apiToken + or connector_config.auth.token + or "" + ) + if not token: + raise ValueError( + "Access token required for ACCESS_TOKEN auth type" + ) + + token_config = IntercomTokenConfig(access_token=token) + return cls(token_config.create_client()) + + else: + raise ValueError( + f"Invalid auth type: {connector_config.auth.authType}" + ) + + except Exception as e: + logger.error( + f"Failed to build Intercom client from services: {e!s}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "IntercomClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service for shared OAuth config + + Returns: + IntercomClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("credentials", {}) or {} + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + access_token: str = str(credentials.get("access_token", "")) + if not access_token: + raise ValueError( + "Access token not found in toolset config" + ) + + client_id: str = str(auth_config.get("clientId", "")) + client_secret: str = str(auth_config.get("clientSecret", "")) + + # Try shared OAuth config + oauth_config_id: str | None = cast( + str | None, auth_config.get("oauthConfigId") + ) + if ( + oauth_config_id + and config_service + and not (client_id and client_secret) + ): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/intercom", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + oauth_cfg = IntercomOAuthConfig( + access_token=access_token, + client_id=client_id, + client_secret=client_secret, + ) + return cls(oauth_cfg.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Intercom client from toolset: {e!s}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Intercom.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get Intercom connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error(f"Failed to get Intercom connector config: {e}") + raise ValueError( + f"Failed to get Intercom connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/invision/invision.py b/backend/python/app/sources/client/invision/invision.py new file mode 100644 index 000000000..157258363 --- /dev/null +++ b/backend/python/app/sources/client/invision/invision.py @@ -0,0 +1,316 @@ +"""InVision client implementation. + +This module provides a client for interacting with the InVision API using +API Key authentication (Bearer token). + +InVision uses API keys passed as Bearer tokens in the Authorization header. + +API Reference: https://developers.invisionapp.com/ +""" + +import base64 +import json +import logging +from typing import Any, cast + +from pydantic import BaseModel, Field, field_validator # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class InVisionResponse(BaseModel): + """Standardized InVision API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client class +# --------------------------------------------------------------------------- + + +class InVisionRESTClientViaToken(HTTPClient): + """InVision REST client via API Key (Bearer token). + + InVision uses API keys passed as Bearer tokens. + + Args: + api_key: The API key for authentication + """ + + def __init__(self, api_key: str) -> None: + super().__init__(api_key, token_type="Bearer") + self.base_url = "https://api.invisionapp.com/v2" + self.api_key = api_key + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +# --------------------------------------------------------------------------- +# Configuration model (Pydantic) +# --------------------------------------------------------------------------- + + +class InVisionTokenConfig(BaseModel): + """Configuration for InVision client via API Key. + + Args: + api_key: The API key for authentication + """ + + api_key: str + + @field_validator("api_key") + @classmethod + def validate_api_key(cls, v: str) -> str: + """Validate api_key field.""" + if not v or not v.strip(): + raise ValueError("api_key cannot be empty or None") + return v + + def create_client(self) -> InVisionRESTClientViaToken: + return InVisionRESTClientViaToken(self.api_key) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class InVisionAuthConfig(BaseModel): + """Auth section of the InVision connector configuration from etcd.""" + + authType: str = "API_KEY" + apiKey: str | None = None + apiToken: str | None = None + + class Config: + extra = "allow" + + +class InVisionConnectorConfig(BaseModel): + """Top-level InVision connector configuration from etcd.""" + + auth: InVisionAuthConfig = Field(default_factory=InVisionAuthConfig) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class InVisionClient(IClient): + """Builder class for InVision clients. + + Supports API Key (Bearer token) authentication only. + """ + + def __init__(self, client: InVisionRESTClientViaToken) -> None: + """Initialize with an InVision client object.""" + super().__init__() + self.client = client + + @override + def get_client(self) -> InVisionRESTClientViaToken: + """Return the InVision client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: InVisionTokenConfig, + ) -> "InVisionClient": + """Build InVisionClient with configuration. + + Args: + config: InVisionTokenConfig instance + + Returns: + InVisionClient instance + """ + return cls(config.create_client()) + + @classmethod + def build_with_api_key( + cls, + api_key: str, + ) -> "InVisionClient": + """Build InVisionClient with API key directly. + + Args: + api_key: The API key for authentication + + Returns: + InVisionClient instance + """ + config = InVisionTokenConfig(api_key=api_key) + return cls.build_with_config(config) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "InVisionClient": + """Build InVisionClient using configuration service. + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + InVisionClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get InVision connector configuration" + ) + + connector_config = InVisionConnectorConfig.model_validate( + raw_config + ) + + api_key = ( + connector_config.auth.apiKey + or connector_config.auth.apiToken + or "" + ) + if not api_key: + raise ValueError( + "API key required for InVision authentication" + ) + + token_config = InVisionTokenConfig(api_key=api_key) + return cls(token_config.create_client()) + + except Exception as e: + logger.error( + f"Failed to build InVision client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "InVisionClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service (unused) + + Returns: + InVisionClient instance + """ + try: + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + api_key: str = str( + auth_config.get("apiKey") + or auth_config.get("apiToken") + or "" + ) + if not api_key: + raise ValueError( + "API key not found in toolset config" + ) + + token_config = InVisionTokenConfig(api_key=api_key) + return cls(token_config.create_client()) + + except Exception as e: + logger.error( + f"Failed to build InVision client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for InVision.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get InVision connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error(f"Failed to get InVision connector config: {e}") + raise ValueError( + f"Failed to get InVision connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/ironclad/__init__.py b/backend/python/app/sources/client/ironclad/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/python/app/sources/client/ironclad/ironclad.py b/backend/python/app/sources/client/ironclad/ironclad.py new file mode 100644 index 000000000..bcdb99d6d --- /dev/null +++ b/backend/python/app/sources/client/ironclad/ironclad.py @@ -0,0 +1,480 @@ +"""Ironclad client implementation. + +This module provides clients for interacting with the Ironclad API using either: +1. OAuth 2.0 access token authentication (authorization code flow) +2. API Key (Bearer token) authentication + +Ironclad is a contract lifecycle management platform. The API provides access +to workflows, records, templates, webhooks, users, and groups. + +Authentication Reference: https://developer.ironcladapp.com/reference +API Reference: https://developer.ironcladapp.com/reference +""" + +import base64 +import json +import logging +from enum import Enum +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class IroncladAuthType(str, Enum): + """Authentication types supported by the Ironclad connector.""" + + OAUTH = "OAUTH" + API_KEY = "API_KEY" + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class IroncladResponse(BaseModel): + """Standardized Ironclad API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client classes +# --------------------------------------------------------------------------- + + +class IroncladRESTClientViaToken(HTTPClient): + """Ironclad REST client via API Key (Bearer token). + + API keys are passed as Bearer tokens in the Authorization header. + + Args: + token: The API key + """ + + def __init__(self, token: str) -> None: + super().__init__(token, token_type="Bearer") + self.base_url = "https://ironcladapp.com/public/api/v1" + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +class IroncladRESTClientViaOAuth(HTTPClient): + """Ironclad REST client via OAuth 2.0 access token. + + OAuth tokens are passed as Bearer tokens in the Authorization header. + + Args: + access_token: The OAuth access token + client_id: OAuth client ID (for reference / token refresh) + client_secret: OAuth client secret (for reference / token refresh) + """ + + def __init__( + self, + access_token: str, + client_id: str | None = None, + client_secret: str | None = None, + ) -> None: + super().__init__(access_token, "Bearer") + self.base_url = "https://ironcladapp.com/public/api/v1" + self.access_token = access_token + self.client_id = client_id + self.client_secret = client_secret + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class IroncladTokenConfig(BaseModel): + """Configuration for Ironclad client via API Key. + + Args: + token: The API key + """ + + token: str + + def create_client(self) -> IroncladRESTClientViaToken: + return IroncladRESTClientViaToken(self.token) + + +class IroncladOAuthConfig(BaseModel): + """Configuration for Ironclad client via OAuth 2.0. + + Args: + access_token: The OAuth access token + client_id: OAuth client ID + client_secret: OAuth client secret + """ + + access_token: str + client_id: str | None = None + client_secret: str | None = None + + def create_client(self) -> IroncladRESTClientViaOAuth: + return IroncladRESTClientViaOAuth( + self.access_token, + self.client_id, + self.client_secret, + ) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class IroncladAuthConfig(BaseModel): + """Auth section of the Ironclad connector configuration from etcd.""" + + authType: IroncladAuthType = IroncladAuthType.API_KEY + apiToken: str | None = None + token: str | None = None + clientId: str | None = None + clientSecret: str | None = None + redirectUri: str | None = None + oauthConfigId: str | None = None + + class Config: + extra = "allow" + + +class IroncladCredentialsConfig(BaseModel): + """Credentials section of the Ironclad connector configuration.""" + + access_token: str | None = None + refresh_token: str | None = None + + class Config: + extra = "allow" + + +class IroncladConnectorConfig(BaseModel): + """Top-level Ironclad connector configuration from etcd.""" + + auth: IroncladAuthConfig = Field(default_factory=IroncladAuthConfig) + credentials: IroncladCredentialsConfig = Field( + default_factory=IroncladCredentialsConfig + ) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class IroncladClient(IClient): + """Builder class for Ironclad clients with different authentication methods. + + Supports: + - API Key (Bearer token) authentication + - OAuth 2.0 access token authentication + """ + + def __init__( + self, + client: IroncladRESTClientViaToken | IroncladRESTClientViaOAuth, + ) -> None: + """Initialize with an Ironclad client object.""" + super().__init__() + self.client = client + + @override + def get_client( + self, + ) -> IroncladRESTClientViaToken | IroncladRESTClientViaOAuth: + """Return the Ironclad client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: IroncladTokenConfig | IroncladOAuthConfig, + ) -> "IroncladClient": + """Build IroncladClient with configuration. + + Args: + config: IroncladTokenConfig or IroncladOAuthConfig instance + + Returns: + IroncladClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "IroncladClient": + """Build IroncladClient using configuration service. + + Supports two authentication strategies: + 1. API_KEY: For API key (Bearer token) authentication + 2. OAUTH: For OAuth 2.0 access tokens + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + IroncladClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError("Failed to get Ironclad connector configuration") + + connector_config = IroncladConnectorConfig.model_validate(raw_config) + + if connector_config.auth.authType == IroncladAuthType.OAUTH: + access_token = connector_config.credentials.access_token or "" + client_id = connector_config.auth.clientId or "" + client_secret = connector_config.auth.clientSecret or "" + + # Try shared OAuth config if credentials are missing + oauth_config_id = connector_config.auth.oauthConfigId + if oauth_config_id and not (client_id and client_secret): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/ironclad", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + if not access_token: + raise ValueError( + "Access token required for OAuth auth type" + ) + + oauth_cfg = IroncladOAuthConfig( + access_token=access_token, + client_id=client_id, + client_secret=client_secret, + ) + return cls(oauth_cfg.create_client()) + + elif connector_config.auth.authType == IroncladAuthType.API_KEY: + token = ( + connector_config.auth.apiToken + or connector_config.auth.token + or "" + ) + if not token: + raise ValueError( + "API key required for API_KEY auth type" + ) + + token_config = IroncladTokenConfig(token=token) + return cls(token_config.create_client()) + + else: + raise ValueError( + f"Invalid auth type: {connector_config.auth.authType}" + ) + + except Exception as e: + logger.error( + f"Failed to build Ironclad client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "IroncladClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service for shared OAuth config + + Returns: + IroncladClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("credentials", {}) or {} + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + access_token: str = str(credentials.get("access_token", "")) + if not access_token: + raise ValueError("Access token not found in toolset config") + + client_id: str = str(auth_config.get("clientId", "")) + client_secret: str = str(auth_config.get("clientSecret", "")) + + # Try shared OAuth config + oauth_config_id: str | None = cast( + str | None, auth_config.get("oauthConfigId") + ) + if oauth_config_id and config_service and not ( + client_id and client_secret + ): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/ironclad", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + oauth_cfg = IroncladOAuthConfig( + access_token=access_token, + client_id=client_id, + client_secret=client_secret, + ) + return cls(oauth_cfg.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Ironclad client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Ironclad.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get Ironclad connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error(f"Failed to get Ironclad connector config: {e}") + raise ValueError( + f"Failed to get Ironclad connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/jenkins/jenkins.py b/backend/python/app/sources/client/jenkins/jenkins.py new file mode 100644 index 000000000..5c2565dae --- /dev/null +++ b/backend/python/app/sources/client/jenkins/jenkins.py @@ -0,0 +1,479 @@ +"""Jenkins client implementation. + +This module provides clients for interacting with the Jenkins API using either: +1. API Token via HTTP Basic Auth (username:api_token) +2. Pre-generated Bearer token + +Authentication Reference: https://www.jenkins.io/doc/book/system-administration/authenticating-scripted-clients/ +API Reference: https://www.jenkins.io/doc/book/using/remote-access-api/ +""" + +import base64 +import json +import logging +from enum import Enum +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class JenkinsAuthType(str, Enum): + """Authentication types supported by the Jenkins connector.""" + + API_TOKEN = "API_TOKEN" + TOKEN = "TOKEN" + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class JenkinsResponse(BaseModel): + """Standardized Jenkins API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client classes +# --------------------------------------------------------------------------- + + +class JenkinsRESTClientViaApiToken(HTTPClient): + """Jenkins REST client via API Token with HTTP Basic Auth. + + Uses HTTP Basic Authentication with username:api_token encoded as + a Base64 string in the Authorization header. + + Args: + jenkins_url: The Jenkins instance URL (e.g. https://jenkins.example.com) + username: Jenkins username + api_token: Jenkins API token + """ + + def __init__( + self, + jenkins_url: str, + username: str, + api_token: str, + ) -> None: + # Initialize with empty token; we override the header below + super().__init__("", token_type="Basic") + self.base_url = jenkins_url.rstrip("/") + self.username = username + self.api_token = api_token + # Jenkins API Token auth: HTTP Basic with username:api_token + credentials = base64.b64encode( + f"{username}:{api_token}".encode() + ).decode("utf-8") + self.headers["Authorization"] = f"Basic {credentials}" + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +class JenkinsRESTClientViaToken(HTTPClient): + """Jenkins REST client via pre-generated Bearer token. + + Simple authentication using a pre-generated token passed directly + in the Authorization header. + + Args: + jenkins_url: The Jenkins instance URL (e.g. https://jenkins.example.com) + token: The pre-generated Bearer token + """ + + def __init__( + self, + jenkins_url: str, + token: str, + ) -> None: + super().__init__(token, token_type="Bearer") + self.base_url = jenkins_url.rstrip("/") + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class JenkinsApiTokenConfig(BaseModel): + """Configuration for Jenkins client via API Token (Basic Auth). + + Args: + jenkins_url: The Jenkins instance URL + username: Jenkins username + api_token: Jenkins API token + """ + + jenkins_url: str + username: str + api_token: str + + def create_client(self) -> JenkinsRESTClientViaApiToken: + return JenkinsRESTClientViaApiToken( + self.jenkins_url, + self.username, + self.api_token, + ) + + +class JenkinsTokenConfig(BaseModel): + """Configuration for Jenkins client via pre-generated Bearer token. + + Args: + jenkins_url: The Jenkins instance URL + token: The pre-generated Bearer token + """ + + jenkins_url: str + token: str + + def create_client(self) -> JenkinsRESTClientViaToken: + return JenkinsRESTClientViaToken(self.jenkins_url, self.token) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class JenkinsAuthConfig(BaseModel): + """Auth section of the Jenkins connector configuration from etcd.""" + + authType: JenkinsAuthType = JenkinsAuthType.API_TOKEN + jenkinsUrl: str | None = None + jenkins_url: str | None = None + username: str | None = None + apiToken: str | None = None + api_token: str | None = None + token: str | None = None + + class Config: + extra = "allow" + + +class JenkinsCredentialsConfig(BaseModel): + """Credentials section of the Jenkins connector configuration.""" + + api_token: str | None = None + token: str | None = None + + class Config: + extra = "allow" + + +class JenkinsConnectorConfig(BaseModel): + """Top-level Jenkins connector configuration from etcd.""" + + auth: JenkinsAuthConfig = Field(default_factory=JenkinsAuthConfig) + credentials: JenkinsCredentialsConfig = Field( + default_factory=JenkinsCredentialsConfig + ) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class JenkinsClient(IClient): + """Builder class for Jenkins clients with different authentication methods. + + Supports: + - API Token via HTTP Basic Auth (username:api_token) + - Pre-generated Bearer token + """ + + def __init__( + self, + client: JenkinsRESTClientViaApiToken | JenkinsRESTClientViaToken, + ) -> None: + """Initialize with a Jenkins client object.""" + super().__init__() + self.client = client + + @override + def get_client( + self, + ) -> JenkinsRESTClientViaApiToken | JenkinsRESTClientViaToken: + """Return the Jenkins client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: JenkinsApiTokenConfig | JenkinsTokenConfig, + ) -> "JenkinsClient": + """Build JenkinsClient with configuration. + + Args: + config: JenkinsApiTokenConfig or JenkinsTokenConfig instance + + Returns: + JenkinsClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "JenkinsClient": + """Build JenkinsClient using configuration service. + + Supports two authentication strategies: + 1. API_TOKEN: HTTP Basic Auth with username:api_token + 2. TOKEN: Pre-generated Bearer token + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + JenkinsClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get Jenkins connector configuration" + ) + + connector_config = JenkinsConnectorConfig.model_validate( + raw_config + ) + + jenkins_url = ( + connector_config.auth.jenkinsUrl + or connector_config.auth.jenkins_url + or "" + ) + if not jenkins_url: + raise ValueError( + "jenkins_url is required in Jenkins connector configuration" + ) + + if connector_config.auth.authType == JenkinsAuthType.API_TOKEN: + username = connector_config.auth.username or "" + api_token = ( + connector_config.auth.apiToken + or connector_config.auth.api_token + or connector_config.credentials.api_token + or "" + ) + + if not (username and api_token): + raise ValueError( + "username and api_token are required " + "for API_TOKEN auth type" + ) + + api_token_config = JenkinsApiTokenConfig( + jenkins_url=jenkins_url, + username=username, + api_token=api_token, + ) + return cls(api_token_config.create_client()) + + elif connector_config.auth.authType == JenkinsAuthType.TOKEN: + token = ( + connector_config.auth.token + or connector_config.credentials.token + or "" + ) + if not token: + raise ValueError( + "Token required for TOKEN auth type" + ) + + token_config = JenkinsTokenConfig( + jenkins_url=jenkins_url, + token=token, + ) + return cls(token_config.create_client()) + + else: + raise ValueError( + f"Invalid auth type: {connector_config.auth.authType}" + ) + + except Exception as e: + logger.error( + f"Failed to build Jenkins client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "JenkinsClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service + + Returns: + JenkinsClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("credentials", {}) or {} + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + jenkins_url: str = str( + auth_config.get("jenkinsUrl") + or auth_config.get("jenkins_url") + or "" + ) + if not jenkins_url: + raise ValueError( + "jenkins_url not found in toolset config" + ) + + auth_type = str( + auth_config.get("authType", JenkinsAuthType.API_TOKEN.value) + ) + + if auth_type == JenkinsAuthType.API_TOKEN.value: + username: str = str(auth_config.get("username", "")) + api_token: str = str( + credentials.get("api_token") + or auth_config.get("apiToken") + or auth_config.get("api_token") + or "" + ) + + if not (username and api_token): + raise ValueError( + "username and api_token not found in toolset config" + ) + + api_token_cfg = JenkinsApiTokenConfig( + jenkins_url=jenkins_url, + username=username, + api_token=api_token, + ) + return cls(api_token_cfg.create_client()) + + else: + token: str = str( + credentials.get("token") + or auth_config.get("token") + or "" + ) + if not token: + raise ValueError( + "token not found in toolset config" + ) + + token_cfg = JenkinsTokenConfig( + jenkins_url=jenkins_url, + token=token, + ) + return cls(token_cfg.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Jenkins client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Jenkins.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get Jenkins connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error(f"Failed to get Jenkins connector config: {e}") + raise ValueError( + f"Failed to get Jenkins connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/jfrog/jfrog.py b/backend/python/app/sources/client/jfrog/jfrog.py new file mode 100644 index 000000000..3d84039aa --- /dev/null +++ b/backend/python/app/sources/client/jfrog/jfrog.py @@ -0,0 +1,560 @@ +"""JFrog Artifactory client implementation. + +This module provides clients for interacting with the JFrog Artifactory API using either: +1. API Key authentication (X-JFrog-Art-Api header) +2. Bearer Token authentication +3. Basic Auth (username:password) + +Authentication Reference: https://jfrog.com/help/r/jfrog-platform-administration-documentation/access-tokens +API Reference: https://jfrog.com/help/r/jfrog-rest-apis/artifactory-rest-apis +""" + +import base64 +import json +import logging +from enum import Enum +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class JFrogAuthType(str, Enum): + """Authentication types supported by the JFrog connector.""" + + API_KEY = "API_KEY" + TOKEN = "TOKEN" + BASIC_AUTH = "BASIC_AUTH" + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class JFrogResponse(BaseModel): + """Standardized JFrog API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client classes +# --------------------------------------------------------------------------- + + +class JFrogRESTClientViaApiKey(HTTPClient): + """JFrog REST client via API Key authentication. + + API keys are passed in the X-JFrog-Art-Api header. + + Args: + api_key: The JFrog API key + domain: The JFrog domain (e.g., "mycompany" for mycompany.jfrog.io) + base_url: Optional full base URL override + """ + + def __init__( + self, + api_key: str, + domain: str, + base_url: str | None = None, + ) -> None: + super().__init__(api_key, token_type="Bearer") + self.base_url = ( + base_url or f"https://{domain}.jfrog.io/artifactory/api" + ) + self.domain = domain + # Override Authorization with the JFrog-specific header + del self.headers["Authorization"] + self.headers["X-JFrog-Art-Api"] = api_key + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +class JFrogRESTClientViaToken(HTTPClient): + """JFrog REST client via Bearer Token authentication. + + Bearer tokens are passed in the standard Authorization header. + + Args: + token: The Bearer token + domain: The JFrog domain (e.g., "mycompany" for mycompany.jfrog.io) + base_url: Optional full base URL override + """ + + def __init__( + self, + token: str, + domain: str, + base_url: str | None = None, + ) -> None: + super().__init__(token, token_type="Bearer") + self.base_url = ( + base_url or f"https://{domain}.jfrog.io/artifactory/api" + ) + self.domain = domain + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +class JFrogRESTClientViaBasicAuth(HTTPClient): + """JFrog REST client via Basic Auth (username:password). + + Credentials are base64-encoded and passed in the Authorization header. + + Args: + username: The JFrog username + password: The JFrog password or API key + domain: The JFrog domain (e.g., "mycompany" for mycompany.jfrog.io) + base_url: Optional full base URL override + """ + + def __init__( + self, + username: str, + password: str, + domain: str, + base_url: str | None = None, + ) -> None: + super().__init__("", token_type="Basic") + self.base_url = ( + base_url or f"https://{domain}.jfrog.io/artifactory/api" + ) + self.domain = domain + self.username = username + self.password = password + credentials = base64.b64encode( + f"{username}:{password}".encode() + ).decode("utf-8") + self.headers["Authorization"] = f"Basic {credentials}" + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class JFrogApiKeyConfig(BaseModel): + """Configuration for JFrog client via API Key. + + Args: + api_key: The JFrog API key + domain: The JFrog domain + base_url: Optional full base URL override + """ + + api_key: str + domain: str + base_url: str | None = None + + def create_client(self) -> JFrogRESTClientViaApiKey: + return JFrogRESTClientViaApiKey( + self.api_key, self.domain, self.base_url + ) + + +class JFrogTokenConfig(BaseModel): + """Configuration for JFrog client via Bearer Token. + + Args: + token: The Bearer token + domain: The JFrog domain + base_url: Optional full base URL override + """ + + token: str + domain: str + base_url: str | None = None + + def create_client(self) -> JFrogRESTClientViaToken: + return JFrogRESTClientViaToken( + self.token, self.domain, self.base_url + ) + + +class JFrogBasicAuthConfig(BaseModel): + """Configuration for JFrog client via Basic Auth. + + Args: + username: The JFrog username + password: The JFrog password or API key + domain: The JFrog domain + base_url: Optional full base URL override + """ + + username: str + password: str + domain: str + base_url: str | None = None + + def create_client(self) -> JFrogRESTClientViaBasicAuth: + return JFrogRESTClientViaBasicAuth( + self.username, self.password, self.domain, self.base_url + ) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class JFrogAuthConfig(BaseModel): + """Auth section of the JFrog connector configuration from etcd.""" + + authType: JFrogAuthType = JFrogAuthType.API_KEY + apiKey: str | None = None + token: str | None = None + username: str | None = None + password: str | None = None + domain: str | None = None + baseUrl: str | None = None + + class Config: + extra = "allow" + + +class JFrogCredentialsConfig(BaseModel): + """Credentials section of the JFrog connector configuration.""" + + access_token: str | None = None + api_key: str | None = None + + class Config: + extra = "allow" + + +class JFrogConnectorConfig(BaseModel): + """Top-level JFrog connector configuration from etcd.""" + + auth: JFrogAuthConfig = Field(default_factory=JFrogAuthConfig) + credentials: JFrogCredentialsConfig = Field( + default_factory=JFrogCredentialsConfig + ) + domain: str = "" + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class JFrogClient(IClient): + """Builder class for JFrog clients with different authentication methods. + + Supports: + - API Key authentication (X-JFrog-Art-Api header) + - Bearer Token authentication + - Basic Auth (username:password) + """ + + def __init__( + self, + client: ( + JFrogRESTClientViaApiKey + | JFrogRESTClientViaToken + | JFrogRESTClientViaBasicAuth + ), + ) -> None: + """Initialize with a JFrog client object.""" + super().__init__() + self.client = client + + @override + def get_client( + self, + ) -> ( + JFrogRESTClientViaApiKey + | JFrogRESTClientViaToken + | JFrogRESTClientViaBasicAuth + ): + """Return the JFrog client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: JFrogApiKeyConfig | JFrogTokenConfig | JFrogBasicAuthConfig, + ) -> "JFrogClient": + """Build JFrogClient with configuration. + + Args: + config: JFrogApiKeyConfig, JFrogTokenConfig, or + JFrogBasicAuthConfig instance + + Returns: + JFrogClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "JFrogClient": + """Build JFrogClient using configuration service. + + Supports three authentication strategies: + 1. API_KEY: For JFrog API key (X-JFrog-Art-Api header) + 2. TOKEN: For Bearer token authentication + 3. BASIC_AUTH: For username:password authentication + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + JFrogClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get JFrog connector configuration" + ) + + connector_config = JFrogConnectorConfig.model_validate( + raw_config + ) + domain = ( + connector_config.auth.domain or connector_config.domain or "" + ) + base_url = connector_config.auth.baseUrl or None + + if not domain and not base_url: + raise ValueError( + "JFrog domain or base URL is required" + ) + + if connector_config.auth.authType == JFrogAuthType.TOKEN: + token = ( + connector_config.credentials.access_token + or connector_config.auth.token + or "" + ) + if not token: + raise ValueError( + "Token required for TOKEN auth type" + ) + + token_config = JFrogTokenConfig( + token=token, domain=domain, base_url=base_url + ) + return cls(token_config.create_client()) + + elif connector_config.auth.authType == JFrogAuthType.BASIC_AUTH: + username = connector_config.auth.username or "" + password = connector_config.auth.password or "" + + if not username or not password: + raise ValueError( + "Username and password required for BASIC_AUTH" + ) + + basic_config = JFrogBasicAuthConfig( + username=username, + password=password, + domain=domain, + base_url=base_url, + ) + return cls(basic_config.create_client()) + + else: + # Default: API_KEY + api_key = ( + connector_config.credentials.api_key + or connector_config.auth.apiKey + or "" + ) + if not api_key: + raise ValueError( + "API key required for API_KEY auth type" + ) + + api_key_config = JFrogApiKeyConfig( + api_key=api_key, domain=domain, base_url=base_url + ) + return cls(api_key_config.create_client()) + + except Exception as e: + logger.error( + f"Failed to build JFrog client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "JFrogClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service + + Returns: + JFrogClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], + toolset_config.get("credentials", {}) or {}, + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + domain: str = str(toolset_config.get("domain", "")) + base_url: str | None = cast( + str | None, toolset_config.get("baseUrl") + ) + auth_type = auth_config.get("authType", "API_KEY") + + if auth_type == "TOKEN": + token = str( + credentials.get("access_token", "") + or auth_config.get("token", "") + ) + if not token: + raise ValueError( + "Token not found in toolset config" + ) + token_cfg = JFrogTokenConfig( + token=token, domain=domain, base_url=base_url + ) + return cls(token_cfg.create_client()) + + elif auth_type == "BASIC_AUTH": + username = str(auth_config.get("username", "")) + password = str( + credentials.get("password", "") + or auth_config.get("password", "") + ) + if not username or not password: + raise ValueError( + "Username and password not found in toolset config" + ) + basic_cfg = JFrogBasicAuthConfig( + username=username, + password=password, + domain=domain, + base_url=base_url, + ) + return cls(basic_cfg.create_client()) + + else: + # Default: API_KEY + api_key = str( + credentials.get("api_key", "") + or auth_config.get("apiKey", "") + ) + if not api_key: + raise ValueError( + "API key not found in toolset config" + ) + api_key_cfg = JFrogApiKeyConfig( + api_key=api_key, domain=domain, base_url=base_url + ) + return cls(api_key_cfg.create_client()) + + except Exception as e: + logger.error( + f"Failed to build JFrog client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for JFrog.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get JFrog connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error(f"Failed to get JFrog connector config: {e}") + raise ValueError( + f"Failed to get JFrog connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/jumpcloud/jumpcloud.py b/backend/python/app/sources/client/jumpcloud/jumpcloud.py new file mode 100644 index 000000000..a318d830d --- /dev/null +++ b/backend/python/app/sources/client/jumpcloud/jumpcloud.py @@ -0,0 +1,329 @@ +"""JumpCloud client implementation. + +This module provides a client for interacting with the JumpCloud API +using API Key authentication via the ``x-api-key`` header. + +Authentication Reference: https://docs.jumpcloud.com/api/1.0/index.html#section/Authentication +API v2 Reference: https://docs.jumpcloud.com/api/2.0/index.html +""" + +import base64 +import json +import logging +from enum import Enum +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class JumpCloudAuthType(str, Enum): + """Authentication types supported by the JumpCloud connector.""" + + API_KEY = "API_KEY" + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class JumpCloudResponse(BaseModel): + """Standardized JumpCloud API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client classes +# --------------------------------------------------------------------------- + + +class JumpCloudRESTClientViaApiKey(HTTPClient): + """JumpCloud REST client via API Key. + + Uses the ``x-api-key`` header for authentication instead of the + standard ``Authorization`` header. + + Args: + api_key: The JumpCloud API key + """ + + def __init__(self, api_key: str) -> None: + super().__init__(api_key, token_type="Bearer") + self.base_url = "https://console.jumpcloud.com/api/v2" + # Replace Authorization with x-api-key + _ = self.headers.pop("Authorization", None) + self.headers["x-api-key"] = api_key + self.headers["Content-Type"] = "application/json" + self.headers["Accept"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class JumpCloudApiKeyConfig(BaseModel): + """Configuration for JumpCloud client via API Key. + + Args: + api_key: The JumpCloud API key + """ + + api_key: str + + def create_client(self) -> JumpCloudRESTClientViaApiKey: + return JumpCloudRESTClientViaApiKey(self.api_key) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class JumpCloudAuthConfig(BaseModel): + """Auth section of the JumpCloud connector configuration from etcd.""" + + authType: JumpCloudAuthType = JumpCloudAuthType.API_KEY + apiKey: str | None = None + api_key: str | None = None + + class Config: + extra = "allow" + + +class JumpCloudCredentialsConfig(BaseModel): + """Credentials section of the JumpCloud connector configuration.""" + + api_key: str | None = None + + class Config: + extra = "allow" + + +class JumpCloudConnectorConfig(BaseModel): + """Top-level JumpCloud connector configuration from etcd.""" + + auth: JumpCloudAuthConfig = Field(default_factory=JumpCloudAuthConfig) + credentials: JumpCloudCredentialsConfig = Field( + default_factory=JumpCloudCredentialsConfig + ) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class JumpCloudClient(IClient): + """Builder class for JumpCloud clients. + + Supports: + - API Key authentication (x-api-key header) + """ + + def __init__( + self, + client: JumpCloudRESTClientViaApiKey, + ) -> None: + """Initialize with a JumpCloud client object.""" + super().__init__() + self.client = client + + @override + def get_client(self) -> JumpCloudRESTClientViaApiKey: + """Return the JumpCloud client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: JumpCloudApiKeyConfig, + ) -> "JumpCloudClient": + """Build JumpCloudClient with configuration. + + Args: + config: JumpCloudApiKeyConfig instance + + Returns: + JumpCloudClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "JumpCloudClient": + """Build JumpCloudClient using configuration service. + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + JumpCloudClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get JumpCloud connector configuration" + ) + + connector_config = JumpCloudConnectorConfig.model_validate( + raw_config + ) + + api_key = ( + connector_config.auth.apiKey + or connector_config.auth.api_key + or connector_config.credentials.api_key + or "" + ) + if not api_key: + raise ValueError( + "API key required for JumpCloud auth" + ) + + key_config = JumpCloudApiKeyConfig(api_key=api_key) + return cls(key_config.create_client()) + + except Exception as e: + logger.error( + f"Failed to build JumpCloud client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "JumpCloudClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service + + Returns: + JumpCloudClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("credentials", {}) or {} + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + api_key: str = str( + credentials.get("api_key", "") + or auth_config.get("apiKey", "") + or auth_config.get("api_key", "") + ) + if not api_key: + raise ValueError( + "API key not found in toolset config" + ) + + key_config = JumpCloudApiKeyConfig(api_key=api_key) + return cls(key_config.create_client()) + + except Exception as e: + logger.error( + f"Failed to build JumpCloud client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for JumpCloud.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get JumpCloud connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error( + f"Failed to get JumpCloud connector config: {e}" + ) + raise ValueError( + f"Failed to get JumpCloud connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/keycloak/keycloak.py b/backend/python/app/sources/client/keycloak/keycloak.py new file mode 100644 index 000000000..21499f474 --- /dev/null +++ b/backend/python/app/sources/client/keycloak/keycloak.py @@ -0,0 +1,576 @@ +"""Keycloak client implementation. + +This module provides clients for interacting with the Keycloak Admin REST API +using either: +1. OAuth2 (client_credentials or password grant) +2. Pre-generated Bearer token + +Authentication Reference: https://www.keycloak.org/docs-api/latest/rest-api/ +Token Endpoint: https://{hostname}/realms/{realm}/protocol/openid-connect/token +Admin API Base: https://{hostname}/admin/realms/{realm} +""" + +import base64 +import json +import logging +from enum import Enum +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.http.http_request import HTTPRequest +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class KeycloakAuthType(str, Enum): + """Authentication types supported by the Keycloak connector.""" + + OAUTH = "OAUTH" + TOKEN = "TOKEN" + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class KeycloakResponse(BaseModel): + """Standardized Keycloak API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client classes +# --------------------------------------------------------------------------- + + +class KeycloakRESTClientViaOAuth(HTTPClient): + """Keycloak REST client via OAuth2 (client_credentials or password grant). + + Fetches an access token from the Keycloak token endpoint using + client_credentials grant. The token is obtained automatically on + first use via ensure_authenticated(). + + Args: + hostname: Keycloak server hostname (e.g. keycloak.example.com) + realm: Keycloak realm name + client_id: OAuth client ID + client_secret: OAuth client secret + """ + + def __init__( + self, + hostname: str, + realm: str, + client_id: str, + client_secret: str, + ) -> None: + super().__init__("", token_type="Bearer") + self.hostname = hostname.rstrip("/") + self.realm = realm + self.client_id = client_id + self.client_secret = client_secret + self.base_url = f"https://{self.hostname}/admin/realms/{self.realm}" + self._authenticated = False + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + async def ensure_authenticated(self) -> None: + """Fetch an access token via client_credentials grant if needed.""" + if self._authenticated: + return + + token_url = ( + f"https://{self.hostname}/realms/{self.realm}" + f"/protocol/openid-connect/token" + ) + + token_request = HTTPRequest( + url=token_url, + method="POST", + headers={ + "Content-Type": "application/x-www-form-urlencoded", + }, + body={ + "grant_type": "client_credentials", + "client_id": self.client_id, + "client_secret": self.client_secret, + }, + ) + + response = await self.execute(token_request) # type: ignore[reportUnknownMemberType] + response_data = response.json() + + access_token = response_data.get("access_token") + if not access_token: + raise ValueError( + "Failed to obtain access token from Keycloak: " + f"{response_data}" + ) + + self.headers["Authorization"] = f"Bearer {access_token}" + self._authenticated = True + + +class KeycloakRESTClientViaToken(HTTPClient): + """Keycloak REST client via pre-generated Bearer token. + + Simple authentication using a pre-generated token passed directly + in the Authorization header. + + Args: + token: The pre-generated Bearer token + hostname: Keycloak server hostname + realm: Keycloak realm name + """ + + def __init__( + self, + token: str, + hostname: str, + realm: str, + ) -> None: + super().__init__(token, token_type="Bearer") + self.hostname = hostname.rstrip("/") + self.realm = realm + self.base_url = f"https://{self.hostname}/admin/realms/{self.realm}" + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class KeycloakOAuthConfig(BaseModel): + """Configuration for Keycloak client via OAuth2. + + Args: + hostname: Keycloak server hostname + realm: Keycloak realm name + client_id: OAuth client ID + client_secret: OAuth client secret + """ + + hostname: str + realm: str + client_id: str + client_secret: str + + def create_client(self) -> KeycloakRESTClientViaOAuth: + return KeycloakRESTClientViaOAuth( + self.hostname, + self.realm, + self.client_id, + self.client_secret, + ) + + +class KeycloakTokenConfig(BaseModel): + """Configuration for Keycloak client via Bearer token. + + Args: + token: The pre-generated Bearer token + hostname: Keycloak server hostname + realm: Keycloak realm name + """ + + token: str + hostname: str + realm: str + + def create_client(self) -> KeycloakRESTClientViaToken: + return KeycloakRESTClientViaToken( + self.token, self.hostname, self.realm + ) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class KeycloakAuthConfig(BaseModel): + """Auth section of the Keycloak connector configuration from etcd.""" + + authType: KeycloakAuthType = KeycloakAuthType.OAUTH + hostname: str | None = None + realm: str | None = None + clientId: str | None = None + clientSecret: str | None = None + token: str | None = None + oauthConfigId: str | None = None + + class Config: + extra = "allow" + + +class KeycloakCredentialsConfig(BaseModel): + """Credentials section of the Keycloak connector configuration.""" + + access_token: str | None = None + refresh_token: str | None = None + + class Config: + extra = "allow" + + +class KeycloakConnectorConfig(BaseModel): + """Top-level Keycloak connector configuration from etcd.""" + + auth: KeycloakAuthConfig = Field(default_factory=KeycloakAuthConfig) + credentials: KeycloakCredentialsConfig = Field( + default_factory=KeycloakCredentialsConfig + ) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class KeycloakClient(IClient): + """Builder class for Keycloak clients with different authentication methods. + + Supports: + - OAuth2 (client_credentials grant) authentication + - Pre-generated Bearer token authentication + """ + + def __init__( + self, + client: KeycloakRESTClientViaOAuth | KeycloakRESTClientViaToken, + ) -> None: + """Initialize with a Keycloak client object.""" + super().__init__() + self.client = client + + @override + def get_client( + self, + ) -> KeycloakRESTClientViaOAuth | KeycloakRESTClientViaToken: + """Return the Keycloak client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: KeycloakOAuthConfig | KeycloakTokenConfig, + ) -> "KeycloakClient": + """Build KeycloakClient with configuration. + + Args: + config: KeycloakOAuthConfig or KeycloakTokenConfig instance + + Returns: + KeycloakClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "KeycloakClient": + """Build KeycloakClient using configuration service. + + Supports two authentication strategies: + 1. OAUTH: Client credentials grant with client_id and client_secret + 2. TOKEN: Pre-generated Bearer token + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + KeycloakClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get Keycloak connector configuration" + ) + + connector_config = KeycloakConnectorConfig.model_validate( + raw_config + ) + + hostname = connector_config.auth.hostname or "" + realm = connector_config.auth.realm or "" + + if not (hostname and realm): + raise ValueError( + "hostname and realm are required for Keycloak" + ) + + if connector_config.auth.authType == KeycloakAuthType.OAUTH: + client_id = connector_config.auth.clientId or "" + client_secret = connector_config.auth.clientSecret or "" + + # Try shared OAuth config if credentials are missing + oauth_config_id = connector_config.auth.oauthConfigId + if oauth_config_id and not (client_id and client_secret): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/keycloak", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + if not (client_id and client_secret): + raise ValueError( + "client_id and client_secret are required " + "for OAuth auth type" + ) + + oauth_cfg = KeycloakOAuthConfig( + hostname=hostname, + realm=realm, + client_id=client_id, + client_secret=client_secret, + ) + return cls(oauth_cfg.create_client()) + + elif connector_config.auth.authType == KeycloakAuthType.TOKEN: + token = ( + connector_config.auth.token + or connector_config.credentials.access_token + or "" + ) + if not token: + raise ValueError( + "Token required for TOKEN auth type" + ) + + token_config = KeycloakTokenConfig( + token=token, hostname=hostname, realm=realm + ) + return cls(token_config.create_client()) + + else: + raise ValueError( + f"Invalid auth type: {connector_config.auth.authType}" + ) + + except Exception as e: + logger.error( + f"Failed to build Keycloak client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "KeycloakClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service for shared OAuth + + Returns: + KeycloakClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("credentials", {}) or {} + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + hostname: str = str(auth_config.get("hostname", "")) + realm: str = str(auth_config.get("realm", "")) + if not (hostname and realm): + raise ValueError( + "hostname and realm not found in toolset config" + ) + + access_token: str = str(credentials.get("access_token", "")) + if not access_token: + raise ValueError( + "Access token not found in toolset config" + ) + + client_id: str = str(auth_config.get("clientId", "")) + client_secret: str = str(auth_config.get("clientSecret", "")) + + # Try shared OAuth config + oauth_config_id: str | None = cast( + str | None, auth_config.get("oauthConfigId") + ) + if oauth_config_id and config_service and not ( + client_id and client_secret + ): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/keycloak", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + # If we have client credentials, use OAuth flow + if client_id and client_secret: + oauth_cfg = KeycloakOAuthConfig( + hostname=hostname, + realm=realm, + client_id=client_id, + client_secret=client_secret, + ) + return cls(oauth_cfg.create_client()) + + # Otherwise use the access token directly + token_config = KeycloakTokenConfig( + token=access_token, hostname=hostname, realm=realm + ) + return cls(token_config.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Keycloak client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Keycloak.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get Keycloak connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error( + f"Failed to get Keycloak connector config: {e}" + ) + raise ValueError( + f"Failed to get Keycloak connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/klue/klue.py b/backend/python/app/sources/client/klue/klue.py new file mode 100644 index 000000000..3124d6ff5 --- /dev/null +++ b/backend/python/app/sources/client/klue/klue.py @@ -0,0 +1,313 @@ +"""Klue client implementation. + +This module provides a client for interacting with the Klue API using +API Key (Bearer token) authentication. + +Klue is a competitive intelligence platform that provides battlecards, +competitor profiles, and intel feeds. + +API Reference: https://api.klue.com/v1 +Authentication: API Key passed as Bearer token in Authorization header. +""" + +import base64 +import json +import logging +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class KlueResponse(BaseModel): + """Standardized Klue API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client class +# --------------------------------------------------------------------------- + + +class KlueRESTClientViaToken(HTTPClient): + """Klue REST client via API Key (Bearer token). + + API keys are passed as Bearer tokens in the Authorization header. + + Args: + token: The Klue API key + base_url: API base URL (default: https://api.klue.com/v1) + """ + + def __init__( + self, + token: str, + base_url: str = "https://api.klue.com/v1", + ) -> None: + super().__init__(token, token_type="Bearer") + self.base_url = base_url + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class KlueTokenConfig(BaseModel): + """Configuration for Klue client via API Key. + + Args: + token: The Klue API key + base_url: API base URL (default: https://api.klue.com/v1) + """ + + token: str + base_url: str = "https://api.klue.com/v1" + + def create_client(self) -> KlueRESTClientViaToken: + return KlueRESTClientViaToken(self.token, self.base_url) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class KlueAuthConfig(BaseModel): + """Auth section of the Klue connector configuration from etcd.""" + + apiToken: str | None = None + token: str | None = None + + class Config: + extra = "allow" + + +class KlueCredentialsConfig(BaseModel): + """Credentials section of the Klue connector configuration.""" + + access_token: str | None = None + + class Config: + extra = "allow" + + +class KlueConnectorConfig(BaseModel): + """Top-level Klue connector configuration from etcd.""" + + auth: KlueAuthConfig = Field(default_factory=KlueAuthConfig) + credentials: KlueCredentialsConfig = Field( + default_factory=KlueCredentialsConfig + ) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class KlueClient(IClient): + """Builder class for Klue clients with API Key authentication. + + Supports: + - API Key (Bearer token) authentication + """ + + def __init__( + self, + client: KlueRESTClientViaToken, + ) -> None: + """Initialize with a Klue client object.""" + super().__init__() + self.client = client + + @override + def get_client(self) -> KlueRESTClientViaToken: + """Return the Klue client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: KlueTokenConfig, + ) -> "KlueClient": + """Build KlueClient with configuration. + + Args: + config: KlueTokenConfig instance + + Returns: + KlueClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "KlueClient": + """Build KlueClient using configuration service. + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + KlueClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError("Failed to get Klue connector configuration") + + connector_config = KlueConnectorConfig.model_validate(raw_config) + + token = ( + connector_config.auth.apiToken + or connector_config.auth.token + or connector_config.credentials.access_token + or "" + ) + if not token: + raise ValueError( + "API token required for Klue authentication" + ) + + token_config = KlueTokenConfig(token=token) + return cls(token_config.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Klue client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "KlueClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service + + Returns: + KlueClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("credentials", {}) or {} + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + token: str = str( + credentials.get("access_token", "") + or auth_config.get("apiToken", "") + or auth_config.get("token", "") + ) + if not token: + raise ValueError("API token not found in toolset config") + + token_config = KlueTokenConfig(token=token) + return cls(token_config.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Klue client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Klue.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get Klue connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error(f"Failed to get Klue connector config: {e}") + raise ValueError( + f"Failed to get Klue connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/lookerstudio/lookerstudio.py b/backend/python/app/sources/client/lookerstudio/lookerstudio.py new file mode 100644 index 000000000..c57d297cb --- /dev/null +++ b/backend/python/app/sources/client/lookerstudio/lookerstudio.py @@ -0,0 +1,512 @@ +"""Looker Studio (Google Data Studio) client implementation. + +This module provides clients for interacting with the Looker Studio API +using either: +1. OAuth 2.0 (Google OAuth) authentication +2. Pre-generated Bearer token authentication + +OAuth Scopes: +- https://www.googleapis.com/auth/datastudio +- https://www.googleapis.com/auth/datastudio.readonly + +Authentication Reference: https://developers.google.com/identity/protocols/oauth2 +API Reference: https://developers.google.com/looker-studio/integrate/api +""" + +import base64 +import json +import logging +from enum import Enum +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class LookerStudioAuthType(str, Enum): + """Authentication types supported by the Looker Studio connector.""" + + OAUTH = "OAUTH" + TOKEN = "TOKEN" + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class LookerStudioResponse(BaseModel): + """Standardized Looker Studio API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client classes +# --------------------------------------------------------------------------- + + +class LookerStudioRESTClientViaOAuth(HTTPClient): + """Looker Studio REST client via Google OAuth 2.0. + + OAuth tokens are passed as Bearer tokens in the Authorization header. + + Args: + access_token: The OAuth access token + client_id: OAuth client ID (for token refresh) + client_secret: OAuth client secret (for token refresh) + redirect_uri: OAuth redirect URI + """ + + def __init__( + self, + access_token: str, + client_id: str | None = None, + client_secret: str | None = None, + redirect_uri: str | None = None, + ) -> None: + super().__init__(access_token, "Bearer") + self.base_url = "https://datastudio.googleapis.com/v1" + self.access_token = access_token + self.client_id = client_id + self.client_secret = client_secret + self.redirect_uri = redirect_uri + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +class LookerStudioRESTClientViaToken(HTTPClient): + """Looker Studio REST client via pre-generated Bearer token. + + Simple authentication using a service account or pre-generated token + passed directly in the Authorization header. + + Args: + token: The pre-generated Bearer token + """ + + def __init__(self, token: str) -> None: + super().__init__(token, token_type="Bearer") + self.base_url = "https://datastudio.googleapis.com/v1" + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class LookerStudioOAuthConfig(BaseModel): + """Configuration for Looker Studio client via OAuth 2.0. + + Args: + access_token: The OAuth access token + client_id: OAuth client ID + client_secret: OAuth client secret + redirect_uri: OAuth redirect URI + """ + + access_token: str + client_id: str | None = None + client_secret: str | None = None + redirect_uri: str | None = None + + def create_client(self) -> LookerStudioRESTClientViaOAuth: + return LookerStudioRESTClientViaOAuth( + self.access_token, + self.client_id, + self.client_secret, + self.redirect_uri, + ) + + +class LookerStudioTokenConfig(BaseModel): + """Configuration for Looker Studio client via Bearer token. + + Args: + token: The pre-generated Bearer token + """ + + token: str + + def create_client(self) -> LookerStudioRESTClientViaToken: + return LookerStudioRESTClientViaToken(self.token) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class LookerStudioAuthConfigModel(BaseModel): + """Auth section of the Looker Studio connector configuration from etcd.""" + + authType: LookerStudioAuthType = LookerStudioAuthType.OAUTH + clientId: str | None = None + clientSecret: str | None = None + redirectUri: str | None = None + token: str | None = None + oauthConfigId: str | None = None + + class Config: + extra = "allow" + + +class LookerStudioCredentialsConfig(BaseModel): + """Credentials section of the Looker Studio connector configuration.""" + + access_token: str | None = None + refresh_token: str | None = None + + class Config: + extra = "allow" + + +class LookerStudioConnectorConfig(BaseModel): + """Top-level Looker Studio connector configuration from etcd.""" + + auth: LookerStudioAuthConfigModel = Field( + default_factory=LookerStudioAuthConfigModel + ) + credentials: LookerStudioCredentialsConfig = Field( + default_factory=LookerStudioCredentialsConfig + ) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class LookerStudioClient(IClient): + """Builder class for Looker Studio clients with different auth methods. + + Supports: + - Google OAuth 2.0 authentication + - Pre-generated Bearer token authentication + """ + + def __init__( + self, + client: LookerStudioRESTClientViaOAuth | LookerStudioRESTClientViaToken, + ) -> None: + """Initialize with a Looker Studio client object.""" + super().__init__() + self.client = client + + @override + def get_client( + self, + ) -> LookerStudioRESTClientViaOAuth | LookerStudioRESTClientViaToken: + """Return the Looker Studio client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: LookerStudioOAuthConfig | LookerStudioTokenConfig, + ) -> "LookerStudioClient": + """Build LookerStudioClient with configuration. + + Args: + config: LookerStudioOAuthConfig or LookerStudioTokenConfig instance + + Returns: + LookerStudioClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "LookerStudioClient": + """Build LookerStudioClient using configuration service. + + Supports two authentication strategies: + 1. OAUTH: Google OAuth 2.0 with access token + 2. TOKEN: Pre-generated Bearer token + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + LookerStudioClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get Looker Studio connector configuration" + ) + + connector_config = LookerStudioConnectorConfig.model_validate( + raw_config + ) + + if connector_config.auth.authType == LookerStudioAuthType.OAUTH: + access_token = connector_config.credentials.access_token or "" + client_id = connector_config.auth.clientId or "" + client_secret = connector_config.auth.clientSecret or "" + redirect_uri = connector_config.auth.redirectUri or "" + + # Try shared OAuth config if credentials are missing + oauth_config_id = connector_config.auth.oauthConfigId + if oauth_config_id and not (client_id and client_secret): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/lookerstudio", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + redirect_uri = str( + shared.get("redirectUri") + or shared.get("redirect_uri") + or redirect_uri + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + if not access_token: + raise ValueError( + "Access token required for OAuth auth type" + ) + + oauth_cfg = LookerStudioOAuthConfig( + access_token=access_token, + client_id=client_id, + client_secret=client_secret, + redirect_uri=redirect_uri, + ) + return cls(oauth_cfg.create_client()) + + elif connector_config.auth.authType == LookerStudioAuthType.TOKEN: + token = connector_config.auth.token or "" + if not token: + raise ValueError( + "Token required for TOKEN auth type" + ) + + token_config = LookerStudioTokenConfig(token=token) + return cls(token_config.create_client()) + + else: + raise ValueError( + f"Invalid auth type: {connector_config.auth.authType}" + ) + + except Exception as e: + logger.error( + f"Failed to build Looker Studio client from services: " + f"{str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "LookerStudioClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service for shared OAuth + + Returns: + LookerStudioClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("credentials", {}) or {} + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + access_token: str = str(credentials.get("access_token", "")) + if not access_token: + raise ValueError( + "Access token not found in toolset config" + ) + + client_id: str = str(auth_config.get("clientId", "")) + client_secret: str = str(auth_config.get("clientSecret", "")) + redirect_uri: str = str(auth_config.get("redirectUri", "")) + + # Try shared OAuth config + oauth_config_id: str | None = cast( + str | None, auth_config.get("oauthConfigId") + ) + if ( + oauth_config_id + and config_service + and not (client_id and client_secret) + ): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/lookerstudio", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + redirect_uri = str( + shared.get("redirectUri") + or shared.get("redirect_uri") + or redirect_uri + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + oauth_cfg = LookerStudioOAuthConfig( + access_token=access_token, + client_id=client_id, + client_secret=client_secret, + redirect_uri=redirect_uri, + ) + return cls(oauth_cfg.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Looker Studio client from toolset: " + f"{str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Looker Studio.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get Looker Studio connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error( + f"Failed to get Looker Studio connector config: {e}" + ) + raise ValueError( + f"Failed to get Looker Studio connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/loopio/loopio.py b/backend/python/app/sources/client/loopio/loopio.py new file mode 100644 index 000000000..64f7dabcb --- /dev/null +++ b/backend/python/app/sources/client/loopio/loopio.py @@ -0,0 +1,309 @@ +"""Loopio client implementation. + +This module provides a client for interacting with the Loopio API using +Bearer token (API Key) authentication. + +API Reference: https://developer.loopio.com/ +""" + +import base64 +import json +import logging +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class LoopioResponse(BaseModel): + """Standardized Loopio API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client class +# --------------------------------------------------------------------------- + + +class LoopioRESTClientViaToken(HTTPClient): + """Loopio REST client via API Key (Bearer token). + + Args: + token: API key used as Bearer token + """ + + def __init__(self, token: str) -> None: + super().__init__(token, "Bearer") + self.base_url = "https://api.loopio.com/v1" + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class LoopioTokenConfig(BaseModel): + """Configuration for Loopio client via API Key. + + Args: + token: API key (Bearer token) + """ + + token: str + + def create_client(self) -> LoopioRESTClientViaToken: + return LoopioRESTClientViaToken(self.token) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class LoopioAuthConfig(BaseModel): + """Auth section of the Loopio connector configuration from etcd.""" + + apiToken: str | None = None + token: str | None = None + + class Config: + extra = "allow" + + +class LoopioCredentialsConfig(BaseModel): + """Credentials section of the Loopio connector configuration.""" + + access_token: str | None = None + + class Config: + extra = "allow" + + +class LoopioConnectorConfig(BaseModel): + """Top-level Loopio connector configuration from etcd.""" + + auth: LoopioAuthConfig = Field(default_factory=LoopioAuthConfig) + credentials: LoopioCredentialsConfig = Field( + default_factory=LoopioCredentialsConfig + ) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class LoopioClient(IClient): + """Builder class for Loopio clients. + + Supports: + - API Key (Bearer token) authentication + """ + + def __init__( + self, + client: LoopioRESTClientViaToken, + ) -> None: + """Initialize with a Loopio client object.""" + super().__init__() + self.client = client + + @override + def get_client(self) -> LoopioRESTClientViaToken: + """Return the Loopio client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: LoopioTokenConfig, + ) -> "LoopioClient": + """Build LoopioClient with configuration. + + Args: + config: LoopioTokenConfig instance + + Returns: + LoopioClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "LoopioClient": + """Build LoopioClient using configuration service. + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + LoopioClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get Loopio connector configuration" + ) + + connector_config = LoopioConnectorConfig.model_validate( + raw_config + ) + + token = ( + connector_config.auth.apiToken + or connector_config.auth.token + or connector_config.credentials.access_token + or "" + ) + if not token: + raise ValueError( + "API token required for Loopio authentication" + ) + + token_config = LoopioTokenConfig(token=token) + return cls(token_config.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Loopio client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "LoopioClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service + + Returns: + LoopioClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], + toolset_config.get("credentials", {}) or {}, + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + access_token: str = str( + credentials.get("access_token", "") + or auth_config.get("apiToken", "") + or auth_config.get("token", "") + ) + if not access_token: + raise ValueError( + "Access token not found in toolset config" + ) + + token_config = LoopioTokenConfig(token=access_token) + return cls(token_config.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Loopio client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Loopio.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get Loopio connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error( + f"Failed to get Loopio connector config: {e}" + ) + raise ValueError( + f"Failed to get Loopio connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/lucid/lucid.py b/backend/python/app/sources/client/lucid/lucid.py new file mode 100644 index 000000000..5f0c692f6 --- /dev/null +++ b/backend/python/app/sources/client/lucid/lucid.py @@ -0,0 +1,497 @@ +"""Lucid client implementation. + +This module provides clients for interacting with the Lucid API using either: +1. OAuth 2.0 access token authentication +2. Bearer Token authentication + +Lucid API covers Lucidchart and Lucidspark products. + +Authentication Reference: https://developer.lucid.co/ +API Reference: https://developer.lucid.co/rest-api/v1/ +""" + +import base64 +import json +import logging +from enum import Enum +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class LucidAuthType(str, Enum): + """Authentication types supported by the Lucid connector.""" + + OAUTH = "OAUTH" + TOKEN = "TOKEN" + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class LucidResponse(BaseModel): + """Standardized Lucid API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client classes +# --------------------------------------------------------------------------- + + +class LucidRESTClientViaToken(HTTPClient): + """Lucid REST client via Bearer Token. + + Uses a static Bearer token for authentication. + + Args: + token: The Bearer token + """ + + def __init__(self, token: str) -> None: + super().__init__(token, token_type="Bearer") + self.base_url = "https://api.lucid.co/v1" + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +class LucidRESTClientViaOAuth(HTTPClient): + """Lucid REST client via OAuth 2.0 access token. + + OAuth tokens are passed as Bearer tokens in the Authorization header. + + Args: + access_token: The OAuth access token + client_id: OAuth client ID (for reference / token refresh) + client_secret: OAuth client secret (for reference / token refresh) + """ + + def __init__( + self, + access_token: str, + client_id: str | None = None, + client_secret: str | None = None, + ) -> None: + super().__init__(access_token, "Bearer") + self.base_url = "https://api.lucid.co/v1" + self.access_token = access_token + self.client_id = client_id + self.client_secret = client_secret + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class LucidTokenConfig(BaseModel): + """Configuration for Lucid client via Bearer Token. + + Args: + token: The Bearer token + """ + + token: str + + def create_client(self) -> LucidRESTClientViaToken: + return LucidRESTClientViaToken(self.token) + + +class LucidOAuthConfig(BaseModel): + """Configuration for Lucid client via OAuth 2.0. + + Args: + access_token: The OAuth access token + client_id: OAuth client ID + client_secret: OAuth client secret + """ + + access_token: str + client_id: str | None = None + client_secret: str | None = None + + def create_client(self) -> LucidRESTClientViaOAuth: + return LucidRESTClientViaOAuth( + self.access_token, + self.client_id, + self.client_secret, + ) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class LucidAuthConfig(BaseModel): + """Auth section of the Lucid connector configuration from etcd.""" + + authType: LucidAuthType = LucidAuthType.TOKEN + apiToken: str | None = None + token: str | None = None + clientId: str | None = None + clientSecret: str | None = None + redirectUri: str | None = None + oauthConfigId: str | None = None + + class Config: + extra = "allow" + + +class LucidCredentialsConfig(BaseModel): + """Credentials section of the Lucid connector configuration.""" + + access_token: str | None = None + refresh_token: str | None = None + + class Config: + extra = "allow" + + +class LucidConnectorConfig(BaseModel): + """Top-level Lucid connector configuration from etcd.""" + + auth: LucidAuthConfig = Field(default_factory=LucidAuthConfig) + credentials: LucidCredentialsConfig = Field( + default_factory=LucidCredentialsConfig + ) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Shared OAuth configuration model +# --------------------------------------------------------------------------- + + +class LucidSharedOAuthConfig(BaseModel): + """Shared OAuth configuration for Lucid (from etcd /services/oauth/lucid).""" + + _id: str | None = None + clientId: str | None = None + client_id: str | None = None + clientSecret: str | None = None + client_secret: str | None = None + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class LucidClient(IClient): + """Builder class for Lucid clients with different authentication methods. + + Supports: + - Bearer Token authentication + - OAuth 2.0 access token authentication + """ + + def __init__( + self, + client: LucidRESTClientViaToken | LucidRESTClientViaOAuth, + ) -> None: + """Initialize with a Lucid client object.""" + super().__init__() + self.client = client + + @override + def get_client( + self, + ) -> LucidRESTClientViaToken | LucidRESTClientViaOAuth: + """Return the Lucid client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: LucidTokenConfig | LucidOAuthConfig, + ) -> "LucidClient": + """Build LucidClient with configuration. + + Args: + config: LucidTokenConfig or LucidOAuthConfig instance + + Returns: + LucidClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "LucidClient": + """Build LucidClient using configuration service. + + Supports two authentication strategies: + 1. TOKEN: For Bearer token authentication + 2. OAUTH: For OAuth 2.0 access tokens + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + LucidClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError("Failed to get Lucid connector configuration") + + connector_config = LucidConnectorConfig.model_validate(raw_config) + + if connector_config.auth.authType == LucidAuthType.OAUTH: + access_token = connector_config.credentials.access_token or "" + client_id = connector_config.auth.clientId or "" + client_secret = connector_config.auth.clientSecret or "" + + # Try shared OAuth config if credentials are missing + oauth_config_id = connector_config.auth.oauthConfigId + if oauth_config_id and not (client_id and client_secret): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/lucid", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + if not access_token: + raise ValueError( + "Access token required for OAuth auth type" + ) + + oauth_cfg = LucidOAuthConfig( + access_token=access_token, + client_id=client_id, + client_secret=client_secret, + ) + return cls(oauth_cfg.create_client()) + + elif connector_config.auth.authType == LucidAuthType.TOKEN: + token = ( + connector_config.auth.apiToken + or connector_config.auth.token + or "" + ) + if not token: + raise ValueError( + "Bearer token required for TOKEN auth type" + ) + + token_config = LucidTokenConfig(token=token) + return cls(token_config.create_client()) + + else: + raise ValueError( + f"Invalid auth type: {connector_config.auth.authType}" + ) + + except Exception as e: + logger.error( + f"Failed to build Lucid client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "LucidClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service for shared OAuth config + + Returns: + LucidClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("credentials", {}) or {} + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + access_token: str = str(credentials.get("access_token", "")) + if not access_token: + raise ValueError("Access token not found in toolset config") + + client_id: str = str(auth_config.get("clientId", "")) + client_secret: str = str(auth_config.get("clientSecret", "")) + + # Try shared OAuth config + oauth_config_id: str | None = cast( + str | None, auth_config.get("oauthConfigId") + ) + if oauth_config_id and config_service and not ( + client_id and client_secret + ): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/lucid", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + oauth_cfg = LucidOAuthConfig( + access_token=access_token, + client_id=client_id, + client_secret=client_secret, + ) + return cls(oauth_cfg.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Lucid client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Lucid.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get Lucid connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error(f"Failed to get Lucid connector config: {e}") + raise ValueError( + f"Failed to get Lucid connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/lumapps/lumapps.py b/backend/python/app/sources/client/lumapps/lumapps.py new file mode 100644 index 000000000..2bf4e6b27 --- /dev/null +++ b/backend/python/app/sources/client/lumapps/lumapps.py @@ -0,0 +1,521 @@ +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownParameterType=false +"""LumApps client implementation. + +This module provides clients for interacting with the LumApps API using the +official ``lumapps-sdk`` Python package. + +Authentication: + - Access Token: Passed directly to ``BaseClient`` + - Service Account: Client credentials passed via ``auth_info`` + +SDK Reference: https://github.com/lumapps/lumapps-sdk +""" + +import base64 +import json +import logging +from enum import Enum +from typing import Any, cast + +from lumapps.api import BaseClient +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class LumAppsAuthType(str, Enum): + """Authentication types supported by the LumApps connector.""" + + OAUTH = "OAUTH" + TOKEN = "TOKEN" + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class LumAppsResponse(BaseModel): + """Standardized LumApps API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# SDK wrapper classes +# --------------------------------------------------------------------------- + + +class LumAppsClientViaToken: + """LumApps SDK wrapper authenticated via access token. + + Args: + token: The access token + base_url: LumApps cell URL (e.g. ``https://go-cell-001.api.lumapps.com``) + """ + + def __init__(self, token: str, base_url: str = "https://go-cell-001.api.lumapps.com") -> None: + self.token = token + self.base_url = base_url + self._sdk: BaseClient | None = None + + def create_client(self) -> BaseClient: + """Create and return the SDK client.""" + self._sdk = BaseClient( + api_info={"base_url": self.base_url}, + token=self.token, + ) + return self._sdk + + def get_sdk(self) -> BaseClient: + """Return the SDK client, creating it lazily if needed.""" + if self._sdk is None: + return self.create_client() + return self._sdk + + +class LumAppsClientViaServiceAccount: + """LumApps SDK wrapper authenticated via service account credentials. + + Args: + client_id: OAuth client ID + client_secret: OAuth client secret + base_url: LumApps cell URL (e.g. ``https://go-cell-001.api.lumapps.com``) + """ + + def __init__( + self, + client_id: str, + client_secret: str, + base_url: str = "https://go-cell-001.api.lumapps.com", + ) -> None: + self.client_id = client_id + self.client_secret = client_secret + self.base_url = base_url + self._sdk: BaseClient | None = None + + def create_client(self) -> BaseClient: + """Create and return the SDK client.""" + self._sdk = BaseClient( + api_info={"base_url": self.base_url}, + auth_info={ + "client_id": self.client_id, + "client_secret": self.client_secret, + }, + ) + return self._sdk + + def get_sdk(self) -> BaseClient: + """Return the SDK client, creating it lazily if needed.""" + if self._sdk is None: + return self.create_client() + return self._sdk + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class LumAppsTokenConfig(BaseModel): + """Configuration for LumApps client via access token. + + Args: + token: The access token + base_url: LumApps cell URL + """ + + token: str + base_url: str = "https://go-cell-001.api.lumapps.com" + + def create_client(self) -> LumAppsClientViaToken: + return LumAppsClientViaToken(token=self.token, base_url=self.base_url) + + +class LumAppsOAuthConfig(BaseModel): + """Configuration for LumApps client via service account. + + Args: + client_id: OAuth client ID + client_secret: OAuth client secret + access_token: Optional pre-fetched access token (used as token auth) + base_url: LumApps cell URL + """ + + client_id: str + client_secret: str + access_token: str | None = None + base_url: str = "https://go-cell-001.api.lumapps.com" + + def create_client(self) -> LumAppsClientViaToken | LumAppsClientViaServiceAccount: + # If we already have an access token, use token auth + if self.access_token: + return LumAppsClientViaToken(token=self.access_token, base_url=self.base_url) + return LumAppsClientViaServiceAccount( + client_id=self.client_id, + client_secret=self.client_secret, + base_url=self.base_url, + ) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class LumAppsAuthConfigModel(BaseModel): + """Auth section of the LumApps connector configuration from etcd.""" + + authType: LumAppsAuthType = LumAppsAuthType.OAUTH + apiToken: str | None = None + token: str | None = None + clientId: str | None = None + clientSecret: str | None = None + redirectUri: str | None = None + oauthConfigId: str | None = None + baseUrl: str | None = None + + class Config: + extra = "allow" + + +class LumAppsCredentialsConfig(BaseModel): + """Credentials section of the LumApps connector configuration.""" + + access_token: str | None = None + refresh_token: str | None = None + + class Config: + extra = "allow" + + +class LumAppsConnectorConfig(BaseModel): + """Top-level LumApps connector configuration from etcd.""" + + auth: LumAppsAuthConfigModel = Field(default_factory=LumAppsAuthConfigModel) + credentials: LumAppsCredentialsConfig = Field( + default_factory=LumAppsCredentialsConfig + ) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class LumAppsClient(IClient): + """Builder class for LumApps clients using the official SDK. + + Supports: + - Access token authentication + - Service account (client credentials) authentication + """ + + def __init__( + self, + client: LumAppsClientViaToken | LumAppsClientViaServiceAccount, + ) -> None: + """Initialize with a LumApps SDK wrapper.""" + super().__init__() + self.client = client + + @override + def get_client( + self, + ) -> LumAppsClientViaToken | LumAppsClientViaServiceAccount: + """Return the LumApps SDK wrapper.""" + return self.client + + def get_sdk(self) -> BaseClient: + """Return the underlying LumApps SDK instance.""" + return self.client.get_sdk() + + @classmethod + def build_with_config( + cls, + config: LumAppsTokenConfig | LumAppsOAuthConfig, + ) -> "LumAppsClient": + """Build LumAppsClient with configuration. + + Args: + config: LumAppsTokenConfig or LumAppsOAuthConfig instance + + Returns: + LumAppsClient instance + """ + wrapper = config.create_client() + wrapper.get_sdk() # eagerly initialize + return cls(wrapper) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "LumAppsClient": + """Build LumAppsClient using configuration service. + + Supports two authentication strategies: + 1. OAUTH: OAuth 2.0 access token (service account) + 2. TOKEN: Bearer token + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + LumAppsClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get LumApps connector configuration" + ) + + connector_config = LumAppsConnectorConfig.model_validate(raw_config) + + base_url = ( + connector_config.auth.baseUrl + or "https://go-cell-001.api.lumapps.com" + ) + + if connector_config.auth.authType == LumAppsAuthType.OAUTH: + access_token = connector_config.credentials.access_token or "" + client_id = connector_config.auth.clientId or "" + client_secret = connector_config.auth.clientSecret or "" + + # Try shared OAuth config if credentials are missing + oauth_config_id = connector_config.auth.oauthConfigId + if oauth_config_id and not (client_id and client_secret): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/lumapps", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + if access_token: + wrapper = LumAppsClientViaToken( + token=access_token, base_url=base_url + ) + elif client_id and client_secret: + wrapper = LumAppsClientViaServiceAccount( + client_id=client_id, + client_secret=client_secret, + base_url=base_url, + ) + else: + raise ValueError( + "Access token or client credentials required for OAuth auth type" + ) + + wrapper.get_sdk() + return cls(wrapper) + + elif connector_config.auth.authType == LumAppsAuthType.TOKEN: + token = ( + connector_config.auth.apiToken + or connector_config.auth.token + or "" + ) + if not token: + raise ValueError( + "Token required for TOKEN auth type" + ) + + wrapper = LumAppsClientViaToken(token=token, base_url=base_url) + wrapper.get_sdk() + return cls(wrapper) + + else: + raise ValueError( + f"Invalid auth type: {connector_config.auth.authType}" + ) + + except Exception as e: + logger.error( + f"Failed to build LumApps client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "LumAppsClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service for shared OAuth config + + Returns: + LumAppsClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("credentials", {}) or {} + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + access_token: str = str(credentials.get("access_token", "")) + if not access_token: + raise ValueError( + "Access token not found in toolset config" + ) + + client_id: str = str(auth_config.get("clientId", "")) + client_secret: str = str(auth_config.get("clientSecret", "")) + + # Try shared OAuth config + oauth_config_id: str | None = cast( + str | None, auth_config.get("oauthConfigId") + ) + if oauth_config_id and config_service and not ( + client_id and client_secret + ): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/lumapps", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + wrapper = LumAppsClientViaToken(token=access_token) + wrapper.get_sdk() + return cls(wrapper) + + except Exception as e: + logger.error( + f"Failed to build LumApps client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for LumApps.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get LumApps connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error(f"Failed to get LumApps connector config: {e}") + raise ValueError( + f"Failed to get LumApps connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/marketo/marketo.py b/backend/python/app/sources/client/marketo/marketo.py new file mode 100644 index 000000000..44f97a634 --- /dev/null +++ b/backend/python/app/sources/client/marketo/marketo.py @@ -0,0 +1,461 @@ +"""Marketo client implementation. + +This module provides a client for interacting with the Marketo REST API using +OAuth 2.0 Client Credentials authentication. + +The client automatically fetches an access token from the Marketo identity +endpoint using client_id and client_secret before making API calls. + +Authentication Reference: https://developers.marketo.com/rest-api/authentication/ +API Reference: https://developers.marketo.com/rest-api/ +""" + +import base64 +import json +import logging +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.http.http_request import HTTPRequest +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class MarketoResponse(BaseModel): + """Standardized Marketo API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client class +# --------------------------------------------------------------------------- + + +class MarketoRESTClientViaClientCredentials(HTTPClient): + """Marketo REST client via OAuth 2.0 Client Credentials. + + Automatically fetches an access token from the Marketo identity endpoint + using the client_id and client_secret on first use via + ensure_authenticated(). + + Args: + munchkin_id: Marketo Munchkin Account ID (e.g. "123-ABC-456") + client_id: OAuth client ID + client_secret: OAuth client secret + """ + + def __init__( + self, + munchkin_id: str, + client_id: str, + client_secret: str, + ) -> None: + # Initialize with empty token; will be set after authentication + super().__init__("", token_type="Bearer") + self.munchkin_id = munchkin_id + self.client_id = client_id + self.client_secret = client_secret + self.base_url = f"https://{munchkin_id}.mktorest.com/rest" + self._identity_url = ( + f"https://{munchkin_id}.mktorest.com/identity/oauth/token" + ) + self._authenticated = False + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + async def ensure_authenticated(self) -> None: + """Fetch an access token via client_credentials grant if not already authenticated. + + Posts to the Marketo identity token endpoint with grant_type=client_credentials, + client_id, and client_secret as query parameters. + """ + if self._authenticated: + return + + token_url = ( + f"{self._identity_url}" + f"?grant_type=client_credentials" + f"&client_id={self.client_id}" + f"&client_secret={self.client_secret}" + ) + + token_request = HTTPRequest( + url=token_url, + method="GET", + headers={"Content-Type": "application/json"}, + ) + + response = await self.execute(token_request) # type: ignore[reportUnknownMemberType] + response_data = response.json() + + access_token = response_data.get("access_token") + if not access_token: + raise ValueError( + "Failed to obtain access token from Marketo identity endpoint: " + f"{response_data}" + ) + + self.headers["Authorization"] = f"Bearer {access_token}" + self._authenticated = True + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class MarketoClientCredentialsConfig(BaseModel): + """Configuration for Marketo client via OAuth 2.0 Client Credentials. + + Args: + munchkin_id: Marketo Munchkin Account ID (e.g. "123-ABC-456") + client_id: OAuth client ID + client_secret: OAuth client secret + """ + + munchkin_id: str + client_id: str + client_secret: str + + def create_client(self) -> MarketoRESTClientViaClientCredentials: + return MarketoRESTClientViaClientCredentials( + self.munchkin_id, + self.client_id, + self.client_secret, + ) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class MarketoAuthConfig(BaseModel): + """Auth section of the Marketo connector configuration from etcd.""" + + munchkinId: str | None = None + clientId: str | None = None + clientSecret: str | None = None + oauthConfigId: str | None = None + + class Config: + extra = "allow" + + +class MarketoCredentialsConfig(BaseModel): + """Credentials section of the Marketo connector configuration.""" + + access_token: str | None = None + + class Config: + extra = "allow" + + +class MarketoConnectorConfig(BaseModel): + """Top-level Marketo connector configuration from etcd.""" + + auth: MarketoAuthConfig = Field(default_factory=MarketoAuthConfig) + credentials: MarketoCredentialsConfig = Field( + default_factory=MarketoCredentialsConfig + ) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class MarketoClient(IClient): + """Builder class for Marketo clients. + + Supports: + - OAuth 2.0 Client Credentials authentication (auto-fetches token + using munchkin_id, client_id, and client_secret) + """ + + def __init__( + self, + client: MarketoRESTClientViaClientCredentials, + ) -> None: + """Initialize with a Marketo client object.""" + super().__init__() + self.client = client + + @override + def get_client(self) -> MarketoRESTClientViaClientCredentials: + """Return the Marketo client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: MarketoClientCredentialsConfig, + ) -> "MarketoClient": + """Build MarketoClient with configuration. + + Args: + config: MarketoClientCredentialsConfig instance + + Returns: + MarketoClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "MarketoClient": + """Build MarketoClient using configuration service. + + Uses client_credentials grant with munchkin_id, client_id, + and client_secret from the connector configuration. + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + MarketoClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError("Failed to get Marketo connector configuration") + + connector_config = MarketoConnectorConfig.model_validate(raw_config) + + munchkin_id = connector_config.auth.munchkinId or "" + client_id = connector_config.auth.clientId or "" + client_secret = connector_config.auth.clientSecret or "" + + # Try shared OAuth config if credentials are missing + oauth_config_id = connector_config.auth.oauthConfigId + if oauth_config_id and not (client_id and client_secret): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/marketo", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + munchkin_id = str( + shared.get("munchkinId") + or shared.get("munchkin_id") + or munchkin_id + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + if not (munchkin_id and client_id and client_secret): + raise ValueError( + "munchkin_id, client_id, and client_secret are required " + "for Marketo client credentials auth" + ) + + creds_config = MarketoClientCredentialsConfig( + munchkin_id=munchkin_id, + client_id=client_id, + client_secret=client_secret, + ) + return cls(creds_config.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Marketo client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "MarketoClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service for shared OAuth config + + Returns: + MarketoClient instance + """ + try: + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + munchkin_id: str = str(auth_config.get("munchkinId", "")) + client_id: str = str(auth_config.get("clientId", "")) + client_secret: str = str(auth_config.get("clientSecret", "")) + + # Try shared OAuth config + oauth_config_id: str | None = cast( + str | None, auth_config.get("oauthConfigId") + ) + if oauth_config_id and config_service and not ( + client_id and client_secret + ): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/marketo", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + munchkin_id = str( + shared.get("munchkinId") + or shared.get("munchkin_id") + or munchkin_id + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + if not (munchkin_id and client_id and client_secret): + raise ValueError( + "munchkin_id, client_id, and client_secret are required " + "in toolset config for Marketo" + ) + + creds_config = MarketoClientCredentialsConfig( + munchkin_id=munchkin_id, + client_id=client_id, + client_secret=client_secret, + ) + return cls(creds_config.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Marketo client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Marketo.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get Marketo connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error(f"Failed to get Marketo connector config: {e}") + raise ValueError( + f"Failed to get Marketo connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/mattermost/mattermost.py b/backend/python/app/sources/client/mattermost/mattermost.py new file mode 100644 index 000000000..74898fb01 --- /dev/null +++ b/backend/python/app/sources/client/mattermost/mattermost.py @@ -0,0 +1,345 @@ +"""Mattermost client implementation. + +This module provides clients for interacting with the Mattermost API using either: +1. Personal Access Token authentication (Bearer token) +2. Login-based session token authentication (username + password) + +The base URL is constructed from the server domain: https://{server}/api/v4 + +Authentication Reference: https://api.mattermost.com/#tag/authentication +API Reference: https://api.mattermost.com/ +""" + +import base64 +import json +import logging +from enum import Enum +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class MattermostAuthType(str, Enum): + """Authentication types supported by the Mattermost connector.""" + + TOKEN = "TOKEN" + LOGIN = "LOGIN" + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class MattermostResponse(BaseModel): + """Standardized Mattermost API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client classes +# --------------------------------------------------------------------------- + + +class MattermostRESTClientViaToken(HTTPClient): + """Mattermost REST client via Personal Access Token (Bearer). + + Personal access tokens are passed as Bearer tokens in the Authorization header. + + Args: + token: The personal access token + server: Mattermost server domain (e.g. "mattermost.example.com") + """ + + def __init__(self, token: str, server: str) -> None: + super().__init__(token, token_type="Bearer") + self.base_url = f"https://{server}/api/v4" + self.server = server + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + def get_server(self) -> str: + """Get the server domain.""" + return self.server + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class MattermostTokenConfig(BaseModel): + """Configuration for Mattermost client via Personal Access Token. + + Args: + token: The personal access token + server: Mattermost server domain (e.g. "mattermost.example.com") + """ + + token: str + server: str + + def create_client(self) -> MattermostRESTClientViaToken: + return MattermostRESTClientViaToken(self.token, self.server) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class MattermostAuthConfig(BaseModel): + """Auth section of the Mattermost connector configuration from etcd.""" + + authType: MattermostAuthType = MattermostAuthType.TOKEN + server: str | None = None + apiToken: str | None = None + token: str | None = None + + class Config: + extra = "allow" + + +class MattermostCredentialsConfig(BaseModel): + """Credentials section of the Mattermost connector configuration.""" + + access_token: str | None = None + + class Config: + extra = "allow" + + +class MattermostConnectorConfig(BaseModel): + """Top-level Mattermost connector configuration from etcd.""" + + auth: MattermostAuthConfig = Field(default_factory=MattermostAuthConfig) + credentials: MattermostCredentialsConfig = Field( + default_factory=MattermostCredentialsConfig + ) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class MattermostClient(IClient): + """Builder class for Mattermost clients with different authentication methods. + + Supports: + - Personal Access Token (Bearer) authentication + """ + + def __init__( + self, + client: MattermostRESTClientViaToken, + ) -> None: + """Initialize with a Mattermost client object.""" + super().__init__() + self.client = client + + @override + def get_client(self) -> MattermostRESTClientViaToken: + """Return the Mattermost client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: MattermostTokenConfig, + ) -> "MattermostClient": + """Build MattermostClient with configuration. + + Args: + config: MattermostTokenConfig instance + + Returns: + MattermostClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "MattermostClient": + """Build MattermostClient using configuration service. + + Supports Personal Access Token authentication. + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + MattermostClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get Mattermost connector configuration" + ) + + connector_config = MattermostConnectorConfig.model_validate( + raw_config + ) + + server = connector_config.auth.server or "" + if not server: + raise ValueError("Server domain is required") + + token = ( + connector_config.auth.apiToken + or connector_config.auth.token + or connector_config.credentials.access_token + or "" + ) + if not token: + raise ValueError( + "Personal access token required for TOKEN auth type" + ) + + token_config = MattermostTokenConfig(token=token, server=server) + return cls(token_config.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Mattermost client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "MattermostClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service + + Returns: + MattermostClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("credentials", {}) or {} + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + server: str = str(auth_config.get("server", "")) + if not server: + raise ValueError("Server domain not found in toolset config") + + access_token: str = str(credentials.get("access_token", "")) + if not access_token: + raise ValueError( + "Access token not found in toolset config" + ) + + token_config = MattermostTokenConfig( + token=access_token, server=server + ) + return cls(token_config.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Mattermost client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Mattermost.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get Mattermost connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error( + f"Failed to get Mattermost connector config: {e}" + ) + raise ValueError( + f"Failed to get Mattermost connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/mindtickle/mindtickle.py b/backend/python/app/sources/client/mindtickle/mindtickle.py new file mode 100644 index 000000000..a473f97b3 --- /dev/null +++ b/backend/python/app/sources/client/mindtickle/mindtickle.py @@ -0,0 +1,321 @@ +"""Mindtickle client implementation. + +This module provides a client for interacting with the Mindtickle API using +API Key (Bearer token) authentication. + +Base URL: https://api.mindtickle.com/v2 + +Authentication Reference: https://developer.mindtickle.com/docs/authentication +API Reference: https://developer.mindtickle.com/docs/api +""" + +import base64 +import json +import logging +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class MindtickleResponse(BaseModel): + """Standardized Mindtickle API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client class +# --------------------------------------------------------------------------- + + +class MindtickleRESTClientViaToken(HTTPClient): + """Mindtickle REST client via API Key (Bearer token). + + Simple authentication using an API key passed as a Bearer token + in the Authorization header. + + Args: + token: The API key / Bearer token + base_url: API base URL (default: https://api.mindtickle.com/v2) + """ + + def __init__( + self, + token: str, + base_url: str = "https://api.mindtickle.com/v2", + ) -> None: + super().__init__(token, token_type="Bearer") + self.base_url = base_url + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class MindtickleTokenConfig(BaseModel): + """Configuration for Mindtickle client via API Key (Bearer token). + + Args: + token: The API key / Bearer token + base_url: API base URL (default: https://api.mindtickle.com/v2) + """ + + token: str + base_url: str = "https://api.mindtickle.com/v2" + + def create_client(self) -> MindtickleRESTClientViaToken: + return MindtickleRESTClientViaToken(self.token, self.base_url) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class MindtickleAuthConfig(BaseModel): + """Auth section of the Mindtickle connector configuration from etcd.""" + + apiKey: str | None = None + token: str | None = None + + class Config: + extra = "allow" + + +class MindtickleCredentialsConfig(BaseModel): + """Credentials section of the Mindtickle connector configuration.""" + + access_token: str | None = None + + class Config: + extra = "allow" + + +class MindtickleConnectorConfig(BaseModel): + """Top-level Mindtickle connector configuration from etcd.""" + + auth: MindtickleAuthConfig = Field(default_factory=MindtickleAuthConfig) + credentials: MindtickleCredentialsConfig = Field( + default_factory=MindtickleCredentialsConfig + ) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class MindtickleClient(IClient): + """Builder class for Mindtickle clients. + + Supports: + - API Key (Bearer token) authentication + """ + + def __init__( + self, + client: MindtickleRESTClientViaToken, + ) -> None: + """Initialize with a Mindtickle client object.""" + super().__init__() + self.client = client + + @override + def get_client(self) -> MindtickleRESTClientViaToken: + """Return the Mindtickle client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: MindtickleTokenConfig, + ) -> "MindtickleClient": + """Build MindtickleClient with configuration. + + Args: + config: MindtickleTokenConfig instance + + Returns: + MindtickleClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "MindtickleClient": + """Build MindtickleClient using configuration service. + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + MindtickleClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get Mindtickle connector configuration" + ) + + connector_config = MindtickleConnectorConfig.model_validate( + raw_config + ) + + token = ( + connector_config.auth.apiKey + or connector_config.auth.token + or connector_config.credentials.access_token + or "" + ) + if not token: + raise ValueError( + "API key or token required for Mindtickle" + ) + + token_config = MindtickleTokenConfig(token=token) + return cls(token_config.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Mindtickle client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "MindtickleClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service (unused) + + Returns: + MindtickleClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("credentials", {}) or {} + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + access_token: str = str( + credentials.get("access_token", "") + or auth_config.get("apiKey", "") + or auth_config.get("token", "") + ) + if not access_token: + raise ValueError( + "Access token not found in toolset config" + ) + + token_config = MindtickleTokenConfig(token=access_token) + return cls(token_config.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Mindtickle client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Mindtickle.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get Mindtickle connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error( + f"Failed to get Mindtickle connector config: {e}" + ) + raise ValueError( + f"Failed to get Mindtickle connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/miro/miro.py b/backend/python/app/sources/client/miro/miro.py new file mode 100644 index 000000000..cf91be23b --- /dev/null +++ b/backend/python/app/sources/client/miro/miro.py @@ -0,0 +1,513 @@ +"""Miro client implementation. + +This module provides clients for interacting with the Miro API using the +official ``miro_api`` Python SDK instead of raw HTTP requests. + +Supported authentication strategies: +1. OAuth 2.0 authorization code flow (access token + optional client credentials) +2. Pre-generated access token + +SDK Reference: https://miroapp.github.io/api-clients/python/ +""" + +import logging +from enum import Enum +from typing import Any, cast + +from miro_api import MiroApi # type: ignore[reportMissingTypeStubs] +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class MiroAuthType(str, Enum): + """Authentication types supported by the Miro connector.""" + + OAUTH = "OAUTH" + TOKEN = "TOKEN" + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class MiroResponse(BaseModel): + """Standardized Miro API response wrapper. + + Wraps SDK return values into a uniform success/error envelope so that + callers never need to handle raw SDK types directly. + """ + + success: bool = Field( + ..., description="Whether the request was successful" + ) + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, + description="Response data from the SDK", + ) + error: str | None = Field( + default=None, description="Error message if failed" + ) + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary.""" + return self.model_dump(exclude_none=True) + + +# --------------------------------------------------------------------------- +# SDK wrapper classes +# --------------------------------------------------------------------------- + + +class MiroClientViaOAuth: + """Miro SDK client via OAuth 2.0 authorization code flow. + + Wraps ``MiroApi`` from the official ``miro_api`` package. + The *access_token* is the only credential the SDK needs at runtime; + *client_id* / *client_secret* are kept for upstream token-refresh logic. + + Args: + access_token: The OAuth access token. + client_id: OAuth client ID (retained for refresh flows). + client_secret: OAuth client secret (retained for refresh flows). + """ + + def __init__( + self, + access_token: str, + client_id: str | None = None, + client_secret: str | None = None, + ) -> None: + super().__init__() + self.access_token = access_token + self.client_id = client_id + self.client_secret = client_secret + self._sdk: Any = MiroApi(access_token) # type: ignore[reportUnknownVariableType] + + def get_sdk(self) -> Any: # MiroApi + """Return the underlying ``MiroApi`` instance.""" + return self._sdk + + +class MiroClientViaToken: + """Miro SDK client via a pre-generated access token. + + Args: + token: The pre-generated access token. + """ + + def __init__(self, token: str) -> None: + super().__init__() + self.token = token + self._sdk: Any = MiroApi(token) # type: ignore[reportUnknownVariableType] + + def get_sdk(self) -> Any: # MiroApi + """Return the underlying ``MiroApi`` instance.""" + return self._sdk + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class MiroOAuthConfig(BaseModel): + """Configuration for Miro client via OAuth 2.0 authorization code flow. + + Args: + access_token: The OAuth access token. + client_id: OAuth client ID. + client_secret: OAuth client secret. + """ + + access_token: str + client_id: str | None = None + client_secret: str | None = None + + def create_client(self) -> MiroClientViaOAuth: + """Create and return a ``MiroClientViaOAuth`` instance.""" + return MiroClientViaOAuth( + self.access_token, + self.client_id, + self.client_secret, + ) + + +class MiroTokenConfig(BaseModel): + """Configuration for Miro client via pre-generated access token. + + Args: + token: The pre-generated access token. + """ + + token: str + + def create_client(self) -> MiroClientViaToken: + """Create and return a ``MiroClientViaToken`` instance.""" + return MiroClientViaToken(self.token) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class MiroAuthConfig(BaseModel): + """Auth section of the Miro connector configuration from etcd.""" + + authType: MiroAuthType = MiroAuthType.OAUTH + clientId: str | None = None + clientSecret: str | None = None + redirectUri: str | None = None + token: str | None = None + oauthConfigId: str | None = None + + class Config: + extra = "allow" + + +class MiroCredentialsConfig(BaseModel): + """Credentials section of the Miro connector configuration.""" + + access_token: str | None = None + refresh_token: str | None = None + + class Config: + extra = "allow" + + +class MiroConnectorConfig(BaseModel): + """Top-level Miro connector configuration from etcd.""" + + auth: MiroAuthConfig = Field(default_factory=MiroAuthConfig) + credentials: MiroCredentialsConfig = Field( + default_factory=MiroCredentialsConfig + ) + + class Config: + extra = "allow" + + +class MiroSharedOAuthConfigEntry(BaseModel): + """A single entry from the shared OAuth config list in etcd. + + Handles both camelCase and snake_case key variants from the config store. + """ + + entry_id: str | None = Field(default=None, alias="_id") + clientId: str | None = None + client_id: str | None = None + clientSecret: str | None = None + client_secret: str | None = None + redirectUri: str | None = None + redirect_uri: str | None = None + + class Config: + extra = "allow" + populate_by_name = True + + def resolved_client_id(self, fallback: str = "") -> str: + """Return the best available client ID.""" + return self.clientId or self.client_id or fallback + + def resolved_client_secret(self, fallback: str = "") -> str: + """Return the best available client secret.""" + return self.clientSecret or self.client_secret or fallback + + def resolved_redirect_uri(self, fallback: str = "") -> str: + """Return the best available redirect URI.""" + return self.redirectUri or self.redirect_uri or fallback + + +class MiroSharedOAuthWrapper(BaseModel): + """Wrapper for a shared OAuth config entry with nested config.""" + + entry_id: str | None = Field(default=None, alias="_id") + config: MiroSharedOAuthConfigEntry = Field( + default_factory=MiroSharedOAuthConfigEntry + ) + + class Config: + extra = "allow" + populate_by_name = True + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class MiroClient(IClient): + """Builder class for Miro clients with different authentication methods. + + Wraps either ``MiroClientViaOAuth`` or ``MiroClientViaToken`` and + exposes the underlying ``MiroApi`` SDK via ``get_sdk()``. + """ + + def __init__( + self, + client: MiroClientViaOAuth | MiroClientViaToken, + ) -> None: + """Initialize with a Miro SDK wrapper.""" + super().__init__() + self.client = client + + @override + def get_client( + self, + ) -> MiroClientViaOAuth | MiroClientViaToken: + """Return the Miro SDK wrapper.""" + return self.client + + def get_sdk(self) -> Any: # MiroApi + """Return the underlying ``MiroApi`` SDK instance.""" + return self.client.get_sdk() + + @classmethod + def build_with_config( + cls, + config: MiroOAuthConfig | MiroTokenConfig, + ) -> "MiroClient": + """Build MiroClient with configuration. + + Args: + config: MiroOAuthConfig or MiroTokenConfig instance. + + Returns: + MiroClient instance. + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "MiroClient": + """Build MiroClient using configuration service. + + Supports two authentication strategies: + 1. OAUTH: OAuth 2.0 authorization code flow with access token + 2. TOKEN: Pre-generated access token + + Args: + logger: Logger instance. + config_service: Configuration service instance. + connector_instance_id: Optional connector instance ID. + + Returns: + MiroClient instance. + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get Miro connector configuration" + ) + + connector_config = MiroConnectorConfig.model_validate( + raw_config + ) + + if connector_config.auth.authType == MiroAuthType.OAUTH: + access_token = ( + connector_config.credentials.access_token or "" + ) + client_id = connector_config.auth.clientId or "" + client_secret = connector_config.auth.clientSecret or "" + + # Try shared OAuth config if credentials are missing + oauth_config_id = connector_config.auth.oauthConfigId + if oauth_config_id and not (client_id and client_secret): + shared_cfg = await cls._find_shared_oauth_config( + config_service, oauth_config_id, logger + ) + if shared_cfg: + client_id = shared_cfg.resolved_client_id( + client_id + ) + client_secret = ( + shared_cfg.resolved_client_secret( + client_secret + ) + ) + + if not access_token: + raise ValueError( + "Access token required for OAuth auth type" + ) + + oauth_cfg = MiroOAuthConfig( + access_token=access_token, + client_id=client_id, + client_secret=client_secret, + ) + return cls(oauth_cfg.create_client()) + + elif connector_config.auth.authType == MiroAuthType.TOKEN: + token = connector_config.auth.token or "" + if not token: + raise ValueError( + "Token required for TOKEN auth type" + ) + + token_config = MiroTokenConfig(token=token) + return cls(token_config.create_client()) + + else: + raise ValueError( + f"Invalid auth type: {connector_config.auth.authType}" + ) + + except Exception as e: + logger.error( + "Failed to build Miro client from services: %s", str(e) + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "MiroClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict. + logger: Logger instance. + config_service: Optional configuration service for shared + OAuth config. + + Returns: + MiroClient instance. + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], + toolset_config.get("credentials", {}) or {}, + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], + toolset_config.get("auth", {}) or {}, + ) + + access_token: str = str( + credentials.get("access_token", "") + ) + if not access_token: + raise ValueError( + "Access token not found in toolset config" + ) + + client_id: str = str(auth_config.get("clientId", "")) + client_secret: str = str( + auth_config.get("clientSecret", "") + ) + + # Try shared OAuth config + oauth_config_id: str | None = cast( + str | None, auth_config.get("oauthConfigId") + ) + if ( + oauth_config_id + and config_service + and not (client_id and client_secret) + ): + shared_cfg = await cls._find_shared_oauth_config( + config_service, oauth_config_id, logger + ) + if shared_cfg: + client_id = shared_cfg.resolved_client_id(client_id) + client_secret = shared_cfg.resolved_client_secret( + client_secret + ) + + oauth_cfg = MiroOAuthConfig( + access_token=access_token, + client_id=client_id, + client_secret=client_secret, + ) + return cls(oauth_cfg.create_client()) + + except Exception as e: + logger.error( + "Failed to build Miro client from toolset: %s", str(e) + ) + raise + + @staticmethod + async def _find_shared_oauth_config( + config_service: ConfigurationService, + oauth_config_id: str, + logger: logging.Logger, + ) -> MiroSharedOAuthConfigEntry | None: + """Look up shared OAuth config by ID from the config store. + + Args: + config_service: Configuration service instance. + oauth_config_id: The shared OAuth config ID to match. + logger: Logger instance. + + Returns: + Matched MiroSharedOAuthConfigEntry or None. + """ + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/miro", default=[] + ) + entries: list[object] = ( + list(raw) if isinstance(raw, list) else [] # type: ignore[reportUnknownArgumentType] + ) + for entry in entries: + wrapper = MiroSharedOAuthWrapper.model_validate(entry) + if wrapper.entry_id == oauth_config_id: + return wrapper.config + except Exception as e: + logger.warning("Failed to fetch shared OAuth config: %s", e) + return None + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Miro.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get Miro connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error( + "Failed to get Miro connector config: %s", e + ) + raise ValueError( + f"Failed to get Miro connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/netsuite/netsuite.py b/backend/python/app/sources/client/netsuite/netsuite.py new file mode 100644 index 000000000..9acf9d294 --- /dev/null +++ b/backend/python/app/sources/client/netsuite/netsuite.py @@ -0,0 +1,370 @@ +"""NetSuite client implementation. + +This module provides a client for interacting with the NetSuite SuiteTalk +REST API using a pre-generated Bearer token (Token-Based Authentication). + +NetSuite SuiteTalk REST supports OAuth 1.0 TBA and OAuth 2.0. For simplicity, +this client accepts a pre-generated Bearer token. + +Authentication Reference: https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_157373248498.html +API Reference: https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/chapter_1540391670.html +""" + +import base64 +import json +import logging +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class NetSuiteResponse(BaseModel): + """Standardized NetSuite API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client class +# --------------------------------------------------------------------------- + + +class NetSuiteRESTClientViaToken(HTTPClient): + """NetSuite REST client via pre-generated Bearer token. + + Uses a pre-generated Bearer token for authentication against the + NetSuite SuiteTalk REST API. + + Args: + token: The pre-generated Bearer token + account_id: NetSuite account ID (e.g. "1234567" or "1234567_SB1") + """ + + def __init__( + self, + token: str, + account_id: str, + ) -> None: + super().__init__(token, token_type="Bearer") + self.account_id = account_id + self.base_url = ( + f"https://{account_id}.suitetalk.api.netsuite.com/services/rest" + ) + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class NetSuiteTokenConfig(BaseModel): + """Configuration for NetSuite client via pre-generated Bearer token. + + Args: + token: The pre-generated Bearer token + account_id: NetSuite account ID (e.g. "1234567" or "1234567_SB1") + """ + + token: str + account_id: str + + def create_client(self) -> NetSuiteRESTClientViaToken: + return NetSuiteRESTClientViaToken(self.token, self.account_id) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class NetSuiteAuthConfig(BaseModel): + """Auth section of the NetSuite connector configuration from etcd.""" + + accountId: str | None = None + token: str | None = None + oauthConfigId: str | None = None + + class Config: + extra = "allow" + + +class NetSuiteCredentialsConfig(BaseModel): + """Credentials section of the NetSuite connector configuration.""" + + access_token: str | None = None + + class Config: + extra = "allow" + + +class NetSuiteConnectorConfig(BaseModel): + """Top-level NetSuite connector configuration from etcd.""" + + auth: NetSuiteAuthConfig = Field(default_factory=NetSuiteAuthConfig) + credentials: NetSuiteCredentialsConfig = Field( + default_factory=NetSuiteCredentialsConfig + ) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class NetSuiteClient(IClient): + """Builder class for NetSuite clients. + + Supports: + - Pre-generated Bearer token authentication (Token-Based Auth) + """ + + def __init__( + self, + client: NetSuiteRESTClientViaToken, + ) -> None: + """Initialize with a NetSuite client object.""" + super().__init__() + self.client = client + + @override + def get_client(self) -> NetSuiteRESTClientViaToken: + """Return the NetSuite client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: NetSuiteTokenConfig, + ) -> "NetSuiteClient": + """Build NetSuiteClient with configuration. + + Args: + config: NetSuiteTokenConfig instance + + Returns: + NetSuiteClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "NetSuiteClient": + """Build NetSuiteClient using configuration service. + + Uses a pre-generated Bearer token and account_id from the + connector configuration. + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + NetSuiteClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get NetSuite connector configuration" + ) + + connector_config = NetSuiteConnectorConfig.model_validate( + raw_config + ) + + account_id = connector_config.auth.accountId or "" + token = ( + connector_config.credentials.access_token + or connector_config.auth.token + or "" + ) + + # Try shared OAuth config if credentials are missing + oauth_config_id = connector_config.auth.oauthConfigId + if oauth_config_id and not token: + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/netsuite", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + account_id = str( + shared.get("accountId") + or shared.get("account_id") + or account_id + ) + token = str( + shared.get("token") + or shared.get("access_token") + or token + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + if not (account_id and token): + raise ValueError( + "account_id and token are required for NetSuite auth" + ) + + token_config = NetSuiteTokenConfig( + token=token, + account_id=account_id, + ) + return cls(token_config.create_client()) + + except Exception as e: + logger.error( + f"Failed to build NetSuite client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "NetSuiteClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service for shared config + + Returns: + NetSuiteClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("credentials", {}) or {} + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + account_id: str = str(auth_config.get("accountId", "")) + token: str = str( + credentials.get("access_token", "") + or auth_config.get("token", "") + ) + + if not (account_id and token): + raise ValueError( + "account_id and token are required in toolset config " + "for NetSuite" + ) + + token_config = NetSuiteTokenConfig( + token=token, + account_id=account_id, + ) + return cls(token_config.create_client()) + + except Exception as e: + logger.error( + f"Failed to build NetSuite client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for NetSuite.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get NetSuite connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error(f"Failed to get NetSuite connector config: {e}") + raise ValueError( + f"Failed to get NetSuite connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/newrelic/graphql_op.py b/backend/python/app/sources/client/newrelic/graphql_op.py new file mode 100644 index 000000000..882ef38a0 --- /dev/null +++ b/backend/python/app/sources/client/newrelic/graphql_op.py @@ -0,0 +1,311 @@ +"""Registry of NewRelic NerdGraph GraphQL operations and fragments. + +NewRelic NerdGraph API: https://api.newrelic.com/graphiql +Documentation: https://docs.newrelic.com/docs/apis/nerdgraph/get-started/introduction-new-relic-nerdgraph/ +""" + +from typing import Any + + +class NewRelicGraphQLOperations: + """Registry of NewRelic NerdGraph GraphQL operations and fragments.""" + + # Common fragments + FRAGMENTS: dict[str, str] = { + "EntityFields": """ + fragment EntityFields on EntityOutline { + guid + name + type + entityType + domain + accountId + reporting + tags { + key + values + } + } + """, + "AccountFields": """ + fragment AccountFields on AccountOutline { + id + name + reportingEventTypes + } + """, + } + + # Query operations + QUERIES: dict[str, dict[str, Any]] = { + "list_accounts": { + "query": """ + query listAccounts { + actor { + accounts { + ...AccountFields + } + } + } + """, + "fragments": ["AccountFields"], + "description": "List all accessible accounts", + }, + "get_account": { + "query": """ + query getAccount($accountId: Int!) { + actor { + account(id: $accountId) { + id + name + reportingEventTypes + } + } + } + """, + "fragments": [], + "description": "Get a specific account by ID", + }, + "nrql_query": { + "query": """ + query nrqlQuery($accountId: Int!, $nrqlQuery: Nrql!) { + actor { + account(id: $accountId) { + nrql(query: $nrqlQuery) { + results + } + } + } + } + """, + "fragments": [], + "description": "Execute a NRQL query against an account", + }, + "list_entities": { + "query": """ + query listEntities($queryString: String, $entityTypes: [EntitySearchQueryBuilderType!]) { + actor { + entitySearch(query: $queryString, queryBuilder: {type: $entityTypes}) { + count + results { + entities { + ...EntityFields + } + nextCursor + } + } + } + } + """, + "fragments": ["EntityFields"], + "description": "Search for entities with optional filters", + }, + "get_entity": { + "query": """ + query getEntity($guid: EntityGuid!) { + actor { + entity(guid: $guid) { + guid + name + type + entityType + domain + accountId + reporting + tags { + key + values + } + ... on AlertableEntity { + alertSeverity + recentAlertViolations(count: 5) { + alertSeverity + label + openedAt + closedAt + violationUrl + } + } + } + } + } + """, + "fragments": [], + "description": "Get a specific entity by GUID", + }, + "list_dashboards": { + "query": """ + query listDashboards { + actor { + entitySearch(queryBuilder: {type: DASHBOARD}) { + count + results { + entities { + guid + name + accountId + tags { + key + values + } + ... on DashboardEntityOutline { + dashboardParentGuid + owner { + email + userId + } + } + } + nextCursor + } + } + } + } + """, + "fragments": [], + "description": "List all dashboards", + }, + "list_alert_policies": { + "query": """ + query listAlertPolicies($accountId: Int!, $cursor: String) { + actor { + account(id: $accountId) { + alerts { + policiesSearch(cursor: $cursor) { + nextCursor + totalCount + policies { + id + name + incidentPreference + accountId + } + } + } + } + } + } + """, + "fragments": [], + "description": "List alert policies for an account", + }, + "list_synthetics_monitors": { + "query": """ + query listSyntheticsMonitors { + actor { + entitySearch(queryBuilder: {type: SYNTHETIC_MONITOR}) { + count + results { + entities { + guid + name + accountId + tags { + key + values + } + ... on SyntheticMonitorEntityOutline { + monitorType + monitoredUrl + period + monitorSummary { + locationsFailing + locationsRunning + status + successRate + } + } + } + nextCursor + } + } + } + } + """, + "fragments": [], + "description": "List synthetics monitors", + }, + "get_application": { + "query": """ + query getApplication($guid: EntityGuid!) { + actor { + entity(guid: $guid) { + ... on ApmApplicationEntity { + guid + name + accountId + language + runningAgentVersions { + maxVersion + minVersion + } + settings { + apdexTarget + serverSideConfig + } + apmSummary { + apdexScore + errorRate + hostCount + instanceCount + responseTimeAverage + throughput + webResponseTimeAverage + webThroughput + } + tags { + key + values + } + } + } + } + } + """, + "fragments": [], + "description": "Get APM application details by GUID", + }, + } + + # Mutation operations + MUTATIONS: dict[str, dict[str, Any]] = {} + + @classmethod + def get_operation_with_fragments( + cls, operation_type: str, operation_name: str + ) -> str: + """Get a complete GraphQL operation with all required fragments.""" + operations = cls.QUERIES if operation_type == "query" else cls.MUTATIONS + + if operation_name not in operations: + raise ValueError( + f"Operation {operation_name} not found in {operation_type}s" + ) + operation = operations[operation_name] + fragments_needed = operation.get("fragments", []) + + # Collect all fragments (deduplicate while preserving order) + seen: set[str] = set() + fragment_definitions: list[str] = [] + for fragment_name in fragments_needed: + if fragment_name in cls.FRAGMENTS and fragment_name not in seen: + fragment_definitions.append(cls.FRAGMENTS[fragment_name]) + seen.add(fragment_name) + + # Combine fragments and operation + if fragment_definitions: + return ( + "\n\n".join(fragment_definitions) + + "\n\n" + + operation["query"] + ) + return str(operation["query"]) + + @classmethod + def get_all_operations(cls) -> dict[str, dict[str, Any]]: + """Get all available operations.""" + return { + "queries": cls.QUERIES, + "mutations": cls.MUTATIONS, + "fragments": cls.FRAGMENTS, + } diff --git a/backend/python/app/sources/client/newrelic/newrelic.py b/backend/python/app/sources/client/newrelic/newrelic.py new file mode 100644 index 000000000..f28f320e6 --- /dev/null +++ b/backend/python/app/sources/client/newrelic/newrelic.py @@ -0,0 +1,258 @@ +"""NewRelic client implementation. + +This module provides a client for interacting with the NewRelic NerdGraph +(GraphQL) API using the existing GraphQL client base. + +NewRelic uses NerdGraph as its primary API: +- Endpoint: https://api.newrelic.com/graphql +- Authentication: Api-Key header (NOT Bearer token) + +Authentication Reference: https://docs.newrelic.com/docs/apis/intro-apis/new-relic-api-keys/ +NerdGraph Reference: https://docs.newrelic.com/docs/apis/nerdgraph/get-started/introduction-new-relic-nerdgraph/ +""" + +import logging +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.graphql.client import GraphQLClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Default endpoint +# --------------------------------------------------------------------------- + +NEWRELIC_GRAPHQL_ENDPOINT = "https://api.newrelic.com/graphql" + +# --------------------------------------------------------------------------- +# GraphQL client class +# --------------------------------------------------------------------------- + + +class NewRelicGraphQLClientViaApiKey(GraphQLClient): + """NewRelic NerdGraph client via API key. + + NewRelic uses a custom header `Api-Key` for authentication (not + the standard Authorization: Bearer pattern). + + Args: + api_key: NewRelic API key (e.g., NRAK-XXXXX) + endpoint: NerdGraph endpoint URL + timeout: Request timeout in seconds + """ + + def __init__( + self, + api_key: str, + endpoint: str = NEWRELIC_GRAPHQL_ENDPOINT, + timeout: int = 30, + ) -> None: + api_key = api_key.strip() if api_key else "" + if not api_key: + raise ValueError("NewRelic API key cannot be empty") + + headers = { + "Api-Key": api_key, + "Content-Type": "application/json", + } + super().__init__( + endpoint=endpoint, + headers=headers, + timeout=timeout, + ) + self.api_key = api_key + + def get_endpoint(self) -> str: + """Get the GraphQL endpoint.""" + return self.endpoint + + @override + def get_auth_header(self) -> str | None: + """Get the authorization header value. + + NewRelic uses Api-Key header, not Authorization. + This returns the Api-Key value for reference. + """ + return f"Api-Key {self.api_key}" + + def get_api_key(self) -> str: + """Get the API key.""" + return self.api_key + + def set_api_key(self, api_key: str) -> None: + """Set the API key and update the Api-Key header.""" + self.api_key = api_key + self.headers["Api-Key"] = api_key + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class NewRelicApiKeyConfig(BaseModel): + """Configuration for NewRelic NerdGraph client via API key. + + Args: + api_key: NewRelic API key (NRAK-XXXXX) + endpoint: NerdGraph endpoint URL + timeout: Request timeout in seconds + """ + + api_key: str = Field(..., description="NewRelic API key (NRAK-XXXXX)") + endpoint: str = Field( + default=NEWRELIC_GRAPHQL_ENDPOINT, + description="NerdGraph endpoint URL", + ) + timeout: int = Field( + default=30, description="Request timeout in seconds", gt=0 + ) + + def create_client(self) -> NewRelicGraphQLClientViaApiKey: + """Create a NewRelic NerdGraph client.""" + return NewRelicGraphQLClientViaApiKey( + api_key=self.api_key, + endpoint=self.endpoint, + timeout=self.timeout, + ) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class NewRelicAuthConfig(BaseModel): + """Auth section of the NewRelic connector configuration from etcd.""" + + authType: str = "API_KEY" + apiKey: str | None = None + + class Config: + extra = "allow" + + +class NewRelicConnectorConfig(BaseModel): + """Top-level NewRelic connector configuration from etcd.""" + + auth: NewRelicAuthConfig = Field(default_factory=NewRelicAuthConfig) + timeout: int = 30 + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class NewRelicClient(IClient): + """Builder class for NewRelic NerdGraph clients. + + NewRelic only supports API key authentication for NerdGraph. + """ + + def __init__( + self, + client: NewRelicGraphQLClientViaApiKey, + ) -> None: + """Initialize with a NewRelic NerdGraph client object.""" + super().__init__() + self.client = client + + @override + def get_client(self) -> NewRelicGraphQLClientViaApiKey: + """Return the NewRelic NerdGraph client object.""" + return self.client + + @classmethod + def build_with_config( + cls, + config: NewRelicApiKeyConfig, + ) -> "NewRelicClient": + """Build NewRelicClient with configuration. + + Args: + config: NewRelicApiKeyConfig instance + + Returns: + NewRelicClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "NewRelicClient": + """Build NewRelicClient using configuration service. + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + NewRelicClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get NewRelic connector configuration" + ) + + connector_config = NewRelicConnectorConfig.model_validate( + raw_config + ) + + api_key = connector_config.auth.apiKey or "" + if not api_key: + raise ValueError( + "API key required for NewRelic authentication" + ) + + timeout = connector_config.timeout + + client = NewRelicGraphQLClientViaApiKey( + api_key=api_key, timeout=timeout + ) + return cls(client) + + except Exception as e: + logger.error( + f"Failed to build NewRelic client from services: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for NewRelic.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get NewRelic connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error(f"Failed to get NewRelic connector config: {e}") + raise ValueError( + f"Failed to get NewRelic connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/nicecxone/nicecxone.py b/backend/python/app/sources/client/nicecxone/nicecxone.py new file mode 100644 index 000000000..f63233851 --- /dev/null +++ b/backend/python/app/sources/client/nicecxone/nicecxone.py @@ -0,0 +1,520 @@ +"""NICE CXone client implementation. + +This module provides clients for interacting with the NICE CXone API using either: +1. OAuth 2.0 authentication (client_credentials or authorization code) +2. Pre-generated Bearer token authentication + +The base URL includes a cluster parameter, as NICE CXone APIs are cluster-specific: +https://api-{cluster}.niceincontact.com/incontactapi/services/v31.0 + +Authentication Reference: https://developer.niceincontact.com/Documentation/Authentication +API Reference: https://developer.niceincontact.com/API/ +""" + +import base64 +import json +import logging +from enum import Enum +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.http.http_request import HTTPRequest +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class NiceCXoneAuthType(str, Enum): + """Authentication types supported by the NICE CXone connector.""" + + OAUTH = "OAUTH" + TOKEN = "TOKEN" + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class NiceCXoneResponse(BaseModel): + """Standardized NICE CXone API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client classes +# --------------------------------------------------------------------------- + + +class NiceCXoneRESTClientViaOAuth(HTTPClient): + """NICE CXone REST client via OAuth 2.0. + + Uses client_credentials grant type to obtain an access token from the + NICE CXone OAuth token endpoint. The token is fetched automatically + on first use via ensure_authenticated(). + + Args: + client_id: OAuth client ID + client_secret: OAuth client secret + auth_domain: The authentication domain (e.g., cxone.niceincontact.com) + cluster: The cluster identifier for the API base URL + """ + + def __init__( + self, + client_id: str, + client_secret: str, + auth_domain: str, + cluster: str = "c1", + ) -> None: + super().__init__("", token_type="Bearer") + self.cluster = cluster + self.base_url = ( + f"https://api-{cluster}.niceincontact.com" + f"/incontactapi/services/v31.0" + ) + self.client_id = client_id + self.client_secret = client_secret + self.auth_domain = auth_domain + self._authenticated = False + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + async def ensure_authenticated(self) -> None: + """Fetch an access token via client_credentials grant. + + Posts to the NICE CXone token endpoint with grant_type=client_credentials. + """ + if self._authenticated: + return + + token_request = HTTPRequest( + url=f"https://{self.auth_domain}/auth/token", + method="POST", + headers={ + "Content-Type": "application/x-www-form-urlencoded", + }, + body={ + "grant_type": "client_credentials", + "client_id": self.client_id, + "client_secret": self.client_secret, + }, + ) + + response = await self.execute(token_request) # type: ignore[reportUnknownMemberType] + response_data = response.json() + + access_token = response_data.get("access_token") + if not access_token: + raise ValueError( + "Failed to obtain access token from NICE CXone OAuth: " + f"{response_data}" + ) + + self.headers["Authorization"] = f"Bearer {access_token}" + self._authenticated = True + + +class NiceCXoneRESTClientViaToken(HTTPClient): + """NICE CXone REST client via pre-generated Bearer token. + + Simple authentication using a pre-generated token passed directly + in the Authorization header. + + Args: + token: The pre-generated Bearer token + cluster: The cluster identifier for the API base URL + """ + + def __init__( + self, + token: str, + cluster: str = "c1", + ) -> None: + super().__init__(token, token_type="Bearer") + self.cluster = cluster + self.base_url = ( + f"https://api-{cluster}.niceincontact.com" + f"/incontactapi/services/v31.0" + ) + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class NiceCXoneOAuthConfig(BaseModel): + """Configuration for NICE CXone client via OAuth 2.0. + + Args: + client_id: OAuth client ID + client_secret: OAuth client secret + auth_domain: The authentication domain + cluster: The cluster identifier (default: "c1") + """ + + client_id: str + client_secret: str + auth_domain: str + cluster: str = "c1" + + def create_client(self) -> NiceCXoneRESTClientViaOAuth: + return NiceCXoneRESTClientViaOAuth( + self.client_id, + self.client_secret, + self.auth_domain, + self.cluster, + ) + + +class NiceCXoneTokenConfig(BaseModel): + """Configuration for NICE CXone client via pre-generated Bearer token. + + Args: + token: The pre-generated Bearer token + cluster: The cluster identifier (default: "c1") + """ + + token: str + cluster: str = "c1" + + def create_client(self) -> NiceCXoneRESTClientViaToken: + return NiceCXoneRESTClientViaToken(self.token, self.cluster) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class NiceCXoneAuthConfig(BaseModel): + """Auth section of the NICE CXone connector configuration from etcd.""" + + authType: NiceCXoneAuthType = NiceCXoneAuthType.TOKEN + clientId: str | None = None + clientSecret: str | None = None + authDomain: str | None = None + token: str | None = None + cluster: str | None = None + oauthConfigId: str | None = None + + class Config: + extra = "allow" + + +class NiceCXoneCredentialsConfig(BaseModel): + """Credentials section of the NICE CXone connector configuration.""" + + access_token: str | None = None + refresh_token: str | None = None + + class Config: + extra = "allow" + + +class NiceCXoneConnectorConfig(BaseModel): + """Top-level NICE CXone connector configuration from etcd.""" + + auth: NiceCXoneAuthConfig = Field(default_factory=NiceCXoneAuthConfig) + credentials: NiceCXoneCredentialsConfig = Field( + default_factory=NiceCXoneCredentialsConfig + ) + cluster: str = "c1" + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class NiceCXoneClient(IClient): + """Builder class for NICE CXone clients with different auth methods. + + Supports: + - OAuth 2.0 client_credentials grant + - Pre-generated Bearer token + """ + + def __init__( + self, + client: NiceCXoneRESTClientViaOAuth | NiceCXoneRESTClientViaToken, + ) -> None: + """Initialize with a NICE CXone client object.""" + super().__init__() + self.client = client + + @override + def get_client( + self, + ) -> NiceCXoneRESTClientViaOAuth | NiceCXoneRESTClientViaToken: + """Return the NICE CXone client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: NiceCXoneOAuthConfig | NiceCXoneTokenConfig, + ) -> "NiceCXoneClient": + """Build NiceCXoneClient with configuration. + + Args: + config: NiceCXoneOAuthConfig or NiceCXoneTokenConfig instance + + Returns: + NiceCXoneClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "NiceCXoneClient": + """Build NiceCXoneClient using configuration service. + + Supports two authentication strategies: + 1. OAUTH: Client credentials grant with client_id, client_secret, + and auth_domain + 2. TOKEN: Pre-generated Bearer token + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + NiceCXoneClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get NICE CXone connector configuration" + ) + + connector_config = NiceCXoneConnectorConfig.model_validate( + raw_config + ) + + if connector_config.auth.authType == NiceCXoneAuthType.OAUTH: + client_id = connector_config.auth.clientId or "" + client_secret = connector_config.auth.clientSecret or "" + auth_domain = connector_config.auth.authDomain or "" + cluster = ( + connector_config.auth.cluster + or connector_config.cluster + ) + + # Try shared OAuth config if credentials are missing + oauth_config_id = connector_config.auth.oauthConfigId + if oauth_config_id and not (client_id and client_secret): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/nicecxone", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + auth_domain = str( + shared.get("authDomain") + or shared.get("auth_domain") + or auth_domain + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + if not (client_id and client_secret and auth_domain): + raise ValueError( + "client_id, client_secret, and auth_domain are " + "required for OAuth auth type" + ) + + oauth_cfg = NiceCXoneOAuthConfig( + client_id=client_id, + client_secret=client_secret, + auth_domain=auth_domain, + cluster=cluster, + ) + return cls(oauth_cfg.create_client()) + + elif connector_config.auth.authType == NiceCXoneAuthType.TOKEN: + token = connector_config.auth.token or "" + if not token: + # Fall back to credentials access_token + token = connector_config.credentials.access_token or "" + if not token: + raise ValueError( + "Token required for TOKEN auth type" + ) + + cluster = ( + connector_config.auth.cluster + or connector_config.cluster + ) + token_config = NiceCXoneTokenConfig( + token=token, cluster=cluster + ) + return cls(token_config.create_client()) + + else: + raise ValueError( + f"Invalid auth type: {connector_config.auth.authType}" + ) + + except Exception as e: + logger.error( + f"Failed to build NICE CXone client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "NiceCXoneClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service for shared OAuth + + Returns: + NiceCXoneClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("credentials", {}) or {} + ) + cluster: str = str(toolset_config.get("cluster", "c1")) + + access_token: str = str(credentials.get("access_token", "")) + if not access_token: + raise ValueError( + "Access token not found in toolset config" + ) + + token_config = NiceCXoneTokenConfig( + token=access_token, cluster=cluster + ) + return cls(token_config.create_client()) + + except Exception as e: + logger.error( + f"Failed to build NICE CXone client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for NICE CXone.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get NICE CXone connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error( + f"Failed to get NICE CXone connector config: {e}" + ) + raise ValueError( + f"Failed to get NICE CXone connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/okta/okta.py b/backend/python/app/sources/client/okta/okta.py new file mode 100644 index 000000000..248d01221 --- /dev/null +++ b/backend/python/app/sources/client/okta/okta.py @@ -0,0 +1,464 @@ +"""Okta client implementation using the official ``okta`` SDK. + +This module provides clients for interacting with the Okta API using either: +1. API Token (SSWS token) +2. OAuth 2.0 (authorization code flow) + +The SDK is initialised with the Okta org URL and a token; the underlying +``okta.client.Client`` object is exposed via ``get_sdk()``. + +SDK Reference: https://github.com/okta/okta-sdk-python +API Reference: https://developer.okta.com/docs/api/ +""" + +import logging +from enum import Enum +from typing import Any, cast + +from okta.client import Client as OktaSDKClient # type: ignore[reportMissingImports] +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class OktaAuthType(str, Enum): + """Authentication types supported by the Okta connector.""" + + OAUTH = "OAUTH" + API_TOKEN = "API_TOKEN" + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class OktaResponse(BaseModel): + """Standardised Okta API response wrapper.""" + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field(default=None, description="Response data from the SDK") + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + arbitrary_types_allowed = True + + def to_dict(self) -> dict[str, Any]: + """Convert response to dictionary.""" + return self.model_dump(exclude_none=True) + + +# --------------------------------------------------------------------------- +# SDK wrapper client +# --------------------------------------------------------------------------- + + +class OktaClientViaApiToken: + """Okta SDK client via API Token. + + Wraps the official ``okta`` SDK ``Client`` object. + + Args: + domain: Full Okta org URL (e.g. ``https://dev-123456.okta.com``) + api_token: The Okta API token + """ + + def __init__(self, domain: str, api_token: str) -> None: + super().__init__() + # Normalise: make sure domain is a full URL + if not domain.startswith("http"): + domain = f"https://{domain}.okta.com" + self.domain = domain.rstrip("/") + self.api_token = api_token + + self._sdk: Any = None # OktaSDKClient + + def create_client(self) -> Any: # OktaSDKClient + config = { + "orgUrl": self.domain, + "token": self.api_token, + } + self._sdk = OktaSDKClient(config) # type: ignore[reportUnknownVariableType] + return self._sdk # type: ignore[reportUnknownMemberType,reportUnknownVariableType] + + def get_sdk(self) -> Any: # OktaSDKClient + if self._sdk is None: + return self.create_client() + return self._sdk + + def get_base_url(self) -> str: + return self.domain + + def get_domain(self) -> str: + return self.domain + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class OktaApiTokenConfig(BaseModel): + """Configuration for Okta client via API Token. + + Args: + api_token: The Okta API token + domain: Okta domain (e.g. ``dev-123456`` or full URL) + """ + + api_token: str + domain: str + + def create_client(self) -> OktaClientViaApiToken: + return OktaClientViaApiToken(domain=self.domain, api_token=self.api_token) + + +class OktaOAuthConfig(BaseModel): + """Configuration for Okta client via OAuth 2.0. + + For OAuth, the access_token is passed as the API token to the SDK. + + Args: + access_token: The OAuth access token + domain: Okta domain + client_id: OAuth client ID + client_secret: OAuth client secret + redirect_uri: OAuth redirect URI + """ + + access_token: str + domain: str + client_id: str | None = None + client_secret: str | None = None + redirect_uri: str | None = None + + def create_client(self) -> OktaClientViaApiToken: + # The SDK accepts an access token in the same way as an API token + return OktaClientViaApiToken( + domain=self.domain, + api_token=self.access_token, + ) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class OktaAuthConfigModel(BaseModel): + """Auth section of the Okta connector configuration from etcd.""" + + authType: OktaAuthType = OktaAuthType.API_TOKEN + apiToken: str | None = None + token: str | None = None + domain: str | None = None + clientId: str | None = None + clientSecret: str | None = None + redirectUri: str | None = None + oauthConfigId: str | None = None + + class Config: + extra = "allow" + + +class OktaCredentialsConfig(BaseModel): + """Credentials section of the Okta connector configuration.""" + + access_token: str | None = None + refresh_token: str | None = None + + class Config: + extra = "allow" + + +class OktaConnectorConfig(BaseModel): + """Top-level Okta connector configuration from etcd.""" + + auth: OktaAuthConfigModel = Field(default_factory=OktaAuthConfigModel) + credentials: OktaCredentialsConfig = Field( + default_factory=OktaCredentialsConfig + ) + domain: str | None = None + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class OktaClient(IClient): + """Builder class for Okta clients with different authentication methods. + + Supports: + - OAuth 2.0 authorization code flow + - API Token (SSWS) authentication + """ + + def __init__(self, client: OktaClientViaApiToken) -> None: + super().__init__() + self.client = client + + @override + def get_client(self) -> OktaClientViaApiToken: + return self.client + + def get_sdk(self) -> Any: # OktaSDKClient + return self.client.get_sdk() + + def get_base_url(self) -> str: + return self.client.get_base_url() + + @property + def domain(self) -> str: + return self.client.get_domain() + + @classmethod + def build_with_config( + cls, + config: OktaOAuthConfig | OktaApiTokenConfig, + ) -> "OktaClient": + client = config.create_client() + client.get_sdk() + return cls(client) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "OktaClient": + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get Okta connector configuration" + ) + + connector_config = OktaConnectorConfig.model_validate(raw_config) + + okta_domain = ( + connector_config.auth.domain + or connector_config.domain + or "" + ) + if not okta_domain: + raise ValueError("Domain required for Okta") + + if connector_config.auth.authType == OktaAuthType.OAUTH: + access_token = connector_config.credentials.access_token or "" + client_id = connector_config.auth.clientId or "" + client_secret = connector_config.auth.clientSecret or "" + redirect_uri = connector_config.auth.redirectUri or "" + + oauth_config_id = connector_config.auth.oauthConfigId + if oauth_config_id and not (client_id and client_secret): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/okta", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + redirect_uri = str( + shared.get("redirectUri") + or shared.get("redirect_uri") + or redirect_uri + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + if not access_token: + raise ValueError( + "Access token required for OAuth auth type" + ) + + oauth_cfg = OktaOAuthConfig( + access_token=access_token, + domain=okta_domain, + client_id=client_id, + client_secret=client_secret, + redirect_uri=redirect_uri, + ) + return cls(oauth_cfg.create_client()) + + elif connector_config.auth.authType == OktaAuthType.API_TOKEN: + api_token = ( + connector_config.auth.apiToken + or connector_config.auth.token + or "" + ) + if not api_token: + raise ValueError( + "API token required for API_TOKEN auth type" + ) + + token_config = OktaApiTokenConfig( + api_token=api_token, domain=okta_domain + ) + return cls(token_config.create_client()) + + else: + raise ValueError( + f"Invalid auth type: {connector_config.auth.authType}" + ) + + except Exception as e: + logger.error( + f"Failed to build Okta client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "OktaClient": + try: + credentials: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("credentials", {}) or {} + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + okta_domain: str = str( + auth_config.get("domain", "") + or toolset_config.get("domain", "") + ) + if not okta_domain: + raise ValueError( + "Domain not found in toolset config" + ) + + access_token: str = str(credentials.get("access_token", "")) + if not access_token: + raise ValueError( + "Access token not found in toolset config" + ) + + client_id: str = str(auth_config.get("clientId", "")) + client_secret: str = str(auth_config.get("clientSecret", "")) + redirect_uri: str = str(auth_config.get("redirectUri", "")) + + oauth_config_id: str | None = cast( + str | None, auth_config.get("oauthConfigId") + ) + if ( + oauth_config_id + and config_service + and not (client_id and client_secret) + ): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/okta", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + redirect_uri = str( + shared.get("redirectUri") + or shared.get("redirect_uri") + or redirect_uri + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + oauth_cfg = OktaOAuthConfig( + access_token=access_token, + domain=okta_domain, + client_id=client_id, + client_secret=client_secret, + redirect_uri=redirect_uri, + ) + return cls(oauth_cfg.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Okta client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get Okta connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error(f"Failed to get Okta connector config: {e}") + raise ValueError( + f"Failed to get Okta connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/onelogin/onelogin.py b/backend/python/app/sources/client/onelogin/onelogin.py new file mode 100644 index 000000000..f57e7f595 --- /dev/null +++ b/backend/python/app/sources/client/onelogin/onelogin.py @@ -0,0 +1,391 @@ +"""OneLogin client implementation using the official ``onelogin`` SDK. + +This module provides a client for interacting with the OneLogin API using +OAuth2 client_credentials authentication via the official SDK. + +SDK Reference: https://github.com/onelogin/onelogin-python-sdk +API Reference: https://developers.onelogin.com/api-docs/2/getting-started/dev-overview +""" + +import logging +from enum import Enum +from typing import Any, cast + +import onelogin # type: ignore[reportMissingTypeStubs] +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class OneLoginAuthType(str, Enum): + """Authentication types supported by the OneLogin connector.""" + + CLIENT_CREDENTIALS = "CLIENT_CREDENTIALS" + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class OneLoginResponse(BaseModel): + """Standardised OneLogin API response wrapper.""" + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field(default=None, description="Response data from the SDK") + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + arbitrary_types_allowed = True + + def to_dict(self) -> dict[str, Any]: + """Convert response to dictionary.""" + return self.model_dump(exclude_none=True) + + +# --------------------------------------------------------------------------- +# SDK wrapper client +# --------------------------------------------------------------------------- + + +class OneLoginClientViaClientCredentials: + """OneLogin SDK client via OAuth2 client_credentials grant. + + Wraps the official ``onelogin`` SDK. Auto-fetches a token on first use. + + Args: + region: OneLogin region (e.g. ``us``, ``eu``) + client_id: OneLogin API client ID + client_secret: OneLogin API client secret + """ + + def __init__( + self, + region: str, + client_id: str, + client_secret: str, + ) -> None: + super().__init__() + self.region = region + self.client_id = client_id + self.client_secret = client_secret + + self._sdk: Any = None # onelogin.ApiClient + self._configuration: Any = None # onelogin.Configuration + + def create_client(self) -> Any: # onelogin.ApiClient + host = f"https://api.{self.region}.onelogin.com" + self._configuration = onelogin.Configuration( # type: ignore[reportAttributeAccessIssue] + host=host, + username=self.client_id, + password=self.client_secret, + ) + self._sdk = onelogin.ApiClient(self._configuration) # type: ignore[reportAttributeAccessIssue] + + # Auto-fetch token via client_credentials grant + token_api = onelogin.OAuth2Api(self._sdk) # type: ignore[reportAttributeAccessIssue] + response = token_api.generate_token( # type: ignore[reportUnknownMemberType] + onelogin.GenerateTokenRequest(grant_type="client_credentials") # type: ignore[reportAttributeAccessIssue,arg-type] + ) + if self._configuration is not None: # type: ignore[reportUnknownMemberType] + self._configuration.access_token = response.access_token # type: ignore[reportUnknownMemberType] + + return self._sdk # type: ignore[reportUnknownMemberType,reportUnknownVariableType] + + def get_sdk(self) -> Any: # onelogin.ApiClient + if self._sdk is None: + return self.create_client() + return self._sdk + + def get_base_url(self) -> str: + return f"https://api.{self.region}.onelogin.com" + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class OneLoginClientCredentialsConfig(BaseModel): + """Configuration for OneLogin client via client_credentials. + + Args: + client_id: OneLogin API client ID + client_secret: OneLogin API client secret + region: OneLogin region (e.g. ``us``, ``eu``) + """ + + client_id: str + client_secret: str + region: str = "us" + + def create_client(self) -> OneLoginClientViaClientCredentials: + return OneLoginClientViaClientCredentials( + region=self.region, + client_id=self.client_id, + client_secret=self.client_secret, + ) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class OneLoginAuthConfig(BaseModel): + """Auth section of the OneLogin connector configuration from etcd.""" + + authType: OneLoginAuthType = OneLoginAuthType.CLIENT_CREDENTIALS + clientId: str | None = None + clientSecret: str | None = None + region: str | None = None + oauthConfigId: str | None = None + + class Config: + extra = "allow" + + +class OneLoginCredentialsConfig(BaseModel): + """Credentials section of the OneLogin connector configuration.""" + + access_token: str | None = None + + class Config: + extra = "allow" + + +class OneLoginConnectorConfig(BaseModel): + """Top-level OneLogin connector configuration from etcd.""" + + auth: OneLoginAuthConfig = Field(default_factory=OneLoginAuthConfig) + credentials: OneLoginCredentialsConfig = Field( + default_factory=OneLoginCredentialsConfig + ) + region: str = "us" + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class OneLoginClient(IClient): + """Builder class for OneLogin clients. + + Supports: + - OAuth2 client_credentials grant authentication + """ + + def __init__(self, client: OneLoginClientViaClientCredentials) -> None: + super().__init__() + self.client = client + + @override + def get_client(self) -> OneLoginClientViaClientCredentials: + return self.client + + def get_sdk(self) -> Any: # onelogin.ApiClient + return self.client.get_sdk() + + def get_base_url(self) -> str: + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: OneLoginClientCredentialsConfig, + ) -> "OneLoginClient": + client = config.create_client() + client.get_sdk() + return cls(client) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "OneLoginClient": + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get OneLogin connector configuration" + ) + + connector_config = OneLoginConnectorConfig.model_validate( + raw_config + ) + + client_id = connector_config.auth.clientId or "" + client_secret = connector_config.auth.clientSecret or "" + region = ( + connector_config.auth.region + or connector_config.region + or "us" + ) + + oauth_config_id = connector_config.auth.oauthConfigId + if oauth_config_id and not (client_id and client_secret): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/onelogin", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + if not (client_id and client_secret): + raise ValueError( + "client_id and client_secret are required " + "for OneLogin authentication" + ) + + cc_config = OneLoginClientCredentialsConfig( + client_id=client_id, + client_secret=client_secret, + region=region, + ) + return cls(cc_config.create_client()) + + except Exception as e: + logger.error( + f"Failed to build OneLogin client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "OneLoginClient": + try: + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + client_id: str = str(auth_config.get("clientId", "")) + client_secret: str = str(auth_config.get("clientSecret", "")) + region: str = str(auth_config.get("region", "us")) + + oauth_config_id: str | None = cast( + str | None, auth_config.get("oauthConfigId") + ) + if oauth_config_id and config_service and not ( + client_id and client_secret + ): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/onelogin", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + if not (client_id and client_secret): + raise ValueError( + "client_id and client_secret not found in toolset config" + ) + + cc_config = OneLoginClientCredentialsConfig( + client_id=client_id, + client_secret=client_secret, + region=region, + ) + return cls(cc_config.create_client()) + + except Exception as e: + logger.error( + f"Failed to build OneLogin client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get OneLogin connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error( + f"Failed to get OneLogin connector config: {e}" + ) + raise ValueError( + f"Failed to get OneLogin connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/onetrust/onetrust.py b/backend/python/app/sources/client/onetrust/onetrust.py new file mode 100644 index 000000000..b20736ed0 --- /dev/null +++ b/backend/python/app/sources/client/onetrust/onetrust.py @@ -0,0 +1,458 @@ +"""OneTrust client implementation. + +This module provides clients for interacting with the OneTrust API using either: +1. OAuth 2.0 client_credentials flow +2. Bearer Token authentication + +Token Endpoint: https://{hostname}/api/access/v1/oauth/token +API Base URL: https://{hostname}/api +""" + +import base64 +import json +import logging +from enum import Enum +from typing import Any, cast + +import httpx # type: ignore +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class OneTrustAuthType(str, Enum): + """Authentication types supported by the OneTrust connector.""" + + OAUTH = "OAUTH" + TOKEN = "TOKEN" + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class OneTrustResponse(BaseModel): + """Standardized OneTrust API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client classes +# --------------------------------------------------------------------------- + + +class OneTrustRESTClientViaOAuth(HTTPClient): + """OneTrust REST client via OAuth 2.0 client_credentials. + + Automatically fetches an access token from the OneTrust token endpoint. + + Args: + client_id: OAuth client ID + client_secret: OAuth client secret + hostname: The OneTrust hostname (e.g. 'mycompany.onetrust.com') + """ + + def __init__( + self, + client_id: str, + client_secret: str, + hostname: str, + ) -> None: + super().__init__("", token_type="Bearer") + self.base_url = f"https://{hostname}/api" + self.hostname = hostname + self.client_id = client_id + self.client_secret = client_secret + self.token_endpoint = ( + f"https://{hostname}/api/access/v1/oauth/token" + ) + self._access_token: str | None = None + self.headers["Content-Type"] = "application/json" + + async def _fetch_token(self) -> str: + """Fetch an access token using client_credentials grant. + + Returns: + Access token string. + """ + async with httpx.AsyncClient() as client: # type: ignore[reportUnknownMemberType] + response = await client.post( # type: ignore[reportUnknownMemberType] + self.token_endpoint, + data={ + "grant_type": "client_credentials", + "client_id": self.client_id, + "client_secret": self.client_secret, + }, + headers={ + "Content-Type": "application/x-www-form-urlencoded", + }, + ) + response.raise_for_status() # type: ignore[reportUnknownMemberType] + token_data: dict[str, Any] = response.json() # type: ignore[reportUnknownMemberType] + access_token: str = str(token_data.get("access_token", "")) # type: ignore[reportUnknownMemberType, reportUnknownArgumentType] + if not access_token: + raise ValueError("No access_token in token response") + return access_token + + async def ensure_token(self) -> None: + """Ensure a valid access token is set in headers.""" + if not self._access_token: + self._access_token = await self._fetch_token() + self.headers["Authorization"] = f"Bearer {self._access_token}" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +class OneTrustRESTClientViaToken(HTTPClient): + """OneTrust REST client via Bearer Token. + + Args: + token: The bearer token + hostname: The OneTrust hostname (e.g. 'mycompany.onetrust.com') + """ + + def __init__(self, token: str, hostname: str) -> None: + super().__init__(token, "Bearer") + self.base_url = f"https://{hostname}/api" + self.hostname = hostname + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class OneTrustOAuthConfig(BaseModel): + """Configuration for OneTrust client via OAuth 2.0 client_credentials. + + Args: + client_id: OAuth client ID + client_secret: OAuth client secret + hostname: The OneTrust hostname + """ + + client_id: str + client_secret: str + hostname: str + + def create_client(self) -> OneTrustRESTClientViaOAuth: + return OneTrustRESTClientViaOAuth( + self.client_id, + self.client_secret, + self.hostname, + ) + + +class OneTrustTokenConfig(BaseModel): + """Configuration for OneTrust client via Bearer Token. + + Args: + token: The bearer token + hostname: The OneTrust hostname + """ + + token: str + hostname: str + + def create_client(self) -> OneTrustRESTClientViaToken: + return OneTrustRESTClientViaToken(self.token, self.hostname) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class OneTrustAuthConfigModel(BaseModel): + """Auth section of the OneTrust connector configuration from etcd.""" + + authType: OneTrustAuthType = OneTrustAuthType.OAUTH + apiToken: str | None = None + token: str | None = None + clientId: str | None = None + clientSecret: str | None = None + hostname: str | None = None + + class Config: + extra = "allow" + + +class OneTrustConnectorConfig(BaseModel): + """Top-level OneTrust connector configuration from etcd.""" + + auth: OneTrustAuthConfigModel = Field( + default_factory=OneTrustAuthConfigModel + ) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class OneTrustClient(IClient): + """Builder class for OneTrust clients with different authentication methods. + + Supports: + - OAuth 2.0 client_credentials flow + - Bearer Token authentication + """ + + def __init__( + self, + client: OneTrustRESTClientViaOAuth | OneTrustRESTClientViaToken, + ) -> None: + """Initialize with a OneTrust client object.""" + super().__init__() + self.client = client + + @override + def get_client( + self, + ) -> OneTrustRESTClientViaOAuth | OneTrustRESTClientViaToken: + """Return the OneTrust client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: OneTrustOAuthConfig | OneTrustTokenConfig, + ) -> "OneTrustClient": + """Build OneTrustClient with configuration. + + Args: + config: OneTrustOAuthConfig or OneTrustTokenConfig instance + + Returns: + OneTrustClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "OneTrustClient": + """Build OneTrustClient using configuration service. + + Supports two authentication strategies: + 1. OAUTH: OAuth 2.0 client_credentials + 2. TOKEN: Bearer token + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + OneTrustClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get OneTrust connector configuration" + ) + + connector_config = OneTrustConnectorConfig.model_validate( + raw_config + ) + + hostname = connector_config.auth.hostname or "" + if not hostname: + raise ValueError("OneTrust hostname is required") + + if connector_config.auth.authType == OneTrustAuthType.OAUTH: + client_id = connector_config.auth.clientId or "" + client_secret = connector_config.auth.clientSecret or "" + + if not (client_id and client_secret): + raise ValueError( + "client_id and client_secret required for OAuth auth type" + ) + + oauth_config = OneTrustOAuthConfig( + client_id=client_id, + client_secret=client_secret, + hostname=hostname, + ) + return cls(oauth_config.create_client()) + + elif connector_config.auth.authType == OneTrustAuthType.TOKEN: + token = ( + connector_config.auth.apiToken + or connector_config.auth.token + or "" + ) + if not token: + raise ValueError( + "Token required for TOKEN auth type" + ) + + token_config = OneTrustTokenConfig( + token=token, + hostname=hostname, + ) + return cls(token_config.create_client()) + + else: + raise ValueError( + f"Invalid auth type: {connector_config.auth.authType}" + ) + + except Exception as e: + logger.error( + f"Failed to build OneTrust client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "OneTrustClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service (unused) + + Returns: + OneTrustClient instance + """ + try: + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + hostname: str = str(auth_config.get("hostname", "")) + if not hostname: + raise ValueError( + "OneTrust hostname not found in toolset config" + ) + + auth_type = str(auth_config.get("authType", "OAUTH")) + + if auth_type == "TOKEN": + token: str = str(auth_config.get("token", "")) + if not token: + raise ValueError( + "Token not found in toolset config" + ) + config: OneTrustOAuthConfig | OneTrustTokenConfig = ( + OneTrustTokenConfig(token=token, hostname=hostname) + ) + else: + client_id: str = str(auth_config.get("clientId", "")) + client_secret: str = str( + auth_config.get("clientSecret", "") + ) + if not (client_id and client_secret): + raise ValueError( + "client_id and client_secret not found in toolset config" + ) + config = OneTrustOAuthConfig( + client_id=client_id, + client_secret=client_secret, + hostname=hostname, + ) + + return cls(config.create_client()) + + except Exception as e: + logger.error( + f"Failed to build OneTrust client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for OneTrust.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get OneTrust connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error(f"Failed to get OneTrust connector config: {e}") + raise ValueError( + f"Failed to get OneTrust connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/opsgenie/opsgenie.py b/backend/python/app/sources/client/opsgenie/opsgenie.py new file mode 100644 index 000000000..f9d530428 --- /dev/null +++ b/backend/python/app/sources/client/opsgenie/opsgenie.py @@ -0,0 +1,202 @@ +"""Opsgenie client implementation using the official ``opsgenie-sdk`` package. + +This module provides a client for interacting with the Opsgenie API using +API Key authentication via the official SDK. + +SDK Reference: https://github.com/opsgenie/opsgenie-python-sdk +API Reference: https://docs.opsgenie.com/docs/api-overview +""" + +import logging +from typing import Any + +import opsgenie_sdk # type: ignore[reportMissingImports] +from pydantic import BaseModel, Field, field_validator # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.iclient import IClient + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class OpsgenieResponse(BaseModel): + """Standardised Opsgenie API response wrapper.""" + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field(default=None, description="Response data from the SDK") + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + arbitrary_types_allowed = True + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary for JSON serialization.""" + return self.model_dump() + + def to_json(self) -> str: + """Convert to JSON string.""" + return self.model_dump_json() + + +# --------------------------------------------------------------------------- +# SDK wrapper client +# --------------------------------------------------------------------------- + + +class OpsgenieClientViaApiKey: + """Opsgenie SDK client via API Key. + + Wraps the official ``opsgenie_sdk`` package. The API key is configured + with the ``GenieKey`` prefix as required by Opsgenie. + + Args: + api_key: The Opsgenie API key + """ + + def __init__(self, api_key: str) -> None: + super().__init__() + self.api_key = api_key + self._sdk: Any = None # opsgenie_sdk.ApiClient + self._configuration: Any = None # opsgenie_sdk.Configuration + + def create_client(self) -> Any: # opsgenie_sdk.ApiClient + self._configuration = opsgenie_sdk.Configuration() # type: ignore[reportUnknownMemberType] + self._configuration.api_key["Authorization"] = self.api_key # type: ignore[reportUnknownMemberType] + self._configuration.api_key_prefix["Authorization"] = "GenieKey" # type: ignore[reportUnknownMemberType] + self._sdk = opsgenie_sdk.ApiClient(self._configuration) # type: ignore[reportUnknownMemberType] + return self._sdk # type: ignore[reportUnknownMemberType,reportUnknownVariableType] + + def get_sdk(self) -> Any: # opsgenie_sdk.ApiClient + if self._sdk is None: + return self.create_client() + return self._sdk + + def get_base_url(self) -> str: + return "https://api.opsgenie.com/v2" + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class OpsgenieApiKeyConfig(BaseModel): + """Configuration for Opsgenie client via API Key. + + Args: + api_key: The Opsgenie API key + """ + + api_key: str + + @field_validator("api_key") + @classmethod + def validate_api_key(cls, v: str) -> str: + if not v or not v.strip(): + raise ValueError("api_key cannot be empty or None") + return v + + def create_client(self) -> OpsgenieClientViaApiKey: + return OpsgenieClientViaApiKey(self.api_key) + + def to_dict(self) -> dict[str, Any]: + return {"has_api_key": bool(self.api_key)} + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class OpsgenieClient(IClient): + """Builder class for Opsgenie clients.""" + + def __init__(self, client: OpsgenieClientViaApiKey) -> None: + super().__init__() + self.client = client + + @override + def get_client(self) -> OpsgenieClientViaApiKey: + return self.client + + def get_sdk(self) -> Any: # opsgenie_sdk.ApiClient + return self.client.get_sdk() + + def get_base_url(self) -> str: + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: OpsgenieApiKeyConfig, + ) -> "OpsgenieClient": + client = config.create_client() + client.get_sdk() + return cls(client) + + @classmethod + def build_with_api_key( + cls, + api_key: str, + ) -> "OpsgenieClient": + config = OpsgenieApiKeyConfig(api_key=api_key) + return cls.build_with_config(config) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "OpsgenieClient": + config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not config: + raise ValueError( + "Failed to get Opsgenie connector configuration" + ) + auth_config = config.get("auth", {}) + auth_type = auth_config.get("authType", "API_KEY") + if auth_type == "API_KEY": + api_key = auth_config.get("apiKey", "") + if not api_key: + raise ValueError("API key required for API key auth type") + client = OpsgenieApiKeyConfig(api_key=api_key).create_client() + else: + raise ValueError(f"Invalid auth type: {auth_type}") + return cls(client) + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + try: + config = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not config: + raise ValueError( + f"Failed to get Opsgenie connector configuration " + f"for instance {connector_instance_id}" + ) + return dict(config) # type: ignore[arg-type] + except Exception as e: + logger.error(f"Failed to get Opsgenie connector config: {e}") + raise ValueError( + f"Failed to get Opsgenie connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/panopto/panopto.py b/backend/python/app/sources/client/panopto/panopto.py new file mode 100644 index 000000000..0fe933a4e --- /dev/null +++ b/backend/python/app/sources/client/panopto/panopto.py @@ -0,0 +1,557 @@ +"""Panopto client implementation. + +This module provides clients for interacting with the Panopto API using either: +1. OAuth 2.0 authorization code flow +2. Pre-generated API Key (Bearer token) + +The base URL is domain-specific: +https://{domain}.hosted.panopto.com/Panopto/api/v1 + +OAuth Auth Endpoint: https://{domain}.hosted.panopto.com/Panopto/oauth2/connect/authorize +OAuth Token Endpoint: https://{domain}.hosted.panopto.com/Panopto/oauth2/connect/token + +Authentication Reference: https://support.panopto.com/s/article/oauth2-for-services +API Reference: https://support.panopto.com/s/article/api-0 +""" + +import base64 +import json +import logging +from enum import Enum +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class PanoptoAuthType(str, Enum): + """Authentication types supported by the Panopto connector.""" + + OAUTH = "OAUTH" + TOKEN = "TOKEN" + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class PanoptoResponse(BaseModel): + """Standardized Panopto API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client classes +# --------------------------------------------------------------------------- + + +class PanoptoRESTClientViaOAuth(HTTPClient): + """Panopto REST client via OAuth 2.0 authorization code flow. + + OAuth tokens are passed as Bearer tokens in the Authorization header. + Supports token refresh via client_id and client_secret. + + Args: + access_token: The OAuth access token + domain: The Panopto domain (e.g., "mycompany") + client_id: OAuth client ID (for token refresh) + client_secret: OAuth client secret (for token refresh) + redirect_uri: OAuth redirect URI + """ + + def __init__( + self, + access_token: str, + domain: str, + client_id: str | None = None, + client_secret: str | None = None, + redirect_uri: str | None = None, + ) -> None: + super().__init__(access_token, "Bearer") + self.domain = domain + self.base_url = ( + f"https://{domain}.hosted.panopto.com/Panopto/api/v1" + ) + self.access_token = access_token + self.client_id = client_id + self.client_secret = client_secret + self.redirect_uri = redirect_uri + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + def get_domain(self) -> str: + """Get the Panopto domain.""" + return self.domain + + +class PanoptoRESTClientViaToken(HTTPClient): + """Panopto REST client via pre-generated API Key (Bearer token). + + Simple authentication using an API key passed as a Bearer token + in the Authorization header. + + Args: + token: The API key / Bearer token + domain: The Panopto domain (e.g., "mycompany") + """ + + def __init__( + self, + token: str, + domain: str, + ) -> None: + super().__init__(token, token_type="Bearer") + self.domain = domain + self.base_url = ( + f"https://{domain}.hosted.panopto.com/Panopto/api/v1" + ) + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + def get_domain(self) -> str: + """Get the Panopto domain.""" + return self.domain + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class PanoptoOAuthConfig(BaseModel): + """Configuration for Panopto client via OAuth 2.0. + + Args: + access_token: The OAuth access token + domain: The Panopto domain (e.g., "mycompany") + client_id: OAuth client ID + client_secret: OAuth client secret + redirect_uri: OAuth redirect URI + """ + + access_token: str + domain: str + client_id: str | None = None + client_secret: str | None = None + redirect_uri: str | None = None + + def create_client(self) -> PanoptoRESTClientViaOAuth: + return PanoptoRESTClientViaOAuth( + self.access_token, + self.domain, + self.client_id, + self.client_secret, + self.redirect_uri, + ) + + +class PanoptoTokenConfig(BaseModel): + """Configuration for Panopto client via API Key (Bearer token). + + Args: + token: The API key / Bearer token + domain: The Panopto domain (e.g., "mycompany") + """ + + token: str + domain: str + + def create_client(self) -> PanoptoRESTClientViaToken: + return PanoptoRESTClientViaToken(self.token, self.domain) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class PanoptoAuthConfig(BaseModel): + """Auth section of the Panopto connector configuration from etcd.""" + + authType: PanoptoAuthType = PanoptoAuthType.TOKEN + clientId: str | None = None + clientSecret: str | None = None + redirectUri: str | None = None + token: str | None = None + domain: str | None = None + oauthConfigId: str | None = None + + class Config: + extra = "allow" + + +class PanoptoCredentialsConfig(BaseModel): + """Credentials section of the Panopto connector configuration.""" + + access_token: str | None = None + refresh_token: str | None = None + + class Config: + extra = "allow" + + +class PanoptoConnectorConfig(BaseModel): + """Top-level Panopto connector configuration from etcd.""" + + auth: PanoptoAuthConfig = Field(default_factory=PanoptoAuthConfig) + credentials: PanoptoCredentialsConfig = Field( + default_factory=PanoptoCredentialsConfig + ) + domain: str = "" + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class PanoptoClient(IClient): + """Builder class for Panopto clients with different auth methods. + + Supports: + - OAuth 2.0 authorization code flow + - Pre-generated API Key (Bearer token) + """ + + def __init__( + self, + client: PanoptoRESTClientViaOAuth | PanoptoRESTClientViaToken, + ) -> None: + """Initialize with a Panopto client object.""" + super().__init__() + self.client = client + + @override + def get_client( + self, + ) -> PanoptoRESTClientViaOAuth | PanoptoRESTClientViaToken: + """Return the Panopto client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @property + def domain(self) -> str: + """Return the Panopto domain.""" + return self.client.get_domain() + + @classmethod + def build_with_config( + cls, + config: PanoptoOAuthConfig | PanoptoTokenConfig, + ) -> "PanoptoClient": + """Build PanoptoClient with configuration. + + Args: + config: PanoptoOAuthConfig or PanoptoTokenConfig instance + + Returns: + PanoptoClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "PanoptoClient": + """Build PanoptoClient using configuration service. + + Supports two authentication strategies: + 1. OAUTH: OAuth 2.0 authorization code flow with access token + 2. TOKEN: Pre-generated API key / Bearer token + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + PanoptoClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get Panopto connector configuration" + ) + + connector_config = PanoptoConnectorConfig.model_validate( + raw_config + ) + + domain = ( + connector_config.auth.domain or connector_config.domain + ) + if not domain: + raise ValueError("Panopto domain is required") + + if connector_config.auth.authType == PanoptoAuthType.OAUTH: + access_token = ( + connector_config.credentials.access_token or "" + ) + client_id = connector_config.auth.clientId or "" + client_secret = connector_config.auth.clientSecret or "" + redirect_uri = connector_config.auth.redirectUri or "" + + # Try shared OAuth config if credentials are missing + oauth_config_id = connector_config.auth.oauthConfigId + if oauth_config_id and not (client_id and client_secret): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/panopto", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + redirect_uri = str( + shared.get("redirectUri") + or shared.get("redirect_uri") + or redirect_uri + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + if not access_token: + raise ValueError( + "Access token required for OAuth auth type" + ) + + oauth_cfg = PanoptoOAuthConfig( + access_token=access_token, + domain=domain, + client_id=client_id, + client_secret=client_secret, + redirect_uri=redirect_uri, + ) + return cls(oauth_cfg.create_client()) + + elif connector_config.auth.authType == PanoptoAuthType.TOKEN: + token = connector_config.auth.token or "" + if not token: + token = connector_config.credentials.access_token or "" + if not token: + raise ValueError( + "Token required for TOKEN auth type" + ) + + token_config = PanoptoTokenConfig( + token=token, domain=domain + ) + return cls(token_config.create_client()) + + else: + raise ValueError( + f"Invalid auth type: {connector_config.auth.authType}" + ) + + except Exception as e: + logger.error( + f"Failed to build Panopto client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "PanoptoClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service for shared OAuth + + Returns: + PanoptoClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("credentials", {}) or {} + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + domain: str = str(toolset_config.get("domain", "")) + + if not domain: + raise ValueError("Panopto domain not found in toolset config") + + access_token: str = str(credentials.get("access_token", "")) + if not access_token: + raise ValueError( + "Access token not found in toolset config" + ) + + client_id: str = str(auth_config.get("clientId", "")) + client_secret: str = str(auth_config.get("clientSecret", "")) + redirect_uri: str = str(auth_config.get("redirectUri", "")) + + # Try shared OAuth config + oauth_config_id: str | None = cast( + str | None, auth_config.get("oauthConfigId") + ) + if oauth_config_id and config_service and not ( + client_id and client_secret + ): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/panopto", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + redirect_uri = str( + shared.get("redirectUri") + or shared.get("redirect_uri") + or redirect_uri + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + oauth_cfg = PanoptoOAuthConfig( + access_token=access_token, + domain=domain, + client_id=client_id, + client_secret=client_secret, + redirect_uri=redirect_uri, + ) + return cls(oauth_cfg.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Panopto client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Panopto.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get Panopto connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error(f"Failed to get Panopto connector config: {e}") + raise ValueError( + f"Failed to get Panopto connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/phabricator/phabricator.py b/backend/python/app/sources/client/phabricator/phabricator.py new file mode 100644 index 000000000..ff2fb2c25 --- /dev/null +++ b/backend/python/app/sources/client/phabricator/phabricator.py @@ -0,0 +1,363 @@ +"""Phabricator client implementation. + +This module provides a client for interacting with the Phabricator Conduit API +using API Token authentication. All Phabricator API calls are POST requests +with form-encoded body including the ``api.token`` parameter. + +Authentication Reference: https://secure.phabricator.com/book/phabricator/article/conduit/ +API Reference: https://secure.phabricator.com/conduit/ +""" + +import base64 +import json +import logging +from enum import Enum +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class PhabricatorAuthType(str, Enum): + """Authentication types supported by the Phabricator connector.""" + + API_TOKEN = "API_TOKEN" + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class PhabricatorResponse(BaseModel): + """Standardized Phabricator API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client classes +# --------------------------------------------------------------------------- + + +class PhabricatorRESTClientViaToken(HTTPClient): + """Phabricator REST client via Conduit API Token. + + All Phabricator Conduit API calls are POST requests with form-encoded + body. The ``api.token`` is injected into every request body automatically + by the DataSource layer; this client simply sets appropriate defaults. + + Args: + token: The Conduit API token + instance: Phabricator instance hostname (e.g. ``phabricator.example.com``) + """ + + def __init__(self, token: str, instance: str) -> None: + # We don't use Bearer auth; token goes in POST body + super().__init__(token, token_type="Bearer") + self.base_url = f"https://{instance}/api" + self.api_token = token + # Remove the Authorization header; Phabricator uses api.token in body + _ = self.headers.pop("Authorization", None) + self.headers["Content-Type"] = "application/x-www-form-urlencoded" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + def get_api_token(self) -> str: + """Get the API token for inclusion in POST body.""" + return self.api_token + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class PhabricatorTokenConfig(BaseModel): + """Configuration for Phabricator client via API Token. + + Args: + token: The Conduit API token + instance: Phabricator instance hostname + """ + + token: str + instance: str + + def create_client(self) -> PhabricatorRESTClientViaToken: + return PhabricatorRESTClientViaToken(self.token, self.instance) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class PhabricatorAuthConfig(BaseModel): + """Auth section of the Phabricator connector configuration from etcd.""" + + authType: PhabricatorAuthType = PhabricatorAuthType.API_TOKEN + apiToken: str | None = None + token: str | None = None + instance: str | None = None + + class Config: + extra = "allow" + + +class PhabricatorCredentialsConfig(BaseModel): + """Credentials section of the Phabricator connector configuration.""" + + api_token: str | None = None + + class Config: + extra = "allow" + + +class PhabricatorConnectorConfig(BaseModel): + """Top-level Phabricator connector configuration from etcd.""" + + auth: PhabricatorAuthConfig = Field(default_factory=PhabricatorAuthConfig) + credentials: PhabricatorCredentialsConfig = Field( + default_factory=PhabricatorCredentialsConfig + ) + instance: str | None = None + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class PhabricatorClient(IClient): + """Builder class for Phabricator clients. + + Supports: + - API Token (Conduit) authentication + """ + + def __init__( + self, + client: PhabricatorRESTClientViaToken, + ) -> None: + """Initialize with a Phabricator client object.""" + super().__init__() + self.client = client + + @override + def get_client(self) -> PhabricatorRESTClientViaToken: + """Return the Phabricator client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: PhabricatorTokenConfig, + ) -> "PhabricatorClient": + """Build PhabricatorClient with configuration. + + Args: + config: PhabricatorTokenConfig instance + + Returns: + PhabricatorClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "PhabricatorClient": + """Build PhabricatorClient using configuration service. + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + PhabricatorClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get Phabricator connector configuration" + ) + + connector_config = PhabricatorConnectorConfig.model_validate( + raw_config + ) + + token = ( + connector_config.auth.apiToken + or connector_config.auth.token + or connector_config.credentials.api_token + or "" + ) + if not token: + raise ValueError( + "API token required for Phabricator auth" + ) + + instance = ( + connector_config.auth.instance + or connector_config.instance + or "" + ) + if not instance: + raise ValueError( + "Instance hostname required for Phabricator" + ) + + token_config = PhabricatorTokenConfig( + token=token, instance=instance + ) + return cls(token_config.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Phabricator client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "PhabricatorClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service + + Returns: + PhabricatorClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("credentials", {}) or {} + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + token: str = str( + credentials.get("api_token", "") + or auth_config.get("apiToken", "") + or auth_config.get("token", "") + ) + if not token: + raise ValueError( + "API token not found in toolset config" + ) + + instance: str = str( + auth_config.get("instance", "") + or toolset_config.get("instance", "") + ) + if not instance: + raise ValueError( + "Instance hostname not found in toolset config" + ) + + token_config = PhabricatorTokenConfig( + token=token, instance=instance + ) + return cls(token_config.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Phabricator client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Phabricator.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get Phabricator connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error( + f"Failed to get Phabricator connector config: {e}" + ) + raise ValueError( + f"Failed to get Phabricator connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/pingboard/pingboard.py b/backend/python/app/sources/client/pingboard/pingboard.py new file mode 100644 index 000000000..41bfd259b --- /dev/null +++ b/backend/python/app/sources/client/pingboard/pingboard.py @@ -0,0 +1,476 @@ +"""Pingboard client implementation. + +This module provides clients for interacting with the Pingboard API using either: +1. OAuth2 client_credentials grant (server-to-server) +2. Pre-generated Bearer token + +Pingboard is an employee directory and org chart platform. + +API Reference: https://app.pingboard.com/api/v2 +Authentication: + - OAuth2 client_credentials: POST to https://app.pingboard.com/oauth/token + - Bearer token: Authorization: Bearer {token} +""" + +import base64 +import json +import logging +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.http.http_request import HTTPRequest +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class PingboardResponse(BaseModel): + """Standardized Pingboard API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client classes +# --------------------------------------------------------------------------- + + +class PingboardRESTClientViaClientCredentials(HTTPClient): + """Pingboard REST client via OAuth2 client_credentials grant. + + Uses client_credentials grant type to obtain an access token from the + Pingboard OAuth token endpoint. The token is fetched automatically on + first use via ensure_authenticated(). + + Args: + client_id: OAuth client ID + client_secret: OAuth client secret + base_url: API base URL (default: https://app.pingboard.com/api/v2) + """ + + def __init__( + self, + client_id: str, + client_secret: str, + base_url: str = "https://app.pingboard.com/api/v2", + ) -> None: + super().__init__("", token_type="Bearer") + self.base_url = base_url + self.client_id = client_id + self.client_secret = client_secret + self._authenticated = False + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + async def ensure_authenticated(self) -> None: + """Fetch access token via client_credentials grant if not authenticated. + + Posts to the Pingboard token endpoint with grant_type=client_credentials. + """ + if self._authenticated: + return + + token_request = HTTPRequest( + url="https://app.pingboard.com/oauth/token", + method="POST", + headers={ + "Content-Type": "application/x-www-form-urlencoded", + }, + body={ + "grant_type": "client_credentials", + "client_id": self.client_id, + "client_secret": self.client_secret, + }, + ) + + response = await self.execute(token_request) # type: ignore[reportUnknownMemberType] + response_data = response.json() + + access_token = response_data.get("access_token") + if not access_token: + raise ValueError( + "Failed to obtain access token from Pingboard OAuth: " + f"{response_data}" + ) + + self.headers["Authorization"] = f"Bearer {access_token}" + self._authenticated = True + + +class PingboardRESTClientViaToken(HTTPClient): + """Pingboard REST client via pre-generated Bearer token. + + Simple authentication using a pre-generated token passed directly + in the Authorization header. + + Args: + token: The pre-generated Bearer token + base_url: API base URL (default: https://app.pingboard.com/api/v2) + """ + + def __init__( + self, + token: str, + base_url: str = "https://app.pingboard.com/api/v2", + ) -> None: + super().__init__(token, token_type="Bearer") + self.base_url = base_url + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class PingboardClientCredentialsConfig(BaseModel): + """Configuration for Pingboard client via client_credentials grant. + + Args: + client_id: OAuth client ID + client_secret: OAuth client secret + base_url: API base URL (default: https://app.pingboard.com/api/v2) + """ + + client_id: str + client_secret: str + base_url: str = "https://app.pingboard.com/api/v2" + + def create_client(self) -> PingboardRESTClientViaClientCredentials: + return PingboardRESTClientViaClientCredentials( + self.client_id, + self.client_secret, + self.base_url, + ) + + +class PingboardTokenConfig(BaseModel): + """Configuration for Pingboard client via Bearer token. + + Args: + token: The pre-generated Bearer token + base_url: API base URL (default: https://app.pingboard.com/api/v2) + """ + + token: str + base_url: str = "https://app.pingboard.com/api/v2" + + def create_client(self) -> PingboardRESTClientViaToken: + return PingboardRESTClientViaToken(self.token, self.base_url) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class PingboardAuthConfig(BaseModel): + """Auth section of the Pingboard connector configuration from etcd.""" + + clientId: str | None = None + clientSecret: str | None = None + token: str | None = None + oauthConfigId: str | None = None + + class Config: + extra = "allow" + + +class PingboardCredentialsConfig(BaseModel): + """Credentials section of the Pingboard connector configuration.""" + + access_token: str | None = None + + class Config: + extra = "allow" + + +class PingboardConnectorConfig(BaseModel): + """Top-level Pingboard connector configuration from etcd.""" + + auth: PingboardAuthConfig = Field(default_factory=PingboardAuthConfig) + credentials: PingboardCredentialsConfig = Field( + default_factory=PingboardCredentialsConfig + ) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class PingboardClient(IClient): + """Builder class for Pingboard clients with different auth methods. + + Supports: + - OAuth2 client_credentials grant + - Pre-generated Bearer token + """ + + def __init__( + self, + client: ( + PingboardRESTClientViaClientCredentials + | PingboardRESTClientViaToken + ), + ) -> None: + """Initialize with a Pingboard client object.""" + super().__init__() + self.client = client + + @override + def get_client( + self, + ) -> PingboardRESTClientViaClientCredentials | PingboardRESTClientViaToken: + """Return the Pingboard client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: PingboardClientCredentialsConfig | PingboardTokenConfig, + ) -> "PingboardClient": + """Build PingboardClient with configuration. + + Args: + config: PingboardClientCredentialsConfig or PingboardTokenConfig + + Returns: + PingboardClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "PingboardClient": + """Build PingboardClient using configuration service. + + Supports two authentication strategies: + 1. CLIENT_CREDENTIALS: client_id and client_secret for S2S OAuth + 2. TOKEN: Pre-generated Bearer token + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + PingboardClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get Pingboard connector configuration" + ) + + connector_config = PingboardConnectorConfig.model_validate( + raw_config + ) + + client_id = connector_config.auth.clientId or "" + client_secret = connector_config.auth.clientSecret or "" + + # Try shared OAuth config if credentials are missing + oauth_config_id = connector_config.auth.oauthConfigId + if oauth_config_id and not (client_id and client_secret): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/pingboard", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + # Prefer client_credentials if both are available + if client_id and client_secret: + cc_config = PingboardClientCredentialsConfig( + client_id=client_id, + client_secret=client_secret, + ) + return cls(cc_config.create_client()) + + # Fall back to token + token = ( + connector_config.auth.token + or connector_config.credentials.access_token + or "" + ) + if token: + token_config = PingboardTokenConfig(token=token) + return cls(token_config.create_client()) + + raise ValueError( + "Either client_id/client_secret or token required " + "for Pingboard authentication" + ) + + except Exception as e: + logger.error( + f"Failed to build Pingboard client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "PingboardClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service + + Returns: + PingboardClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("credentials", {}) or {} + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + client_id: str = str(auth_config.get("clientId", "")) + client_secret: str = str(auth_config.get("clientSecret", "")) + + if client_id and client_secret: + cc_config = PingboardClientCredentialsConfig( + client_id=client_id, + client_secret=client_secret, + ) + return cls(cc_config.create_client()) + + token: str = str( + credentials.get("access_token", "") + or auth_config.get("token", "") + ) + if not token: + raise ValueError( + "Client credentials or token not found in toolset config" + ) + + token_config = PingboardTokenConfig(token=token) + return cls(token_config.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Pingboard client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Pingboard.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get Pingboard connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error(f"Failed to get Pingboard connector config: {e}") + raise ValueError( + f"Failed to get Pingboard connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/pingidentity/pingidentity.py b/backend/python/app/sources/client/pingidentity/pingidentity.py new file mode 100644 index 000000000..617dbfade --- /dev/null +++ b/backend/python/app/sources/client/pingidentity/pingidentity.py @@ -0,0 +1,576 @@ +"""Ping Identity (PingOne) client implementation. + +This module provides clients for interacting with the PingOne API using either: +1. OAuth2 (client_credentials grant) +2. Pre-generated Bearer token + +Authentication Reference: https://apidocs.pingidentity.com/pingone/platform/v1/api/ +Token Endpoint: https://auth.pingone.com/{environmentId}/as/token +API Base URL: https://api.pingone.com/v1/environments/{environmentId} +""" + +import base64 +import json +import logging +from enum import Enum +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.http.http_request import HTTPRequest +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class PingIdentityAuthType(str, Enum): + """Authentication types supported by the PingIdentity connector.""" + + OAUTH = "OAUTH" + TOKEN = "TOKEN" + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class PingIdentityResponse(BaseModel): + """Standardized PingIdentity API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client classes +# --------------------------------------------------------------------------- + + +class PingIdentityRESTClientViaOAuth(HTTPClient): + """PingOne REST client via OAuth2 client_credentials grant. + + Fetches an access token from the PingOne token endpoint using + client_credentials grant. The token is obtained automatically on + first use via ensure_authenticated(). + + Args: + environment_id: PingOne environment ID + client_id: OAuth client ID + client_secret: OAuth client secret + """ + + def __init__( + self, + environment_id: str, + client_id: str, + client_secret: str, + ) -> None: + super().__init__("", token_type="Bearer") + self.environment_id = environment_id + self.client_id = client_id + self.client_secret = client_secret + self.base_url = ( + f"https://api.pingone.com/v1/environments/{self.environment_id}" + ) + self._authenticated = False + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + async def ensure_authenticated(self) -> None: + """Fetch an access token via client_credentials grant if needed. + + Uses HTTP Basic Auth (client_id:client_secret) and posts to the + PingOne token endpoint with grant_type=client_credentials. + """ + if self._authenticated: + return + + credentials = base64.b64encode( + f"{self.client_id}:{self.client_secret}".encode() + ).decode("utf-8") + + token_url = ( + f"https://auth.pingone.com/{self.environment_id}/as/token" + ) + + token_request = HTTPRequest( + url=token_url, + method="POST", + headers={ + "Authorization": f"Basic {credentials}", + "Content-Type": "application/x-www-form-urlencoded", + }, + body={ + "grant_type": "client_credentials", + }, + ) + + response = await self.execute(token_request) # type: ignore[reportUnknownMemberType] + response_data = response.json() + + access_token = response_data.get("access_token") + if not access_token: + raise ValueError( + "Failed to obtain access token from PingOne: " + f"{response_data}" + ) + + self.headers["Authorization"] = f"Bearer {access_token}" + self._authenticated = True + + +class PingIdentityRESTClientViaToken(HTTPClient): + """PingOne REST client via pre-generated Bearer token. + + Simple authentication using a pre-generated token passed directly + in the Authorization header. + + Args: + token: The pre-generated Bearer token + environment_id: PingOne environment ID + """ + + def __init__( + self, + token: str, + environment_id: str, + ) -> None: + super().__init__(token, token_type="Bearer") + self.environment_id = environment_id + self.base_url = ( + f"https://api.pingone.com/v1/environments/{self.environment_id}" + ) + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class PingIdentityOAuthConfig(BaseModel): + """Configuration for PingIdentity client via OAuth2. + + Args: + environment_id: PingOne environment ID + client_id: OAuth client ID + client_secret: OAuth client secret + """ + + environment_id: str + client_id: str + client_secret: str + + def create_client(self) -> PingIdentityRESTClientViaOAuth: + return PingIdentityRESTClientViaOAuth( + self.environment_id, + self.client_id, + self.client_secret, + ) + + +class PingIdentityTokenConfig(BaseModel): + """Configuration for PingIdentity client via Bearer token. + + Args: + token: The pre-generated Bearer token + environment_id: PingOne environment ID + """ + + token: str + environment_id: str + + def create_client(self) -> PingIdentityRESTClientViaToken: + return PingIdentityRESTClientViaToken( + self.token, self.environment_id + ) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class PingIdentityAuthConfig(BaseModel): + """Auth section of the PingIdentity connector configuration from etcd.""" + + authType: PingIdentityAuthType = PingIdentityAuthType.OAUTH + environmentId: str | None = None + clientId: str | None = None + clientSecret: str | None = None + token: str | None = None + oauthConfigId: str | None = None + + class Config: + extra = "allow" + + +class PingIdentityCredentialsConfig(BaseModel): + """Credentials section of the PingIdentity connector configuration.""" + + access_token: str | None = None + refresh_token: str | None = None + + class Config: + extra = "allow" + + +class PingIdentityConnectorConfig(BaseModel): + """Top-level PingIdentity connector configuration from etcd.""" + + auth: PingIdentityAuthConfig = Field( + default_factory=PingIdentityAuthConfig + ) + credentials: PingIdentityCredentialsConfig = Field( + default_factory=PingIdentityCredentialsConfig + ) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class PingIdentityClient(IClient): + """Builder class for PingIdentity clients with different auth methods. + + Supports: + - OAuth2 (client_credentials grant) authentication + - Pre-generated Bearer token authentication + """ + + def __init__( + self, + client: ( + PingIdentityRESTClientViaOAuth | PingIdentityRESTClientViaToken + ), + ) -> None: + """Initialize with a PingIdentity client object.""" + super().__init__() + self.client = client + + @override + def get_client( + self, + ) -> PingIdentityRESTClientViaOAuth | PingIdentityRESTClientViaToken: + """Return the PingIdentity client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: PingIdentityOAuthConfig | PingIdentityTokenConfig, + ) -> "PingIdentityClient": + """Build PingIdentityClient with configuration. + + Args: + config: PingIdentityOAuthConfig or PingIdentityTokenConfig + + Returns: + PingIdentityClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "PingIdentityClient": + """Build PingIdentityClient using configuration service. + + Supports two authentication strategies: + 1. OAUTH: Client credentials grant + 2. TOKEN: Pre-generated Bearer token + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + PingIdentityClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get PingIdentity connector configuration" + ) + + connector_config = PingIdentityConnectorConfig.model_validate( + raw_config + ) + + environment_id = connector_config.auth.environmentId or "" + if not environment_id: + raise ValueError( + "environmentId is required for PingIdentity" + ) + + if connector_config.auth.authType == PingIdentityAuthType.OAUTH: + client_id = connector_config.auth.clientId or "" + client_secret = connector_config.auth.clientSecret or "" + + # Try shared OAuth config if credentials are missing + oauth_config_id = connector_config.auth.oauthConfigId + if oauth_config_id and not (client_id and client_secret): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/pingidentity", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + if not (client_id and client_secret): + raise ValueError( + "client_id and client_secret are required " + "for OAuth auth type" + ) + + oauth_cfg = PingIdentityOAuthConfig( + environment_id=environment_id, + client_id=client_id, + client_secret=client_secret, + ) + return cls(oauth_cfg.create_client()) + + elif connector_config.auth.authType == PingIdentityAuthType.TOKEN: + token = ( + connector_config.auth.token + or connector_config.credentials.access_token + or "" + ) + if not token: + raise ValueError( + "Token required for TOKEN auth type" + ) + + token_config = PingIdentityTokenConfig( + token=token, environment_id=environment_id + ) + return cls(token_config.create_client()) + + else: + raise ValueError( + f"Invalid auth type: {connector_config.auth.authType}" + ) + + except Exception as e: + logger.error( + "Failed to build PingIdentity client from services: " + f"{str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "PingIdentityClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service for shared OAuth + + Returns: + PingIdentityClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("credentials", {}) or {} + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + environment_id: str = str( + auth_config.get("environmentId", "") + ) + if not environment_id: + raise ValueError( + "environmentId not found in toolset config" + ) + + access_token: str = str(credentials.get("access_token", "")) + client_id: str = str(auth_config.get("clientId", "")) + client_secret: str = str(auth_config.get("clientSecret", "")) + + # Try shared OAuth config + oauth_config_id: str | None = cast( + str | None, auth_config.get("oauthConfigId") + ) + if oauth_config_id and config_service and not ( + client_id and client_secret + ): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/pingidentity", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + # If we have client credentials, use OAuth flow + if client_id and client_secret: + oauth_cfg = PingIdentityOAuthConfig( + environment_id=environment_id, + client_id=client_id, + client_secret=client_secret, + ) + return cls(oauth_cfg.create_client()) + + # Otherwise use the access token directly + if not access_token: + raise ValueError( + "Access token or client credentials required" + ) + + token_config = PingIdentityTokenConfig( + token=access_token, environment_id=environment_id + ) + return cls(token_config.create_client()) + + except Exception as e: + logger.error( + "Failed to build PingIdentity client from toolset: " + f"{str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for PingIdentity.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get PingIdentity connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error( + f"Failed to get PingIdentity connector config: {e}" + ) + raise ValueError( + f"Failed to get PingIdentity connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/pipedrive/pipedrive.py b/backend/python/app/sources/client/pipedrive/pipedrive.py new file mode 100644 index 000000000..73b74f3ec --- /dev/null +++ b/backend/python/app/sources/client/pipedrive/pipedrive.py @@ -0,0 +1,524 @@ +"""Pipedrive client implementation. + +This module provides clients for interacting with the Pipedrive API using either: +1. OAuth 2.0 authorization code flow +2. API Token authentication (Bearer token) + +Pipedrive supports both authentication methods. OAuth is recommended for +third-party integrations, while API tokens are suitable for personal use. + +Authentication Reference: https://pipedrive.readme.io/docs/core-api-concepts-authentication +OAuth Reference: https://pipedrive.readme.io/docs/marketplace-oauth-authorization +API Reference: https://developers.pipedrive.com/docs/api/v1 +""" + +import base64 +import json +import logging +from enum import Enum +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class PipedriveAuthType(str, Enum): + """Authentication types supported by the Pipedrive connector.""" + + OAUTH = "OAUTH" + TOKEN = "TOKEN" + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class PipedriveResponse(BaseModel): + """Standardized Pipedrive API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client classes +# --------------------------------------------------------------------------- + + +class PipedriveRESTClientViaOAuth(HTTPClient): + """Pipedrive REST client via OAuth 2.0 authorization code flow. + + OAuth tokens are passed as Bearer tokens in the Authorization header. + Supports token refresh via client_id and client_secret. + + Args: + access_token: The OAuth access token + client_id: OAuth client ID (for token refresh) + client_secret: OAuth client secret (for token refresh) + base_url: API base URL (default: https://api.pipedrive.com/v1) + """ + + def __init__( + self, + access_token: str, + client_id: str | None = None, + client_secret: str | None = None, + base_url: str = "https://api.pipedrive.com/v1", + ) -> None: + super().__init__(access_token, "Bearer") + self.base_url = base_url + self.access_token = access_token + self.client_id = client_id + self.client_secret = client_secret + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +class PipedriveRESTClientViaToken(HTTPClient): + """Pipedrive REST client via API Token. + + API tokens are passed as Bearer tokens in the Authorization header. + + Args: + token: The Pipedrive API token + base_url: API base URL (default: https://api.pipedrive.com/v1) + """ + + def __init__( + self, + token: str, + base_url: str = "https://api.pipedrive.com/v1", + ) -> None: + super().__init__(token, token_type="Bearer") + self.base_url = base_url + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class PipedriveOAuthConfig(BaseModel): + """Configuration for Pipedrive client via OAuth 2.0 authorization code flow. + + Args: + access_token: The OAuth access token + client_id: OAuth client ID + client_secret: OAuth client secret + base_url: API base URL (default: https://api.pipedrive.com/v1) + """ + + access_token: str + client_id: str | None = None + client_secret: str | None = None + base_url: str = "https://api.pipedrive.com/v1" + + def create_client(self) -> PipedriveRESTClientViaOAuth: + return PipedriveRESTClientViaOAuth( + self.access_token, + self.client_id, + self.client_secret, + self.base_url, + ) + + +class PipedriveTokenConfig(BaseModel): + """Configuration for Pipedrive client via API Token. + + Args: + token: The Pipedrive API token + base_url: API base URL (default: https://api.pipedrive.com/v1) + """ + + token: str + base_url: str = "https://api.pipedrive.com/v1" + + def create_client(self) -> PipedriveRESTClientViaToken: + return PipedriveRESTClientViaToken(self.token, self.base_url) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class PipedriveAuthConfig(BaseModel): + """Auth section of the Pipedrive connector configuration from etcd.""" + + authType: PipedriveAuthType = PipedriveAuthType.OAUTH + apiToken: str | None = None + token: str | None = None + clientId: str | None = None + clientSecret: str | None = None + redirectUri: str | None = None + oauthConfigId: str | None = None + + class Config: + extra = "allow" + + +class PipedriveCredentialsConfig(BaseModel): + """Credentials section of the Pipedrive connector configuration.""" + + access_token: str | None = None + refresh_token: str | None = None + + class Config: + extra = "allow" + + +class PipedriveConnectorConfig(BaseModel): + """Top-level Pipedrive connector configuration from etcd.""" + + auth: PipedriveAuthConfig = Field(default_factory=PipedriveAuthConfig) + credentials: PipedriveCredentialsConfig = Field( + default_factory=PipedriveCredentialsConfig + ) + + class Config: + extra = "allow" + + +class PipedriveSharedOAuthConfigEntry(BaseModel): + """A single entry from the shared OAuth config list in etcd. + + Handles both camelCase and snake_case key variants from the config store. + """ + + entry_id: str | None = Field(default=None, alias="_id") + clientId: str | None = None + client_id: str | None = None + clientSecret: str | None = None + client_secret: str | None = None + redirectUri: str | None = None + redirect_uri: str | None = None + + class Config: + extra = "allow" + populate_by_name = True + + def resolved_client_id(self, fallback: str = "") -> str: + return self.clientId or self.client_id or fallback + + def resolved_client_secret(self, fallback: str = "") -> str: + return self.clientSecret or self.client_secret or fallback + + def resolved_redirect_uri(self, fallback: str = "") -> str: + return self.redirectUri or self.redirect_uri or fallback + + +class PipedriveSharedOAuthWrapper(BaseModel): + """Wrapper for a shared OAuth config entry with nested config.""" + + entry_id: str | None = Field(default=None, alias="_id") + config: PipedriveSharedOAuthConfigEntry = Field( + default_factory=PipedriveSharedOAuthConfigEntry + ) + + class Config: + extra = "allow" + populate_by_name = True + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class PipedriveClient(IClient): + """Builder class for Pipedrive clients with different authentication methods. + + Supports: + - OAuth 2.0 authorization code flow + - API Token authentication + """ + + def __init__( + self, + client: PipedriveRESTClientViaOAuth | PipedriveRESTClientViaToken, + ) -> None: + """Initialize with a Pipedrive client object.""" + super().__init__() + self.client = client + + @override + def get_client( + self, + ) -> PipedriveRESTClientViaOAuth | PipedriveRESTClientViaToken: + """Return the Pipedrive client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: PipedriveOAuthConfig | PipedriveTokenConfig, + ) -> "PipedriveClient": + """Build PipedriveClient with configuration. + + Args: + config: PipedriveOAuthConfig or PipedriveTokenConfig instance + + Returns: + PipedriveClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "PipedriveClient": + """Build PipedriveClient using configuration service. + + Supports two authentication strategies: + 1. OAUTH: OAuth 2.0 authorization code flow with access token + 2. TOKEN: API Token authentication + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + PipedriveClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get Pipedrive connector configuration" + ) + + connector_config = PipedriveConnectorConfig.model_validate( + raw_config + ) + + if connector_config.auth.authType == PipedriveAuthType.OAUTH: + access_token = connector_config.credentials.access_token or "" + client_id = connector_config.auth.clientId or "" + client_secret = connector_config.auth.clientSecret or "" + + # Try shared OAuth config if credentials are missing + oauth_config_id = connector_config.auth.oauthConfigId + if oauth_config_id and not (client_id and client_secret): + shared_cfg = await cls._find_shared_oauth_config( + config_service, oauth_config_id, logger + ) + if shared_cfg: + client_id = shared_cfg.resolved_client_id(client_id) + client_secret = shared_cfg.resolved_client_secret( + client_secret + ) + + if not access_token: + raise ValueError( + "Access token required for OAuth auth type" + ) + + oauth_cfg = PipedriveOAuthConfig( + access_token=access_token, + client_id=client_id, + client_secret=client_secret, + ) + return cls(oauth_cfg.create_client()) + + elif connector_config.auth.authType == PipedriveAuthType.TOKEN: + token = ( + connector_config.auth.apiToken + or connector_config.auth.token + or "" + ) + if not token: + raise ValueError( + "API token required for TOKEN auth type" + ) + + token_config = PipedriveTokenConfig(token=token) + return cls(token_config.create_client()) + + else: + raise ValueError( + f"Invalid auth type: {connector_config.auth.authType}" + ) + + except Exception as e: + logger.error( + f"Failed to build Pipedrive client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "PipedriveClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service for shared OAuth config + + Returns: + PipedriveClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("credentials", {}) or {} + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + access_token: str = str(credentials.get("access_token", "")) + if not access_token: + raise ValueError("Access token not found in toolset config") + + client_id: str = str(auth_config.get("clientId", "")) + client_secret: str = str(auth_config.get("clientSecret", "")) + + # Try shared OAuth config + oauth_config_id: str | None = cast( + str | None, auth_config.get("oauthConfigId") + ) + if oauth_config_id and config_service and not ( + client_id and client_secret + ): + shared_cfg = await cls._find_shared_oauth_config( + config_service, oauth_config_id, logger + ) + if shared_cfg: + client_id = shared_cfg.resolved_client_id(client_id) + client_secret = shared_cfg.resolved_client_secret( + client_secret + ) + + oauth_cfg = PipedriveOAuthConfig( + access_token=access_token, + client_id=client_id, + client_secret=client_secret, + ) + return cls(oauth_cfg.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Pipedrive client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _find_shared_oauth_config( + config_service: ConfigurationService, + oauth_config_id: str, + logger: logging.Logger, + ) -> PipedriveSharedOAuthConfigEntry | None: + """Look up shared OAuth config by ID from the config store. + + Args: + config_service: Configuration service instance + oauth_config_id: The shared OAuth config ID to match + logger: Logger instance + + Returns: + Matched PipedriveSharedOAuthConfigEntry or None + """ + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/pipedrive", default=[] + ) + entries: list[object] = list(raw) if isinstance(raw, list) else [] # type: ignore[reportUnknownArgumentType] + for entry in entries: + wrapper = PipedriveSharedOAuthWrapper.model_validate(entry) + if wrapper.entry_id == oauth_config_id: + return wrapper.config + except Exception as e: + logger.warning(f"Failed to fetch shared OAuth config: {e}") + return None + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Pipedrive.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get Pipedrive connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error(f"Failed to get Pipedrive connector config: {e}") + raise ValueError( + f"Failed to get Pipedrive connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/plusplus/plusplus.py b/backend/python/app/sources/client/plusplus/plusplus.py new file mode 100644 index 000000000..66ed641e4 --- /dev/null +++ b/backend/python/app/sources/client/plusplus/plusplus.py @@ -0,0 +1,315 @@ +"""PlusPlus client implementation. + +This module provides a client for interacting with the PlusPlus API using +API Key (Bearer token) authentication. + +API Reference: https://api.plusplus.app/v1 +""" + +import base64 +import json +import logging +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class PlusPlusResponse(BaseModel): + """Standardized PlusPlus API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client class +# --------------------------------------------------------------------------- + + +class PlusPlusRESTClientViaToken(HTTPClient): + """PlusPlus REST client via API Key (Bearer token). + + Args: + token: The API key (Bearer token) + base_url: API base URL (default: https://api.plusplus.app/v1) + """ + + def __init__( + self, + token: str, + base_url: str = "https://api.plusplus.app/v1", + ) -> None: + super().__init__(token, token_type="Bearer") + self.base_url = base_url + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class PlusPlusTokenConfig(BaseModel): + """Configuration for PlusPlus client via API Key. + + Args: + token: The API key (Bearer token) + base_url: API base URL (default: https://api.plusplus.app/v1) + """ + + token: str + base_url: str = "https://api.plusplus.app/v1" + + def create_client(self) -> PlusPlusRESTClientViaToken: + return PlusPlusRESTClientViaToken(self.token, self.base_url) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class PlusPlusAuthConfig(BaseModel): + """Auth section of the PlusPlus connector configuration from etcd.""" + + apiToken: str | None = None + token: str | None = None + + class Config: + extra = "allow" + + +class PlusPlusCredentialsConfig(BaseModel): + """Credentials section of the PlusPlus connector configuration.""" + + access_token: str | None = None + + class Config: + extra = "allow" + + +class PlusPlusConnectorConfig(BaseModel): + """Top-level PlusPlus connector configuration from etcd.""" + + auth: PlusPlusAuthConfig = Field(default_factory=PlusPlusAuthConfig) + credentials: PlusPlusCredentialsConfig = Field( + default_factory=PlusPlusCredentialsConfig + ) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class PlusPlusClient(IClient): + """Builder class for PlusPlus clients. + + Supports: + - API Key (Bearer token) authentication + """ + + def __init__( + self, + client: PlusPlusRESTClientViaToken, + ) -> None: + """Initialize with a PlusPlus client object.""" + super().__init__() + self.client = client + + @override + def get_client(self) -> PlusPlusRESTClientViaToken: + """Return the PlusPlus client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: PlusPlusTokenConfig, + ) -> "PlusPlusClient": + """Build PlusPlusClient with configuration. + + Args: + config: PlusPlusTokenConfig instance + + Returns: + PlusPlusClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "PlusPlusClient": + """Build PlusPlusClient using configuration service. + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + PlusPlusClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get PlusPlus connector configuration" + ) + + connector_config = PlusPlusConnectorConfig.model_validate( + raw_config + ) + + token = ( + connector_config.auth.apiToken + or connector_config.auth.token + or connector_config.credentials.access_token + or "" + ) + if not token: + raise ValueError( + "API token required for PlusPlus authentication" + ) + + token_config = PlusPlusTokenConfig(token=token) + return cls(token_config.create_client()) + + except Exception as e: + logger.error( + f"Failed to build PlusPlus client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "PlusPlusClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service + + Returns: + PlusPlusClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("credentials", {}) or {} + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + token: str = str( + credentials.get("access_token", "") + or auth_config.get("apiToken", "") + or auth_config.get("token", "") + ) + if not token: + raise ValueError( + "API token not found in toolset config" + ) + + token_config = PlusPlusTokenConfig(token=token) + return cls(token_config.create_client()) + + except Exception as e: + logger.error( + f"Failed to build PlusPlus client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for PlusPlus.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get PlusPlus connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error( + f"Failed to get PlusPlus connector config: {e}" + ) + raise ValueError( + f"Failed to get PlusPlus connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/procore/__init__.py b/backend/python/app/sources/client/procore/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/python/app/sources/client/procore/procore.py b/backend/python/app/sources/client/procore/procore.py new file mode 100644 index 000000000..c7c884aeb --- /dev/null +++ b/backend/python/app/sources/client/procore/procore.py @@ -0,0 +1,481 @@ +"""Procore client implementation. + +This module provides clients for interacting with the Procore API using: +1. OAuth 2.0 access token authentication (authorization code flow) - required +2. Bearer token authentication (for pre-obtained tokens) + +Procore is a construction management platform. The API provides access to +companies, projects, RFIs, submittals, documents, drawings, daily logs, +incidents, users, tasks, budgets, and change orders. + +Authentication Reference: https://developers.procore.com/documentation/oauth-introduction +API Reference: https://developers.procore.com/reference/rest/v1 +""" + +import base64 +import json +import logging +from enum import Enum +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class ProcoreAuthType(str, Enum): + """Authentication types supported by the Procore connector.""" + + OAUTH = "OAUTH" + TOKEN = "TOKEN" + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class ProcoreResponse(BaseModel): + """Standardized Procore API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client classes +# --------------------------------------------------------------------------- + + +class ProcoreRESTClientViaToken(HTTPClient): + """Procore REST client via Bearer token. + + Tokens are passed as Bearer tokens in the Authorization header. + + Args: + token: The Bearer token + """ + + def __init__(self, token: str) -> None: + super().__init__(token, token_type="Bearer") + self.base_url = "https://api.procore.com/rest/v1.0" + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +class ProcoreRESTClientViaOAuth(HTTPClient): + """Procore REST client via OAuth 2.0 access token. + + OAuth tokens are passed as Bearer tokens in the Authorization header. + + Args: + access_token: The OAuth access token + client_id: OAuth client ID (for reference / token refresh) + client_secret: OAuth client secret (for reference / token refresh) + """ + + def __init__( + self, + access_token: str, + client_id: str | None = None, + client_secret: str | None = None, + ) -> None: + super().__init__(access_token, "Bearer") + self.base_url = "https://api.procore.com/rest/v1.0" + self.access_token = access_token + self.client_id = client_id + self.client_secret = client_secret + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class ProcoreTokenConfig(BaseModel): + """Configuration for Procore client via Bearer token. + + Args: + token: The Bearer token + """ + + token: str + + def create_client(self) -> ProcoreRESTClientViaToken: + return ProcoreRESTClientViaToken(self.token) + + +class ProcoreOAuthConfig(BaseModel): + """Configuration for Procore client via OAuth 2.0. + + Args: + access_token: The OAuth access token + client_id: OAuth client ID + client_secret: OAuth client secret + """ + + access_token: str + client_id: str | None = None + client_secret: str | None = None + + def create_client(self) -> ProcoreRESTClientViaOAuth: + return ProcoreRESTClientViaOAuth( + self.access_token, + self.client_id, + self.client_secret, + ) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class ProcoreAuthConfig(BaseModel): + """Auth section of the Procore connector configuration from etcd.""" + + authType: ProcoreAuthType = ProcoreAuthType.OAUTH + apiToken: str | None = None + token: str | None = None + clientId: str | None = None + clientSecret: str | None = None + redirectUri: str | None = None + oauthConfigId: str | None = None + + class Config: + extra = "allow" + + +class ProcoreCredentialsConfig(BaseModel): + """Credentials section of the Procore connector configuration.""" + + access_token: str | None = None + refresh_token: str | None = None + + class Config: + extra = "allow" + + +class ProcoreConnectorConfig(BaseModel): + """Top-level Procore connector configuration from etcd.""" + + auth: ProcoreAuthConfig = Field(default_factory=ProcoreAuthConfig) + credentials: ProcoreCredentialsConfig = Field( + default_factory=ProcoreCredentialsConfig + ) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class ProcoreClient(IClient): + """Builder class for Procore clients with different authentication methods. + + Supports: + - OAuth 2.0 access token authentication (primary) + - Bearer token authentication + """ + + def __init__( + self, + client: ProcoreRESTClientViaToken | ProcoreRESTClientViaOAuth, + ) -> None: + """Initialize with a Procore client object.""" + super().__init__() + self.client = client + + @override + def get_client( + self, + ) -> ProcoreRESTClientViaToken | ProcoreRESTClientViaOAuth: + """Return the Procore client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: ProcoreTokenConfig | ProcoreOAuthConfig, + ) -> "ProcoreClient": + """Build ProcoreClient with configuration. + + Args: + config: ProcoreTokenConfig or ProcoreOAuthConfig instance + + Returns: + ProcoreClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "ProcoreClient": + """Build ProcoreClient using configuration service. + + Supports two authentication strategies: + 1. OAUTH: For OAuth 2.0 access tokens (primary) + 2. TOKEN: For pre-obtained Bearer tokens + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + ProcoreClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError("Failed to get Procore connector configuration") + + connector_config = ProcoreConnectorConfig.model_validate(raw_config) + + if connector_config.auth.authType == ProcoreAuthType.OAUTH: + access_token = connector_config.credentials.access_token or "" + client_id = connector_config.auth.clientId or "" + client_secret = connector_config.auth.clientSecret or "" + + # Try shared OAuth config if credentials are missing + oauth_config_id = connector_config.auth.oauthConfigId + if oauth_config_id and not (client_id and client_secret): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/procore", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + if not access_token: + raise ValueError( + "Access token required for OAuth auth type" + ) + + oauth_cfg = ProcoreOAuthConfig( + access_token=access_token, + client_id=client_id, + client_secret=client_secret, + ) + return cls(oauth_cfg.create_client()) + + elif connector_config.auth.authType == ProcoreAuthType.TOKEN: + token = ( + connector_config.auth.apiToken + or connector_config.auth.token + or "" + ) + if not token: + raise ValueError( + "Token required for TOKEN auth type" + ) + + token_config = ProcoreTokenConfig(token=token) + return cls(token_config.create_client()) + + else: + raise ValueError( + f"Invalid auth type: {connector_config.auth.authType}" + ) + + except Exception as e: + logger.error( + f"Failed to build Procore client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "ProcoreClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service for shared OAuth config + + Returns: + ProcoreClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("credentials", {}) or {} + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + access_token: str = str(credentials.get("access_token", "")) + if not access_token: + raise ValueError("Access token not found in toolset config") + + client_id: str = str(auth_config.get("clientId", "")) + client_secret: str = str(auth_config.get("clientSecret", "")) + + # Try shared OAuth config + oauth_config_id: str | None = cast( + str | None, auth_config.get("oauthConfigId") + ) + if oauth_config_id and config_service and not ( + client_id and client_secret + ): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/procore", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + oauth_cfg = ProcoreOAuthConfig( + access_token=access_token, + client_id=client_id, + client_secret=client_secret, + ) + return cls(oauth_cfg.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Procore client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Procore.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get Procore connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error(f"Failed to get Procore connector config: {e}") + raise ValueError( + f"Failed to get Procore connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/quickbooks/quickbooks.py b/backend/python/app/sources/client/quickbooks/quickbooks.py new file mode 100644 index 000000000..a12cf355c --- /dev/null +++ b/backend/python/app/sources/client/quickbooks/quickbooks.py @@ -0,0 +1,450 @@ +"""QuickBooks Online client implementation. + +This module provides a client for interacting with the QuickBooks Online API +using OAuth 2.0 (authorization code flow). + +The base URL includes the company_id: +https://quickbooks.api.intuit.com/v3/company/{company_id} + +OAuth Auth Endpoint: https://appcenter.intuit.com/connect/oauth2 +OAuth Token Endpoint: https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer +Auth Method: "body" + +Authentication Reference: https://developer.intuit.com/app/developer/qbo/docs/develop/authentication-and-authorization +API Reference: https://developer.intuit.com/app/developer/qbo/docs/api/accounting/all-entities +""" + +import base64 +import json +import logging +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class QuickBooksResponse(BaseModel): + """Standardized QuickBooks API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client class +# --------------------------------------------------------------------------- + + +class QuickBooksRESTClientViaOAuth(HTTPClient): + """QuickBooks Online REST client via OAuth 2.0 access token. + + OAuth tokens are passed as Bearer tokens in the Authorization header. + The base URL includes the company_id for all API operations. + + Args: + access_token: The OAuth access token + company_id: The QuickBooks company (realm) ID + client_id: OAuth client ID (for token refresh) + client_secret: OAuth client secret (for token refresh) + """ + + def __init__( + self, + access_token: str, + company_id: str, + client_id: str | None = None, + client_secret: str | None = None, + ) -> None: + super().__init__(access_token, "Bearer") + self.base_url = ( + f"https://quickbooks.api.intuit.com/v3/company/{company_id}" + ) + self.company_id = company_id + self.access_token = access_token + self.client_id = client_id + self.client_secret = client_secret + self.headers["Content-Type"] = "application/json" + self.headers["Accept"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL including company ID.""" + return self.base_url + + def get_company_id(self) -> str: + """Get the company (realm) ID.""" + return self.company_id + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class QuickBooksOAuthConfig(BaseModel): + """Configuration for QuickBooks client via OAuth 2.0. + + Args: + access_token: The OAuth access token + company_id: The QuickBooks company (realm) ID + client_id: OAuth client ID + client_secret: OAuth client secret + """ + + access_token: str + company_id: str + client_id: str | None = None + client_secret: str | None = None + + def create_client(self) -> QuickBooksRESTClientViaOAuth: + return QuickBooksRESTClientViaOAuth( + self.access_token, + self.company_id, + self.client_id, + self.client_secret, + ) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class QuickBooksAuthConfig(BaseModel): + """Auth section of the QuickBooks connector configuration from etcd.""" + + companyId: str | None = None + clientId: str | None = None + clientSecret: str | None = None + redirectUri: str | None = None + oauthConfigId: str | None = None + + class Config: + extra = "allow" + + +class QuickBooksCredentialsConfig(BaseModel): + """Credentials section of the QuickBooks connector configuration.""" + + access_token: str | None = None + refresh_token: str | None = None + + class Config: + extra = "allow" + + +class QuickBooksConnectorConfig(BaseModel): + """Top-level QuickBooks connector configuration from etcd.""" + + auth: QuickBooksAuthConfig = Field(default_factory=QuickBooksAuthConfig) + credentials: QuickBooksCredentialsConfig = Field( + default_factory=QuickBooksCredentialsConfig + ) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class QuickBooksClient(IClient): + """Builder class for QuickBooks Online clients. + + Supports: + - OAuth 2.0 access token authentication (authorization code flow) + """ + + def __init__( + self, + client: QuickBooksRESTClientViaOAuth, + ) -> None: + """Initialize with a QuickBooks client object.""" + super().__init__() + self.client = client + + @override + def get_client(self) -> QuickBooksRESTClientViaOAuth: + """Return the QuickBooks client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @property + def company_id(self) -> str: + """Return the company (realm) ID.""" + return self.client.get_company_id() + + @classmethod + def build_with_config( + cls, + config: QuickBooksOAuthConfig, + ) -> "QuickBooksClient": + """Build QuickBooksClient with configuration. + + Args: + config: QuickBooksOAuthConfig instance + + Returns: + QuickBooksClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "QuickBooksClient": + """Build QuickBooksClient using configuration service. + + Supports OAuth 2.0 authentication (authorization code flow). + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + QuickBooksClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get QuickBooks connector configuration" + ) + + connector_config = QuickBooksConnectorConfig.model_validate( + raw_config + ) + + access_token = connector_config.credentials.access_token or "" + company_id = connector_config.auth.companyId or "" + client_id = connector_config.auth.clientId or "" + client_secret = connector_config.auth.clientSecret or "" + + if not company_id: + raise ValueError("Company ID (realm ID) is required") + + # Try shared OAuth config if credentials are missing + oauth_config_id = connector_config.auth.oauthConfigId + if oauth_config_id and not (client_id and client_secret): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/quickbooks", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + if not access_token: + raise ValueError( + "Access token required for OAuth auth type" + ) + + oauth_cfg = QuickBooksOAuthConfig( + access_token=access_token, + company_id=company_id, + client_id=client_id, + client_secret=client_secret, + ) + return cls(oauth_cfg.create_client()) + + except Exception as e: + logger.error( + f"Failed to build QuickBooks client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "QuickBooksClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service for shared OAuth config + + Returns: + QuickBooksClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("credentials", {}) or {} + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + access_token: str = str(credentials.get("access_token", "")) + if not access_token: + raise ValueError( + "Access token not found in toolset config" + ) + + company_id: str = str(auth_config.get("companyId", "")) + if not company_id: + raise ValueError( + "Company ID not found in toolset config" + ) + + client_id: str = str(auth_config.get("clientId", "")) + client_secret: str = str(auth_config.get("clientSecret", "")) + + # Try shared OAuth config + oauth_config_id: str | None = cast( + str | None, auth_config.get("oauthConfigId") + ) + if oauth_config_id and config_service and not ( + client_id and client_secret + ): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/quickbooks", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + oauth_cfg = QuickBooksOAuthConfig( + access_token=access_token, + company_id=company_id, + client_id=client_id, + client_secret=client_secret, + ) + return cls(oauth_cfg.create_client()) + + except Exception as e: + logger.error( + f"Failed to build QuickBooks client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for QuickBooks.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get QuickBooks connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error( + f"Failed to get QuickBooks connector config: {e}" + ) + raise ValueError( + f"Failed to get QuickBooks connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/quip/quip.py b/backend/python/app/sources/client/quip/quip.py new file mode 100644 index 000000000..f1d987c9a --- /dev/null +++ b/backend/python/app/sources/client/quip/quip.py @@ -0,0 +1,415 @@ +"""Quip client implementation. + +This module provides clients for interacting with the Quip API using either: +1. OAuth 2.0 access token authentication +2. Personal Access Token (Bearer) + +Authentication Reference: https://quip.com/dev/automation/documentation +API Base URL: https://platform.quip.com/1 +OAuth Auth Endpoint: https://platform.quip.com/1/oauth/login +OAuth Token Endpoint: https://platform.quip.com/1/oauth/access_token +""" + +import base64 +import json +import logging +from enum import Enum +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class QuipAuthType(str, Enum): + """Authentication types supported by the Quip connector.""" + + OAUTH = "OAUTH" + PERSONAL_TOKEN = "PERSONAL_TOKEN" + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class QuipResponse(BaseModel): + """Standardized Quip API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field( + ..., description="Whether the request was successful" + ) + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, + description="Response data (JSON) or file content (bytes)", + ) + error: str | None = Field( + default=None, description="Error message if failed" + ) + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client classes +# --------------------------------------------------------------------------- + + +class QuipRESTClientViaToken(HTTPClient): + """Quip REST client via Personal Access Token. + + Personal tokens are passed as Bearer tokens in the Authorization header. + + Args: + token: The personal access token + timeout: Request timeout in seconds + """ + + def __init__( + self, + token: str, + timeout: float = 30.0, + ) -> None: + super().__init__(token, token_type="Bearer", timeout=timeout) + self.base_url = "https://platform.quip.com/1" + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +class QuipRESTClientViaOAuth(HTTPClient): + """Quip REST client via OAuth 2.0 access token. + + OAuth tokens are passed as Bearer tokens in the Authorization header. + + Args: + access_token: The OAuth access token + client_id: OAuth client ID (for reference / token refresh) + client_secret: OAuth client secret (for reference / token refresh) + timeout: Request timeout in seconds + """ + + def __init__( + self, + access_token: str, + client_id: str | None = None, + client_secret: str | None = None, + timeout: float = 30.0, + ) -> None: + super().__init__(access_token, "Bearer", timeout=timeout) + self.base_url = "https://platform.quip.com/1" + self.access_token = access_token + self.client_id = client_id + self.client_secret = client_secret + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class QuipTokenConfig(BaseModel): + """Configuration for Quip client via Personal Access Token. + + Args: + token: The personal access token + """ + + token: str + + def create_client(self) -> QuipRESTClientViaToken: + return QuipRESTClientViaToken(self.token) + + +class QuipOAuthConfig(BaseModel): + """Configuration for Quip client via OAuth 2.0. + + Args: + access_token: The OAuth access token + client_id: OAuth client ID + client_secret: OAuth client secret + """ + + access_token: str + client_id: str | None = None + client_secret: str | None = None + + def create_client(self) -> QuipRESTClientViaOAuth: + return QuipRESTClientViaOAuth( + self.access_token, + self.client_id, + self.client_secret, + ) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class QuipAuthConfig(BaseModel): + """Auth section of the Quip connector configuration from etcd.""" + + authType: QuipAuthType = QuipAuthType.PERSONAL_TOKEN + apiToken: str | None = None + token: str | None = None + clientId: str | None = None + clientSecret: str | None = None + redirectUri: str | None = None + oauthConfigId: str | None = None + + class Config: + extra = "allow" + + +class QuipCredentialsConfig(BaseModel): + """Credentials section of the Quip connector configuration.""" + + access_token: str | None = None + refresh_token: str | None = None + + class Config: + extra = "allow" + + +class QuipConnectorConfig(BaseModel): + """Top-level Quip connector configuration from etcd.""" + + auth: QuipAuthConfig = Field(default_factory=QuipAuthConfig) + credentials: QuipCredentialsConfig = Field( + default_factory=QuipCredentialsConfig + ) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class QuipClient(IClient): + """Builder class for Quip clients with different authentication methods. + + Supports: + - Personal Access Token authentication + - OAuth 2.0 access token authentication + """ + + def __init__( + self, + client: QuipRESTClientViaToken | QuipRESTClientViaOAuth, + ) -> None: + """Initialize with a Quip client object.""" + super().__init__() + self.client = client + + @override + def get_client( + self, + ) -> QuipRESTClientViaToken | QuipRESTClientViaOAuth: + """Return the Quip client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: QuipTokenConfig | QuipOAuthConfig, + ) -> "QuipClient": + """Build QuipClient with configuration. + + Args: + config: QuipTokenConfig or QuipOAuthConfig instance + + Returns: + QuipClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "QuipClient": + """Build QuipClient using configuration service. + + Supports two authentication strategies: + 1. PERSONAL_TOKEN: For personal access tokens + 2. OAUTH: For OAuth 2.0 access tokens + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + QuipClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get Quip connector configuration" + ) + + connector_config = QuipConnectorConfig.model_validate(raw_config) + + if connector_config.auth.authType == QuipAuthType.OAUTH: + access_token = ( + connector_config.credentials.access_token or "" + ) + client_id = connector_config.auth.clientId or "" + client_secret = connector_config.auth.clientSecret or "" + + # Try shared OAuth config if credentials are missing + oauth_config_id = connector_config.auth.oauthConfigId + if oauth_config_id and not (client_id and client_secret): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/quip", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + if not access_token: + raise ValueError( + "Access token required for OAuth auth type" + ) + + oauth_cfg = QuipOAuthConfig( + access_token=access_token, + client_id=client_id, + client_secret=client_secret, + ) + return cls(oauth_cfg.create_client()) + + elif ( + connector_config.auth.authType + == QuipAuthType.PERSONAL_TOKEN + ): + token = ( + connector_config.auth.apiToken + or connector_config.auth.token + or "" + ) + if not token: + raise ValueError( + "Personal token required for PERSONAL_TOKEN auth type" + ) + + token_config = QuipTokenConfig(token=token) + return cls(token_config.create_client()) + + else: + raise ValueError( + f"Invalid auth type: {connector_config.auth.authType}" + ) + + except Exception as e: + logger.error( + f"Failed to build Quip client from services: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Quip.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get Quip connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error(f"Failed to get Quip connector config: {e}") + raise ValueError( + f"Failed to get Quip connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/redmine/redmine.py b/backend/python/app/sources/client/redmine/redmine.py new file mode 100644 index 000000000..11e9b8c5d --- /dev/null +++ b/backend/python/app/sources/client/redmine/redmine.py @@ -0,0 +1,436 @@ +"""Redmine client implementation. + +This module provides clients for interacting with the Redmine API using either: +1. API Key authentication (X-Redmine-API-Key header) +2. Basic Auth (username + password) + +The base URL is the instance URL: https://{instance} +All endpoints return JSON when .json is appended. + +Authentication Reference: https://www.redmine.org/projects/redmine/wiki/Rest_api#Authentication +API Reference: https://www.redmine.org/projects/redmine/wiki/Rest_api +""" + +import base64 +import json +import logging +from enum import Enum +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class RedmineAuthType(str, Enum): + """Authentication types supported by the Redmine connector.""" + + API_KEY = "API_KEY" + BASIC = "BASIC" + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class RedmineResponse(BaseModel): + """Standardized Redmine API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client classes +# --------------------------------------------------------------------------- + + +class RedmineRESTClientViaApiKey(HTTPClient): + """Redmine REST client via API Key (X-Redmine-API-Key header). + + Args: + api_key: The Redmine API key + instance_url: Redmine instance URL (e.g. "redmine.example.com") + """ + + def __init__(self, api_key: str, instance_url: str) -> None: + # Initialize with empty token; we set the custom header below + super().__init__("", token_type="Bearer") + self.base_url = f"https://{instance_url}" + self.instance_url = instance_url + self.api_key = api_key + # Remove the default Authorization header and set Redmine-specific key + _ = self.headers.pop("Authorization", None) + self.headers["X-Redmine-API-Key"] = api_key + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + def get_instance_url(self) -> str: + """Get the instance URL.""" + return self.instance_url + + +class RedmineRESTClientViaBasicAuth(HTTPClient): + """Redmine REST client via Basic Auth (username + password). + + Args: + username: Redmine username + password: Redmine password + instance_url: Redmine instance URL (e.g. "redmine.example.com") + """ + + def __init__( + self, + username: str, + password: str, + instance_url: str, + ) -> None: + super().__init__("", token_type="Basic") + self.base_url = f"https://{instance_url}" + self.instance_url = instance_url + self.username = username + credentials = base64.b64encode( + f"{username}:{password}".encode() + ).decode("utf-8") + self.headers["Authorization"] = f"Basic {credentials}" + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + def get_instance_url(self) -> str: + """Get the instance URL.""" + return self.instance_url + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class RedmineApiKeyConfig(BaseModel): + """Configuration for Redmine client via API Key. + + Args: + api_key: The Redmine API key + instance_url: Redmine instance URL (e.g. "redmine.example.com") + """ + + api_key: str + instance_url: str + + def create_client(self) -> RedmineRESTClientViaApiKey: + return RedmineRESTClientViaApiKey(self.api_key, self.instance_url) + + +class RedmineBasicAuthConfig(BaseModel): + """Configuration for Redmine client via Basic Auth. + + Args: + username: Redmine username + password: Redmine password + instance_url: Redmine instance URL (e.g. "redmine.example.com") + """ + + username: str + password: str + instance_url: str + + def create_client(self) -> RedmineRESTClientViaBasicAuth: + return RedmineRESTClientViaBasicAuth( + self.username, self.password, self.instance_url + ) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class RedmineAuthConfigModel(BaseModel): + """Auth section of the Redmine connector configuration from etcd.""" + + authType: RedmineAuthType = RedmineAuthType.API_KEY + instanceUrl: str | None = None + apiKey: str | None = None + username: str | None = None + password: str | None = None + + class Config: + extra = "allow" + + +class RedmineCredentialsConfig(BaseModel): + """Credentials section of the Redmine connector configuration.""" + + api_key: str | None = None + + class Config: + extra = "allow" + + +class RedmineConnectorConfig(BaseModel): + """Top-level Redmine connector configuration from etcd.""" + + auth: RedmineAuthConfigModel = Field( + default_factory=RedmineAuthConfigModel + ) + credentials: RedmineCredentialsConfig = Field( + default_factory=RedmineCredentialsConfig + ) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class RedmineClient(IClient): + """Builder class for Redmine clients with different authentication methods. + + Supports: + - API Key authentication (X-Redmine-API-Key header) + - Basic Auth (username + password) + """ + + def __init__( + self, + client: RedmineRESTClientViaApiKey | RedmineRESTClientViaBasicAuth, + ) -> None: + """Initialize with a Redmine client object.""" + super().__init__() + self.client = client + + @override + def get_client( + self, + ) -> RedmineRESTClientViaApiKey | RedmineRESTClientViaBasicAuth: + """Return the Redmine client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: RedmineApiKeyConfig | RedmineBasicAuthConfig, + ) -> "RedmineClient": + """Build RedmineClient with configuration. + + Args: + config: RedmineApiKeyConfig or RedmineBasicAuthConfig instance + + Returns: + RedmineClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "RedmineClient": + """Build RedmineClient using configuration service. + + Supports two authentication strategies: + 1. API_KEY: API key in X-Redmine-API-Key header + 2. BASIC: Basic Auth with username and password + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + RedmineClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get Redmine connector configuration" + ) + + connector_config = RedmineConnectorConfig.model_validate( + raw_config + ) + + instance_url = connector_config.auth.instanceUrl or "" + if not instance_url: + raise ValueError("Instance URL is required") + + if connector_config.auth.authType == RedmineAuthType.API_KEY: + api_key = ( + connector_config.auth.apiKey + or connector_config.credentials.api_key + or "" + ) + if not api_key: + raise ValueError( + "API key required for API_KEY auth type" + ) + + api_key_cfg = RedmineApiKeyConfig( + api_key=api_key, instance_url=instance_url + ) + return cls(api_key_cfg.create_client()) + + elif connector_config.auth.authType == RedmineAuthType.BASIC: + username = connector_config.auth.username or "" + password = connector_config.auth.password or "" + + if not (username and password): + raise ValueError( + "Username and password required for Basic auth type" + ) + + basic_cfg = RedmineBasicAuthConfig( + username=username, + password=password, + instance_url=instance_url, + ) + return cls(basic_cfg.create_client()) + + else: + raise ValueError( + f"Invalid auth type: {connector_config.auth.authType}" + ) + + except Exception as e: + logger.error( + f"Failed to build Redmine client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "RedmineClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service + + Returns: + RedmineClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("credentials", {}) or {} + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + instance_url: str = str(auth_config.get("instanceUrl", "")) + if not instance_url: + raise ValueError( + "Instance URL not found in toolset config" + ) + + api_key: str = str( + credentials.get("api_key", "") + or auth_config.get("apiKey", "") + ) + if not api_key: + raise ValueError("API key not found in toolset config") + + api_key_cfg = RedmineApiKeyConfig( + api_key=api_key, instance_url=instance_url + ) + return cls(api_key_cfg.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Redmine client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Redmine.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get Redmine connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error( + f"Failed to get Redmine connector config: {e}" + ) + raise ValueError( + f"Failed to get Redmine connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/seismic/seismic.py b/backend/python/app/sources/client/seismic/seismic.py new file mode 100644 index 000000000..c90db2536 --- /dev/null +++ b/backend/python/app/sources/client/seismic/seismic.py @@ -0,0 +1,530 @@ +"""Seismic client implementation. + +This module provides clients for interacting with the Seismic API using either: +1. OAuth 2.0 (authorization code flow) +2. Bearer Token authentication + +OAuth Auth Endpoint: https://auth.seismic.com/tenants/{tenant_id}/connect/authorize +OAuth Token Endpoint: https://auth.seismic.com/tenants/{tenant_id}/connect/token +Auth Method: body (credentials sent in POST body) +API Reference: https://api.seismic.com/v2 +""" + +import base64 +import json +import logging +from enum import Enum +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class SeismicAuthType(str, Enum): + """Authentication types supported by the Seismic connector.""" + + OAUTH = "OAUTH" + TOKEN = "TOKEN" + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class SeismicResponse(BaseModel): + """Standardized Seismic API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client classes +# --------------------------------------------------------------------------- + + +class SeismicRESTClientViaOAuth(HTTPClient): + """Seismic REST client via OAuth 2.0 authorization code flow. + + OAuth tokens are passed as Bearer tokens in the Authorization header. + Token exchange uses the "body" method (client credentials in POST body). + + Args: + access_token: The OAuth access token + client_id: OAuth client ID (for token refresh) + client_secret: OAuth client secret (for token refresh) + tenant_id: Seismic tenant ID (for OAuth endpoints) + redirect_uri: OAuth redirect URI + base_url: API base URL (default: https://api.seismic.com/v2) + """ + + def __init__( + self, + access_token: str, + client_id: str | None = None, + client_secret: str | None = None, + tenant_id: str | None = None, + redirect_uri: str | None = None, + base_url: str = "https://api.seismic.com/v2", + ) -> None: + super().__init__(access_token, "Bearer") + self.base_url = base_url + self.access_token = access_token + self.client_id = client_id + self.client_secret = client_secret + self.tenant_id = tenant_id + self.redirect_uri = redirect_uri + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +class SeismicRESTClientViaToken(HTTPClient): + """Seismic REST client via pre-generated Bearer token. + + Simple authentication using a pre-generated token passed directly + in the Authorization header. + + Args: + token: The pre-generated Bearer token + base_url: API base URL (default: https://api.seismic.com/v2) + """ + + def __init__( + self, + token: str, + base_url: str = "https://api.seismic.com/v2", + ) -> None: + super().__init__(token, token_type="Bearer") + self.base_url = base_url + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class SeismicOAuthConfig(BaseModel): + """Configuration for Seismic client via OAuth 2.0. + + Args: + access_token: The OAuth access token + client_id: OAuth client ID + client_secret: OAuth client secret + tenant_id: Seismic tenant ID (for OAuth endpoints) + redirect_uri: OAuth redirect URI + base_url: API base URL (default: https://api.seismic.com/v2) + """ + + access_token: str + client_id: str | None = None + client_secret: str | None = None + tenant_id: str | None = None + redirect_uri: str | None = None + base_url: str = "https://api.seismic.com/v2" + + def create_client(self) -> SeismicRESTClientViaOAuth: + return SeismicRESTClientViaOAuth( + self.access_token, + self.client_id, + self.client_secret, + self.tenant_id, + self.redirect_uri, + self.base_url, + ) + + +class SeismicTokenConfig(BaseModel): + """Configuration for Seismic client via Bearer token. + + Args: + token: The pre-generated Bearer token + base_url: API base URL (default: https://api.seismic.com/v2) + """ + + token: str + base_url: str = "https://api.seismic.com/v2" + + def create_client(self) -> SeismicRESTClientViaToken: + return SeismicRESTClientViaToken(self.token, self.base_url) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class SeismicAuthConfigModel(BaseModel): + """Auth section of the Seismic connector configuration from etcd.""" + + authType: SeismicAuthType = SeismicAuthType.TOKEN + apiToken: str | None = None + token: str | None = None + clientId: str | None = None + clientSecret: str | None = None + tenantId: str | None = None + redirectUri: str | None = None + oauthConfigId: str | None = None + + class Config: + extra = "allow" + + +class SeismicCredentialsConfig(BaseModel): + """Credentials section of the Seismic connector configuration.""" + + access_token: str | None = None + refresh_token: str | None = None + + class Config: + extra = "allow" + + +class SeismicConnectorConfig(BaseModel): + """Top-level Seismic connector configuration from etcd.""" + + auth: SeismicAuthConfigModel = Field( + default_factory=SeismicAuthConfigModel + ) + credentials: SeismicCredentialsConfig = Field( + default_factory=SeismicCredentialsConfig + ) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class SeismicClient(IClient): + """Builder class for Seismic clients with different authentication methods. + + Supports: + - OAuth 2.0 authorization code flow + - Pre-generated Bearer token + """ + + def __init__( + self, + client: SeismicRESTClientViaOAuth | SeismicRESTClientViaToken, + ) -> None: + """Initialize with a Seismic client object.""" + super().__init__() + self.client = client + + @override + def get_client( + self, + ) -> SeismicRESTClientViaOAuth | SeismicRESTClientViaToken: + """Return the Seismic client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: SeismicOAuthConfig | SeismicTokenConfig, + ) -> "SeismicClient": + """Build SeismicClient with configuration. + + Args: + config: SeismicOAuthConfig or SeismicTokenConfig instance + + Returns: + SeismicClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "SeismicClient": + """Build SeismicClient using configuration service. + + Supports two authentication strategies: + 1. OAUTH: OAuth 2.0 access token + 2. TOKEN: Pre-generated Bearer token + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + SeismicClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get Seismic connector configuration" + ) + + connector_config = SeismicConnectorConfig.model_validate( + raw_config + ) + + if connector_config.auth.authType == SeismicAuthType.OAUTH: + access_token = connector_config.credentials.access_token or "" + client_id = connector_config.auth.clientId or "" + client_secret = connector_config.auth.clientSecret or "" + tenant_id = connector_config.auth.tenantId or "" + redirect_uri = connector_config.auth.redirectUri or "" + + # Try shared OAuth config if credentials are missing + oauth_config_id = connector_config.auth.oauthConfigId + if oauth_config_id and not (client_id and client_secret): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/seismic", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + tenant_id = str( + shared.get("tenantId") + or shared.get("tenant_id") + or tenant_id + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + if not access_token: + raise ValueError( + "Access token required for OAuth auth type" + ) + + oauth_cfg = SeismicOAuthConfig( + access_token=access_token, + client_id=client_id, + client_secret=client_secret, + tenant_id=tenant_id, + redirect_uri=redirect_uri, + ) + return cls(oauth_cfg.create_client()) + + elif connector_config.auth.authType == SeismicAuthType.TOKEN: + token = ( + connector_config.auth.apiToken + or connector_config.auth.token + or "" + ) + if not token: + raise ValueError( + "Token required for TOKEN auth type" + ) + + token_config = SeismicTokenConfig(token=token) + return cls(token_config.create_client()) + + else: + raise ValueError( + f"Invalid auth type: {connector_config.auth.authType}" + ) + + except Exception as e: + logger.error( + f"Failed to build Seismic client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "SeismicClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service for shared OAuth config + + Returns: + SeismicClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("credentials", {}) or {} + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + access_token: str = str(credentials.get("access_token", "")) + if not access_token: + raise ValueError( + "Access token not found in toolset config" + ) + + client_id: str = str(auth_config.get("clientId", "")) + client_secret: str = str(auth_config.get("clientSecret", "")) + tenant_id: str = str(auth_config.get("tenantId", "")) + + # Try shared OAuth config + oauth_config_id: str | None = cast( + str | None, auth_config.get("oauthConfigId") + ) + if oauth_config_id and config_service and not ( + client_id and client_secret + ): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/seismic", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + tenant_id = str( + shared.get("tenantId") + or shared.get("tenant_id") + or tenant_id + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + oauth_cfg = SeismicOAuthConfig( + access_token=access_token, + client_id=client_id, + client_secret=client_secret, + tenant_id=tenant_id, + ) + return cls(oauth_cfg.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Seismic client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Seismic.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get Seismic connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error(f"Failed to get Seismic connector config: {e}") + raise ValueError( + f"Failed to get Seismic connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/simpplr/simpplr.py b/backend/python/app/sources/client/simpplr/simpplr.py new file mode 100644 index 000000000..cef64e81f --- /dev/null +++ b/backend/python/app/sources/client/simpplr/simpplr.py @@ -0,0 +1,478 @@ +"""Simpplr client implementation. + +This module provides clients for interacting with the Simpplr API using either: +1. OAuth 2.0 access token authentication +2. Bearer Token authentication + +API Reference: https://api.simpplr.com/v1 +""" + +import base64 +import json +import logging +from enum import Enum +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class SimpplrAuthType(str, Enum): + """Authentication types supported by the Simpplr connector.""" + + OAUTH = "OAUTH" + TOKEN = "TOKEN" + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class SimpplrResponse(BaseModel): + """Standardized Simpplr API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client classes +# --------------------------------------------------------------------------- + + +class SimpplrRESTClientViaOAuth(HTTPClient): + """Simpplr REST client via OAuth 2.0 access token. + + OAuth tokens are passed as Bearer tokens in the Authorization header. + + Args: + access_token: The OAuth access token + client_id: OAuth client ID (for token refresh) + client_secret: OAuth client secret (for token refresh) + """ + + def __init__( + self, + access_token: str, + client_id: str | None = None, + client_secret: str | None = None, + ) -> None: + super().__init__(access_token, "Bearer") + self.base_url = "https://api.simpplr.com/v1" + self.access_token = access_token + self.client_id = client_id + self.client_secret = client_secret + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +class SimpplrRESTClientViaToken(HTTPClient): + """Simpplr REST client via Bearer Token. + + Args: + token: The bearer token + """ + + def __init__(self, token: str) -> None: + super().__init__(token, "Bearer") + self.base_url = "https://api.simpplr.com/v1" + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class SimpplrOAuthConfig(BaseModel): + """Configuration for Simpplr client via OAuth 2.0. + + Args: + access_token: The OAuth access token + client_id: OAuth client ID + client_secret: OAuth client secret + """ + + access_token: str + client_id: str | None = None + client_secret: str | None = None + + def create_client(self) -> SimpplrRESTClientViaOAuth: + return SimpplrRESTClientViaOAuth( + self.access_token, + self.client_id, + self.client_secret, + ) + + +class SimpplrTokenConfig(BaseModel): + """Configuration for Simpplr client via Bearer Token. + + Args: + token: The bearer token + """ + + token: str + + def create_client(self) -> SimpplrRESTClientViaToken: + return SimpplrRESTClientViaToken(self.token) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class SimpplrAuthConfigModel(BaseModel): + """Auth section of the Simpplr connector configuration from etcd.""" + + authType: SimpplrAuthType = SimpplrAuthType.OAUTH + apiToken: str | None = None + token: str | None = None + clientId: str | None = None + clientSecret: str | None = None + redirectUri: str | None = None + oauthConfigId: str | None = None + + class Config: + extra = "allow" + + +class SimpplrCredentialsConfig(BaseModel): + """Credentials section of the Simpplr connector configuration.""" + + access_token: str | None = None + refresh_token: str | None = None + + class Config: + extra = "allow" + + +class SimpplrConnectorConfig(BaseModel): + """Top-level Simpplr connector configuration from etcd.""" + + auth: SimpplrAuthConfigModel = Field(default_factory=SimpplrAuthConfigModel) + credentials: SimpplrCredentialsConfig = Field( + default_factory=SimpplrCredentialsConfig + ) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class SimpplrClient(IClient): + """Builder class for Simpplr clients with different authentication methods. + + Supports: + - OAuth 2.0 access token authentication + - Bearer Token authentication + """ + + def __init__( + self, + client: SimpplrRESTClientViaOAuth | SimpplrRESTClientViaToken, + ) -> None: + """Initialize with a Simpplr client object.""" + super().__init__() + self.client = client + + @override + def get_client( + self, + ) -> SimpplrRESTClientViaOAuth | SimpplrRESTClientViaToken: + """Return the Simpplr client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: SimpplrOAuthConfig | SimpplrTokenConfig, + ) -> "SimpplrClient": + """Build SimpplrClient with configuration. + + Args: + config: SimpplrOAuthConfig or SimpplrTokenConfig instance + + Returns: + SimpplrClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "SimpplrClient": + """Build SimpplrClient using configuration service. + + Supports two authentication strategies: + 1. OAUTH: OAuth 2.0 access token + 2. TOKEN: Bearer token + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + SimpplrClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get Simpplr connector configuration" + ) + + connector_config = SimpplrConnectorConfig.model_validate(raw_config) + + if connector_config.auth.authType == SimpplrAuthType.OAUTH: + access_token = connector_config.credentials.access_token or "" + client_id = connector_config.auth.clientId or "" + client_secret = connector_config.auth.clientSecret or "" + + # Try shared OAuth config if credentials are missing + oauth_config_id = connector_config.auth.oauthConfigId + if oauth_config_id and not (client_id and client_secret): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/simpplr", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + if not access_token: + raise ValueError( + "Access token required for OAuth auth type" + ) + + oauth_cfg = SimpplrOAuthConfig( + access_token=access_token, + client_id=client_id, + client_secret=client_secret, + ) + return cls(oauth_cfg.create_client()) + + elif connector_config.auth.authType == SimpplrAuthType.TOKEN: + token = ( + connector_config.auth.apiToken + or connector_config.auth.token + or "" + ) + if not token: + raise ValueError( + "Token required for TOKEN auth type" + ) + + token_config = SimpplrTokenConfig(token=token) + return cls(token_config.create_client()) + + else: + raise ValueError( + f"Invalid auth type: {connector_config.auth.authType}" + ) + + except Exception as e: + logger.error( + f"Failed to build Simpplr client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "SimpplrClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service for shared OAuth config + + Returns: + SimpplrClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("credentials", {}) or {} + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + access_token: str = str(credentials.get("access_token", "")) + if not access_token: + raise ValueError( + "Access token not found in toolset config" + ) + + client_id: str = str(auth_config.get("clientId", "")) + client_secret: str = str(auth_config.get("clientSecret", "")) + + # Try shared OAuth config + oauth_config_id: str | None = cast( + str | None, auth_config.get("oauthConfigId") + ) + if oauth_config_id and config_service and not ( + client_id and client_secret + ): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/simpplr", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + oauth_cfg = SimpplrOAuthConfig( + access_token=access_token, + client_id=client_id, + client_secret=client_secret, + ) + return cls(oauth_cfg.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Simpplr client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Simpplr.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get Simpplr connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error(f"Failed to get Simpplr connector config: {e}") + raise ValueError( + f"Failed to get Simpplr connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/slab/graphql_op.py b/backend/python/app/sources/client/slab/graphql_op.py new file mode 100644 index 000000000..f35effa35 --- /dev/null +++ b/backend/python/app/sources/client/slab/graphql_op.py @@ -0,0 +1,203 @@ +"""Registry of Slab GraphQL operations and fragments. + +Slab API: https://slab.com/api/ +GraphQL endpoint: POST https://api.slab.com/v1/graphql +""" + +from typing import Any + + +class SlabGraphQLOperations: + """Registry of Slab GraphQL operations and fragments.""" + + # Common fragments + FRAGMENTS = { + "UserFields": """ + fragment UserFields on User { + id + name + email + role + deactivatedAt + } + """, + "TopicFields": """ + fragment TopicFields on Topic { + id + name + description + postCount + createdAt + updatedAt + } + """, + "PostFields": """ + fragment PostFields on Post { + id + title + content + insertedAt + updatedAt + publishedAt + archivedAt + version + topics { + ...TopicFields + } + creator { + ...UserFields + } + } + """, + } + + # Query operations + QUERIES = { + "organization": { + "query": """ + query organization { + organization { + id + name + hostname + } + } + """, + "fragments": [], + "description": "Get organization information", + }, + "users": { + "query": """ + query users { + organization { + members { + ...UserFields + } + } + } + """, + "fragments": ["UserFields"], + "description": "List all users in the organization", + }, + "posts": { + "query": """ + query posts($status: PostStatus) { + posts(status: $status) { + ...PostFields + } + } + """, + "fragments": ["PostFields", "TopicFields", "UserFields"], + "description": "List posts with optional status filter", + }, + "post": { + "query": """ + query post($id: ID!) { + post(id: $id) { + ...PostFields + } + } + """, + "fragments": ["PostFields", "TopicFields", "UserFields"], + "description": "Get a single post by ID", + }, + "topics": { + "query": """ + query topics { + topics { + ...TopicFields + } + } + """, + "fragments": ["TopicFields"], + "description": "List all topics", + }, + "topic": { + "query": """ + query topic($id: ID!) { + topic(id: $id) { + ...TopicFields + posts { + ...PostFields + } + ancestors { + id + name + } + children { + id + name + } + } + } + """, + "fragments": ["TopicFields", "PostFields", "UserFields"], + "description": "Get a single topic by ID with its posts", + }, + "searchPosts": { + "query": """ + query searchPosts($query: String!) { + searchPosts(query: $query) { + ...PostFields + } + } + """, + "fragments": ["PostFields", "TopicFields", "UserFields"], + "description": "Search posts by query string", + }, + } + + # Mutation operations + MUTATIONS = { + "syncPost": { + "query": """ + mutation syncPost($input: SyncPostInput!) { + syncPost(input: $input) { + ...PostFields + } + } + """, + "fragments": ["PostFields", "TopicFields", "UserFields"], + "description": "Create or update a post via sync", + }, + } + + @classmethod + def get_operation_with_fragments( + cls, operation_type: str, operation_name: str + ) -> str: + """Get a complete GraphQL operation with all required fragments.""" + operations = cls.QUERIES if operation_type == "query" else cls.MUTATIONS + + if operation_name not in operations: + raise ValueError( + f"Operation {operation_name} not found in {operation_type}s" + ) + operation = operations[operation_name] + fragments_needed = operation.get("fragments", []) + + # Collect all fragments (deduplicate while preserving order) + seen: set[str] = set() + fragment_definitions: list[str] = [] + for fragment_name in fragments_needed: + if fragment_name in cls.FRAGMENTS and fragment_name not in seen: + fragment_definitions.append(cls.FRAGMENTS[fragment_name]) + seen.add(fragment_name) + + # Combine fragments and operation + if fragment_definitions: + return ( + "\n\n".join(fragment_definitions) + + "\n\n" + + operation["query"] + ) + return str(operation["query"]) + + @classmethod + def get_all_operations(cls) -> dict[str, dict[str, Any]]: + """Get all available operations.""" + return { + "queries": cls.QUERIES, + "mutations": cls.MUTATIONS, + "fragments": cls.FRAGMENTS, + } diff --git a/backend/python/app/sources/client/slab/slab.py b/backend/python/app/sources/client/slab/slab.py new file mode 100644 index 000000000..03c26df38 --- /dev/null +++ b/backend/python/app/sources/client/slab/slab.py @@ -0,0 +1,208 @@ +"""Slab client implementation. + +This module provides a client for interacting with the Slab GraphQL API using +an organization-level API token (Bearer token). + +Slab uses a GraphQL API exclusively (no REST endpoints). + +Authentication Reference: https://slab.com/api/ +GraphQL Endpoint: POST https://api.slab.com/v1/graphql +""" + +import logging +from typing import Any + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.graphql.client import GraphQLClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# GraphQL client class +# --------------------------------------------------------------------------- + + +class SlabGraphQLClientViaToken(GraphQLClient): + """Slab GraphQL client via API token. + + Slab uses organization-level API tokens passed as Bearer tokens + in the Authorization header. + + Args: + token: The Slab API token + timeout: Request timeout in seconds + """ + + def __init__(self, token: str, timeout: int = 30) -> None: + token = token.strip() if token else "" + if not token: + raise ValueError("Slab API token cannot be empty") + + auth_header = f"Bearer {token}" if not token.startswith("Bearer ") else token + + headers = { + "Authorization": auth_header, + "Content-Type": "application/json", + } + super().__init__( + endpoint="https://api.slab.com/v1/graphql", + headers=headers, + timeout=timeout, + ) + self.token = token + + def get_endpoint(self) -> str: + """Get the GraphQL endpoint.""" + return self.endpoint + + @override + def get_auth_header(self) -> str | None: + """Get the authorization header value.""" + if self.token and not self.token.startswith("Bearer "): + return f"Bearer {self.token}" + return self.token + + def get_token(self) -> str: + """Get the token.""" + return self.token + + def set_token(self, token: str) -> None: + """Set the token and update Authorization header.""" + self.token = token + if token and not token.startswith("Bearer "): + self.headers["Authorization"] = f"Bearer {token}" + else: + self.headers["Authorization"] = token + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class SlabTokenConfig(BaseModel): + """Configuration for Slab GraphQL client via API token. + + Args: + token: Slab API token + timeout: Request timeout in seconds + endpoint: GraphQL endpoint (defaults to Slab's endpoint) + """ + + token: str = Field(..., description="Slab API token") + timeout: int = Field( + default=30, description="Request timeout in seconds", gt=0 + ) + endpoint: str = Field( + default="https://api.slab.com/v1/graphql", + description="GraphQL endpoint URL", + ) + + def create_client(self) -> SlabGraphQLClientViaToken: + """Create a Slab GraphQL client.""" + return SlabGraphQLClientViaToken(self.token, self.timeout) + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class SlabClient(IClient): + """Builder class for Slab GraphQL clients. + + Slab only supports organization-level API token authentication. + """ + + def __init__( + self, + client: SlabGraphQLClientViaToken, + ) -> None: + """Initialize with a Slab GraphQL client object.""" + self.client = client + + @override + def get_client(self) -> SlabGraphQLClientViaToken: + """Return the Slab GraphQL client object.""" + return self.client + + @classmethod + def build_with_config( + cls, + config: SlabTokenConfig, + ) -> "SlabClient": + """Build SlabClient with configuration. + + Args: + config: SlabTokenConfig instance + + Returns: + SlabClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "SlabClient": + """Build SlabClient using configuration service. + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + SlabClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError("Failed to get Slab connector configuration") + + auth_config: dict[str, Any] = raw_config.get("auth", {}) + timeout: int = raw_config.get("timeout", 30) + + token = auth_config.get("apiToken", "") + if not token: + raise ValueError("API token required for Slab authentication") + + client = SlabGraphQLClientViaToken(token, timeout) + return cls(client) + + except Exception as e: + logger.error( + f"Failed to build Slab client from services: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Slab.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get Slab connector configuration " + f"for instance {connector_instance_id}" + ) + return dict(raw) # type: ignore[arg-type] + except Exception as e: + logger.error(f"Failed to get Slab connector config: {e}") + raise ValueError( + f"Failed to get Slab connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/smartsheet/smartsheet.py b/backend/python/app/sources/client/smartsheet/smartsheet.py new file mode 100644 index 000000000..a653b0b4a --- /dev/null +++ b/backend/python/app/sources/client/smartsheet/smartsheet.py @@ -0,0 +1,510 @@ +"""Smartsheet client implementation using the official smartsheet-python-sdk. + +This module provides clients for interacting with the Smartsheet API using either: +1. OAuth 2.0 authorization code flow +2. API Access Token + +SDK Reference: https://github.com/smartsheet/smartsheet-python-sdk +API Reference: https://smartsheet.redoc.ly/ +""" + +import logging +from typing import Any, cast + +import smartsheet as smartsheet_sdk # type: ignore[reportMissingTypeStubs] +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class SmartsheetResponse(BaseModel): + """Standardized Smartsheet API response wrapper. + + Wraps SDK responses into a uniform shape for the data-source layer. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: object = Field( + default=None, description="Response data from the SDK" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + arbitrary_types_allowed = True + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary.""" + return self.model_dump(exclude_none=True) + + +# --------------------------------------------------------------------------- +# SDK client classes +# --------------------------------------------------------------------------- + + +class SmartsheetClientViaOAuth: + """Smartsheet SDK client via OAuth 2.0 authorization code flow. + + Args: + access_token: The OAuth access token + client_id: OAuth client ID (for token refresh) + client_secret: OAuth client secret (for token refresh) + """ + + def __init__( + self, + access_token: str, + client_id: str | None = None, + client_secret: str | None = None, + ) -> None: + super().__init__() + self.access_token = access_token + self.client_id = client_id + self.client_secret = client_secret + self._sdk: Any = None # smartsheet_sdk.Smartsheet + + def create_client(self) -> Any: # smartsheet_sdk.Smartsheet + """Create and return the SDK client.""" + self._sdk = smartsheet_sdk.Smartsheet(self.access_token) # type: ignore[reportUnknownMemberType] + self._sdk.errors_as_exceptions(True) # type: ignore[reportUnknownMemberType] + return self._sdk # type: ignore[reportUnknownMemberType,reportUnknownVariableType] + + def get_sdk(self) -> Any: # smartsheet_sdk.Smartsheet + """Get the SDK client, creating it if necessary.""" + if self._sdk is None: + return self.create_client() # type: ignore[reportUnknownVariableType] + return self._sdk # type: ignore[reportUnknownVariableType] + + +class SmartsheetClientViaToken: + """Smartsheet SDK client via API Access Token. + + Args: + token: The API access token + """ + + def __init__(self, token: str) -> None: + super().__init__() + self.token = token + self._sdk: Any = None # smartsheet_sdk.Smartsheet + + def create_client(self) -> Any: # smartsheet_sdk.Smartsheet + """Create and return the SDK client.""" + self._sdk = smartsheet_sdk.Smartsheet(self.token) # type: ignore[reportUnknownMemberType] + self._sdk.errors_as_exceptions(True) # type: ignore[reportUnknownMemberType] + return self._sdk # type: ignore[reportUnknownMemberType,reportUnknownVariableType] + + def get_sdk(self) -> Any: # smartsheet_sdk.Smartsheet + """Get the SDK client, creating it if necessary.""" + if self._sdk is None: + return self.create_client() # type: ignore[reportUnknownVariableType] + return self._sdk # type: ignore[reportUnknownVariableType] + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class SmartsheetOAuthConfig(BaseModel): + """Configuration for Smartsheet client via OAuth 2.0. + + Args: + access_token: The OAuth access token + client_id: OAuth client ID + client_secret: OAuth client secret + """ + + access_token: str + client_id: str | None = None + client_secret: str | None = None + + def create_client(self) -> SmartsheetClientViaOAuth: + """Create an OAuth SDK client from this config.""" + return SmartsheetClientViaOAuth( + self.access_token, + self.client_id, + self.client_secret, + ) + + +class SmartsheetTokenConfig(BaseModel): + """Configuration for Smartsheet client via API Access Token. + + Args: + token: The API access token + """ + + token: str + + def create_client(self) -> SmartsheetClientViaToken: + """Create a token SDK client from this config.""" + return SmartsheetClientViaToken(self.token) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class SmartsheetAuthConfig(BaseModel): + """Auth section of the Smartsheet connector configuration from etcd.""" + + authType: str = "TOKEN" + apiToken: str | None = None + token: str | None = None + clientId: str | None = None + clientSecret: str | None = None + redirectUri: str | None = None + oauthConfigId: str | None = None + + class Config: + extra = "allow" + + +class SmartsheetCredentialsConfig(BaseModel): + """Credentials section of the Smartsheet connector configuration.""" + + access_token: str | None = None + refresh_token: str | None = None + + class Config: + extra = "allow" + + +class SmartsheetConnectorConfig(BaseModel): + """Top-level Smartsheet connector configuration from etcd.""" + + auth: SmartsheetAuthConfig = Field(default_factory=SmartsheetAuthConfig) + credentials: SmartsheetCredentialsConfig = Field( + default_factory=SmartsheetCredentialsConfig + ) + + class Config: + extra = "allow" + + +class SmartsheetSharedOAuthConfigEntry(BaseModel): + """A single entry from the shared OAuth config list in etcd. + + Handles both camelCase and snake_case key variants from the config store. + """ + + entry_id: str | None = Field(default=None, alias="_id") + clientId: str | None = None + client_id: str | None = None + clientSecret: str | None = None + client_secret: str | None = None + redirectUri: str | None = None + redirect_uri: str | None = None + + class Config: + extra = "allow" + populate_by_name = True + + def resolved_client_id(self, fallback: str = "") -> str: + """Return resolved client_id preferring camelCase.""" + return self.clientId or self.client_id or fallback + + def resolved_client_secret(self, fallback: str = "") -> str: + """Return resolved client_secret preferring camelCase.""" + return self.clientSecret or self.client_secret or fallback + + def resolved_redirect_uri(self, fallback: str = "") -> str: + """Return resolved redirect_uri preferring camelCase.""" + return self.redirectUri or self.redirect_uri or fallback + + +class SmartsheetSharedOAuthWrapper(BaseModel): + """Wrapper for a shared OAuth config entry with nested config.""" + + entry_id: str | None = Field(default=None, alias="_id") + config: SmartsheetSharedOAuthConfigEntry = Field( + default_factory=SmartsheetSharedOAuthConfigEntry + ) + + class Config: + extra = "allow" + populate_by_name = True + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class SmartsheetClient(IClient): + """Builder class for Smartsheet SDK clients with different auth methods. + + Supports: + - OAuth 2.0 authorization code flow + - API Access Token + """ + + def __init__( + self, + client: SmartsheetClientViaOAuth | SmartsheetClientViaToken, + ) -> None: + """Initialize with a Smartsheet SDK client wrapper.""" + super().__init__() + self.client = client + + @override + def get_client( + self, + ) -> SmartsheetClientViaOAuth | SmartsheetClientViaToken: + """Return the Smartsheet client wrapper.""" + return self.client + + def get_sdk(self) -> Any: # smartsheet_sdk.Smartsheet + """Return the underlying smartsheet SDK instance.""" + return self.client.get_sdk() + + @classmethod + def build_with_config( + cls, + config: SmartsheetOAuthConfig | SmartsheetTokenConfig, + ) -> "SmartsheetClient": + """Build SmartsheetClient with configuration. + + Args: + config: SmartsheetOAuthConfig or SmartsheetTokenConfig instance + + Returns: + SmartsheetClient instance + """ + client = config.create_client() + client.get_sdk() # type: ignore[reportUnknownMemberType] # eagerly initialize the SDK + return cls(client) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "SmartsheetClient": + """Build SmartsheetClient using configuration service. + + Supports two authentication strategies: + 1. OAUTH: OAuth 2.0 authorization code flow with access token + 2. TOKEN: Pre-generated API access token + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + SmartsheetClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get Smartsheet connector configuration" + ) + + connector_config = SmartsheetConnectorConfig.model_validate( + raw_config + ) + + if connector_config.auth.authType == "OAUTH": + access_token = ( + connector_config.credentials.access_token or "" + ) + client_id = connector_config.auth.clientId or "" + client_secret = connector_config.auth.clientSecret or "" + + # Try shared OAuth config if credentials are missing + oauth_config_id = connector_config.auth.oauthConfigId + if oauth_config_id and not (client_id and client_secret): + shared_cfg = await cls._find_shared_oauth_config( + config_service, oauth_config_id, logger + ) + if shared_cfg: + client_id = shared_cfg.resolved_client_id(client_id) + client_secret = shared_cfg.resolved_client_secret( + client_secret + ) + + if not access_token: + raise ValueError( + "Access token required for OAuth auth type" + ) + + oauth_cfg = SmartsheetOAuthConfig( + access_token=access_token, + client_id=client_id, + client_secret=client_secret, + ) + wrapper = oauth_cfg.create_client() + wrapper.get_sdk() # type: ignore[reportUnknownMemberType] + return cls(wrapper) + + if connector_config.auth.authType == "TOKEN": + token = ( + connector_config.auth.apiToken + or connector_config.auth.token + or "" + ) + if not token: + raise ValueError( + "API token required for TOKEN auth type" + ) + + token_config = SmartsheetTokenConfig(token=token) + wrapper = token_config.create_client() + wrapper.get_sdk() # type: ignore[reportUnknownMemberType] + return cls(wrapper) + + raise ValueError( + f"Invalid auth type: {connector_config.auth.authType}" + ) + + except Exception as e: + logger.error( + f"Failed to build Smartsheet client from services: {e!s}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, object], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "SmartsheetClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service for shared OAuth config + + Returns: + SmartsheetClient instance + """ + try: + credentials: dict[str, object] = cast( + dict[str, object], toolset_config.get("credentials", {}) or {} + ) + auth_config: dict[str, object] = cast( + dict[str, object], toolset_config.get("auth", {}) or {} + ) + + access_token: str = str(credentials.get("access_token", "")) + if not access_token: + raise ValueError( + "Access token not found in toolset config" + ) + + client_id: str = str(auth_config.get("clientId", "")) + client_secret: str = str(auth_config.get("clientSecret", "")) + + # Try shared OAuth config + oauth_config_id: str | None = cast( + str | None, auth_config.get("oauthConfigId") + ) + if oauth_config_id and config_service and not ( + client_id and client_secret + ): + shared_cfg = await cls._find_shared_oauth_config( + config_service, oauth_config_id, logger + ) + if shared_cfg: + client_id = shared_cfg.resolved_client_id(client_id) + client_secret = shared_cfg.resolved_client_secret( + client_secret + ) + + oauth_cfg = SmartsheetOAuthConfig( + access_token=access_token, + client_id=client_id, + client_secret=client_secret, + ) + wrapper = oauth_cfg.create_client() + wrapper.get_sdk() + return cls(wrapper) + + except Exception as e: + logger.error( + f"Failed to build Smartsheet client from toolset: {e!s}" + ) + raise + + @staticmethod + async def _find_shared_oauth_config( + config_service: ConfigurationService, + oauth_config_id: str, + logger: logging.Logger, + ) -> SmartsheetSharedOAuthConfigEntry | None: + """Look up shared OAuth config by ID from the config store. + + Args: + config_service: Configuration service instance + oauth_config_id: The shared OAuth config ID to match + logger: Logger instance + + Returns: + Matched SmartsheetSharedOAuthConfigEntry or None + """ + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/smartsheet", default=[] + ) + entries: list[object] = list(raw) if isinstance(raw, list) else [] # type: ignore[reportUnknownArgumentType] + for entry in entries: + wrapper = SmartsheetSharedOAuthWrapper.model_validate(entry) + if wrapper.entry_id == oauth_config_id: + return wrapper.config + except Exception as e: + logger.warning(f"Failed to fetch shared OAuth config: {e}") + return None + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, object]: + """Fetch connector config from etcd for Smartsheet. + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + Configuration dictionary + """ + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get Smartsheet connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, object], raw) + except Exception as e: + logger.error(f"Failed to get Smartsheet connector config: {e}") + raise ValueError( + f"Failed to get Smartsheet connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/splunk/splunk.py b/backend/python/app/sources/client/splunk/splunk.py new file mode 100644 index 000000000..979f818a8 --- /dev/null +++ b/backend/python/app/sources/client/splunk/splunk.py @@ -0,0 +1,276 @@ +import logging +from typing import Any + +import splunklib.client as splunk_client # type: ignore[import-untyped] +from pydantic import BaseModel, Field +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.iclient import IClient + + +class SplunkResponse(BaseModel): + success: bool + data: Any | None = None + error: str | None = None + message: str | None = None + + def to_dict(self) -> dict[str, Any]: + return self.model_dump() + + +class SplunkClientViaCredentials: + def __init__( + self, + host: str, + port: int, + username: str, + password: str, + *, + scheme: str = "https", + app: str | None = None, + owner: str | None = None, + ) -> None: + super().__init__() + self.host = host + self.port = port + self.username = username + self.password = password + self.scheme = scheme + self.app = app + self.owner = owner + + self._sdk: splunk_client.Service | None = None # type: ignore[no-any-unimported] + + def create_client(self) -> Any: # splunk_client.Service + kwargs: dict[str, Any] = { + "host": self.host, + "port": self.port, + "username": self.username, + "password": self.password, + "scheme": self.scheme, + } + if self.app is not None: + kwargs["app"] = self.app + if self.owner is not None: + kwargs["owner"] = self.owner + + self._sdk = splunk_client.connect(**kwargs) # type: ignore[no-untyped-call] + try: + _ = self._sdk.info # type: ignore[no-untyped-call] + except Exception as e: + raise RuntimeError("Splunk authentication failed") from e + + return self._sdk # type: ignore[reportUnknownVariableType] + + def get_sdk(self) -> Any: # splunk_client.Service + if self._sdk is None: # type: ignore[reportUnknownMemberType] + return self.create_client() # type: ignore[reportUnknownVariableType] + return self._sdk # type: ignore[reportUnknownVariableType] + + def get_base_url(self) -> str: + return f"{self.scheme}://{self.host}:{self.port}" + + +class SplunkClientViaToken: + def __init__( + self, + host: str, + port: int, + token: str, + *, + scheme: str = "https", + app: str | None = None, + owner: str | None = None, + ) -> None: + super().__init__() + self.host = host + self.port = port + self.token = token + self.scheme = scheme + self.app = app + self.owner = owner + + self._sdk: splunk_client.Service | None = None # type: ignore[no-any-unimported] + + def create_client(self) -> Any: # splunk_client.Service + kwargs: dict[str, Any] = { + "host": self.host, + "port": self.port, + "splunkToken": self.token, + "scheme": self.scheme, + } + if self.app is not None: + kwargs["app"] = self.app + if self.owner is not None: + kwargs["owner"] = self.owner + + self._sdk = splunk_client.connect(**kwargs) # type: ignore[no-untyped-call] + try: + _ = self._sdk.info # type: ignore[no-untyped-call] + except Exception as e: + raise RuntimeError("Splunk authentication failed") from e + + return self._sdk # type: ignore[reportUnknownVariableType] + + def get_sdk(self) -> Any: # splunk_client.Service + if self._sdk is None: # type: ignore[reportUnknownMemberType] + return self.create_client() # type: ignore[reportUnknownVariableType] + return self._sdk # type: ignore[reportUnknownVariableType] + + def get_base_url(self) -> str: + return f"{self.scheme}://{self.host}:{self.port}" + + +class SplunkCredentialsConfig(BaseModel): + host: str = Field(..., description="Splunk host") + port: int = Field(default=8089, description="Splunk management port") + username: str = Field(..., description="Splunk username") + password: str = Field(..., description="Splunk password") + scheme: str = Field(default="https", description="Connection scheme") + app: str | None = Field( + default=None, description="Splunk app context" + ) + owner: str | None = Field( + default=None, description="Splunk owner context" + ) + + def create_client(self) -> SplunkClientViaCredentials: + return SplunkClientViaCredentials( + host=self.host, + port=self.port, + username=self.username, + password=self.password, + scheme=self.scheme, + app=self.app, + owner=self.owner, + ) + + +class SplunkTokenConfig(BaseModel): + host: str = Field(..., description="Splunk host") + port: int = Field(default=8089, description="Splunk management port") + token: str = Field(..., description="Splunk bearer token") + scheme: str = Field(default="https", description="Connection scheme") + app: str | None = Field( + default=None, description="Splunk app context" + ) + owner: str | None = Field( + default=None, description="Splunk owner context" + ) + + def create_client(self) -> SplunkClientViaToken: + return SplunkClientViaToken( + host=self.host, + port=self.port, + token=self.token, + scheme=self.scheme, + app=self.app, + owner=self.owner, + ) + + +SplunkClientWrapper = SplunkClientViaCredentials | SplunkClientViaToken + + +class SplunkClient(IClient): + def __init__(self, client: SplunkClientWrapper) -> None: + super().__init__() + self.client = client + + @override + def get_client(self) -> SplunkClientWrapper: + return self.client + + def get_sdk(self) -> Any: # splunk_client.Service + return self.client.get_sdk() # type: ignore[reportUnknownMemberType] + + @classmethod + def build_with_config( + cls, + config: SplunkCredentialsConfig | SplunkTokenConfig, + ) -> "SplunkClient": + client = config.create_client() + _ = client.get_sdk() # type: ignore[reportUnknownMemberType] + return cls(client) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "SplunkClient": + """Build SplunkClient using configuration service.""" + config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not config: + raise ValueError( + "Failed to get Splunk connector configuration" + ) + auth_config = config.get("auth", {}) + auth_type = auth_config.get("authType", "CREDENTIALS") + host = auth_config.get("host", "localhost") + port = auth_config.get("port", 8089) + scheme = auth_config.get("scheme", "https") + app = auth_config.get("app") + owner = auth_config.get("owner") + + if auth_type == "CREDENTIALS": + username = auth_config.get("username", "") + password = auth_config.get("password", "") + if not username or not password: + raise ValueError( + "username and password required for CREDENTIALS auth" + ) + wrapper: SplunkClientWrapper = SplunkClientViaCredentials( + host=host, + port=port, + username=username, + password=password, + scheme=scheme, + app=app, + owner=owner, + ) + elif auth_type == "BEARER_TOKEN": + token = auth_config.get("token", "") + if not token: + raise ValueError("token required for BEARER_TOKEN auth") + wrapper = SplunkClientViaToken( + host=host, + port=port, + token=token, + scheme=scheme, + app=app, + owner=owner, + ) + else: + raise ValueError(f"Invalid auth type: {auth_type}") + + _ = wrapper.create_client() # type: ignore[reportUnknownVariableType,reportUnknownMemberType] + return cls(wrapper) + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Splunk.""" + try: + config: dict[str, Any] = await config_service.get_config( # type: ignore[assignment] + f"/services/connectors/{connector_instance_id}/config" + ) + if not config: + raise ValueError( + f"Failed to get Splunk connector configuration for instance {connector_instance_id}" + ) + return config + except Exception as e: + logger.error( + "Failed to get Splunk connector config: %s", e + ) + raise ValueError( + f"Failed to get Splunk connector configuration for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/tableau/tableau.py b/backend/python/app/sources/client/tableau/tableau.py new file mode 100644 index 000000000..ef929f219 --- /dev/null +++ b/backend/python/app/sources/client/tableau/tableau.py @@ -0,0 +1,498 @@ +"""Tableau client implementation using the official tableauserverclient SDK. + +This module provides clients for interacting with Tableau Server/Cloud using: +1. Personal Access Token (PAT) authentication via TSC.PersonalAccessTokenAuth +2. Username/Password authentication via TSC.TableauAuth + +SDK Reference: https://tableau.github.io/server-client-python/docs/ +""" + +import logging +from enum import Enum +from typing import Any, cast + +import tableauserverclient as TSC # type: ignore[reportMissingImports] +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class TableauAuthType(str, Enum): + """Authentication types supported by the Tableau connector.""" + + PAT = "PAT" + PASSWORD = "PASSWORD" + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class TableauResponse(BaseModel): + """Standardized Tableau API response wrapper.""" + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = None + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, Any]: + """Convert response to dictionary.""" + return self.model_dump(exclude_none=True) + + +# --------------------------------------------------------------------------- +# SDK client classes +# --------------------------------------------------------------------------- + + +class TableauClientViaPAT: + """Tableau SDK client via Personal Access Token (PAT). + + Wraps TSC.Server and authenticates using TSC.PersonalAccessTokenAuth. + + Args: + server_url: Tableau Server/Cloud URL (e.g., "https://10ax.online.tableau.com") + token_name: Personal Access Token name + token_secret: Personal Access Token secret + site_id: Site content URL (empty string for default site) + """ + + def __init__( + self, + server_url: str, + token_name: str, + token_secret: str, + site_id: str = "", + ) -> None: + self.server_url = server_url.rstrip("/") + self.token_name = token_name + self.token_secret = token_secret + self.site_id = site_id + self._server: TSC.Server | None = None # type: ignore[reportUnknownMemberType] + self._authenticated = False + + def create_client(self) -> TSC.Server: # type: ignore[reportUnknownMemberType] + """Create and authenticate the TSC.Server instance.""" + self._server = TSC.Server(self.server_url, use_server_version=True) # type: ignore[reportUnknownMemberType] + self.ensure_authenticated() + return self._server # type: ignore[reportUnknownVariableType, reportUnknownMemberType] + + def ensure_authenticated(self) -> None: + """Sign in to Tableau using PAT credentials if not already authenticated.""" + if self._authenticated: + return + if self._server is None: # type: ignore[reportUnknownMemberType] + self._server = TSC.Server(self.server_url, use_server_version=True) # type: ignore[reportUnknownMemberType] + auth = TSC.PersonalAccessTokenAuth( # type: ignore[reportUnknownMemberType] + self.token_name, self.token_secret, site_id=self.site_id + ) + try: + self._server.auth.sign_in(auth) # type: ignore[reportUnknownMemberType, reportOptionalMemberAccess] + except Exception as e: + raise RuntimeError("Tableau PAT authentication failed") from e + self._authenticated = True + + def get_sdk(self) -> TSC.Server: # type: ignore[reportUnknownMemberType] + """Return the authenticated TSC.Server instance.""" + if self._server is None: # type: ignore[reportUnknownMemberType] + return self.create_client() # type: ignore[reportUnknownVariableType, reportUnknownMemberType] + return self._server # type: ignore[reportUnknownVariableType, reportUnknownMemberType] + + def get_server_url(self) -> str: + """Return the server URL.""" + return self.server_url + + +class TableauClientViaPassword: + """Tableau SDK client via Username/Password. + + Wraps TSC.Server and authenticates using TSC.TableauAuth. + + Args: + server_url: Tableau Server/Cloud URL (e.g., "https://10ax.online.tableau.com") + username: Tableau username + password: Tableau password + site_id: Site content URL (empty string for default site) + """ + + def __init__( + self, + server_url: str, + username: str, + password: str, + site_id: str = "", + ) -> None: + self.server_url = server_url.rstrip("/") + self.username = username + self.password = password + self.site_id = site_id + self._server: TSC.Server | None = None # type: ignore[reportUnknownMemberType] + self._authenticated = False + + def create_client(self) -> TSC.Server: # type: ignore[reportUnknownMemberType] + """Create and authenticate the TSC.Server instance.""" + self._server = TSC.Server(self.server_url, use_server_version=True) # type: ignore[reportUnknownMemberType] + self.ensure_authenticated() + return self._server # type: ignore[reportUnknownVariableType, reportUnknownMemberType] + + def ensure_authenticated(self) -> None: + """Sign in to Tableau using username/password if not already authenticated.""" + if self._authenticated: + return + if self._server is None: # type: ignore[reportUnknownMemberType] + self._server = TSC.Server(self.server_url, use_server_version=True) # type: ignore[reportUnknownMemberType] + auth = TSC.TableauAuth(self.username, self.password, site_id=self.site_id) # type: ignore[reportUnknownMemberType] + try: + self._server.auth.sign_in(auth) # type: ignore[reportUnknownMemberType, reportOptionalMemberAccess] + except Exception as e: + raise RuntimeError("Tableau password authentication failed") from e + self._authenticated = True + + def get_sdk(self) -> TSC.Server: # type: ignore[reportUnknownMemberType] + """Return the authenticated TSC.Server instance.""" + if self._server is None: # type: ignore[reportUnknownMemberType] + return self.create_client() # type: ignore[reportUnknownVariableType, reportUnknownMemberType] + return self._server # type: ignore[reportUnknownVariableType, reportUnknownMemberType] + + def get_server_url(self) -> str: + """Return the server URL.""" + return self.server_url + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class TableauPATConfig(BaseModel): + """Configuration for Tableau client via Personal Access Token. + + Args: + server_url: Tableau Server/Cloud URL + token_name: Personal Access Token name + token_secret: Personal Access Token secret + site_id: Site content URL (empty string for default site) + """ + + server_url: str + token_name: str + token_secret: str + site_id: str = "" + + def create_client(self) -> TableauClientViaPAT: + return TableauClientViaPAT( + server_url=self.server_url, + token_name=self.token_name, + token_secret=self.token_secret, + site_id=self.site_id, + ) + + +class TableauPasswordConfig(BaseModel): + """Configuration for Tableau client via Username/Password. + + Args: + server_url: Tableau Server/Cloud URL + username: Tableau username + password: Tableau password + site_id: Site content URL (empty string for default site) + """ + + server_url: str + username: str + password: str + site_id: str = "" + + def create_client(self) -> TableauClientViaPassword: + return TableauClientViaPassword( + server_url=self.server_url, + username=self.username, + password=self.password, + site_id=self.site_id, + ) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class TableauAuthConfig(BaseModel): + """Auth section of the Tableau connector configuration from etcd.""" + + authType: TableauAuthType = TableauAuthType.PAT + serverUrl: str | None = None + tokenName: str | None = None + tokenSecret: str | None = None + siteId: str | None = None + username: str | None = None + password: str | None = None + + class Config: + extra = "allow" + + +class TableauConnectorConfig(BaseModel): + """Top-level Tableau connector configuration from etcd.""" + + auth: TableauAuthConfig = Field(default_factory=TableauAuthConfig) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class TableauClient(IClient): + """Builder class for Tableau clients with different authentication methods. + + Supports: + - Personal Access Token (PAT) authentication + - Username/Password authentication + """ + + def __init__( + self, + client: TableauClientViaPAT | TableauClientViaPassword, + ) -> None: + """Initialize with a Tableau SDK client object.""" + super().__init__() + self.client = client + + @override + def get_client( + self, + ) -> TableauClientViaPAT | TableauClientViaPassword: + """Return the Tableau SDK client object.""" + return self.client + + def get_sdk(self) -> TSC.Server: # type: ignore[reportUnknownMemberType] + """Return the authenticated TSC.Server instance.""" + return self.client.get_sdk() # type: ignore[reportUnknownVariableType, reportUnknownMemberType] + + def get_server_url(self) -> str: + """Return the server URL.""" + return self.client.get_server_url() + + @classmethod + def build_with_config( + cls, + config: TableauPATConfig | TableauPasswordConfig, + ) -> "TableauClient": + """Build TableauClient with configuration. + + Args: + config: TableauPATConfig or TableauPasswordConfig instance + + Returns: + TableauClient instance + """ + client = config.create_client() + client.get_sdk() # type: ignore[reportUnknownMemberType] + return cls(client) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "TableauClient": + """Build TableauClient using configuration service. + + Supports two authentication strategies: + 1. PAT: Personal Access Token + 2. PASSWORD: Username/Password + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + TableauClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get Tableau connector configuration" + ) + + connector_config = TableauConnectorConfig.model_validate( + raw_config + ) + + server_url = connector_config.auth.serverUrl or "" + if not server_url: + raise ValueError( + "server_url is required for Tableau connector" + ) + + site_id = connector_config.auth.siteId or "" + + if connector_config.auth.authType == TableauAuthType.PAT: + token_name = connector_config.auth.tokenName or "" + token_secret = connector_config.auth.tokenSecret or "" + + if not (token_name and token_secret): + raise ValueError( + "token_name and token_secret are required " + "for PAT auth type" + ) + + pat_config = TableauPATConfig( + server_url=server_url, + token_name=token_name, + token_secret=token_secret, + site_id=site_id, + ) + client = pat_config.create_client() + client.get_sdk() # type: ignore[reportUnknownMemberType] + return cls(client) + + elif connector_config.auth.authType == TableauAuthType.PASSWORD: + username = connector_config.auth.username or "" + password = connector_config.auth.password or "" + + if not (username and password): + raise ValueError( + "username and password are required " + "for PASSWORD auth type" + ) + + password_config = TableauPasswordConfig( + server_url=server_url, + username=username, + password=password, + site_id=site_id, + ) + client = password_config.create_client() + client.get_sdk() # type: ignore[reportUnknownMemberType] + return cls(client) + + else: + raise ValueError( + f"Invalid auth type: {connector_config.auth.authType}" + ) + + except Exception as e: + logger.error( + f"Failed to build Tableau client from services: {e!s}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "TableauClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service (unused for Tableau) + + Returns: + TableauClient instance + """ + try: + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + server_url: str = str(auth_config.get("serverUrl", "")) + if not server_url: + raise ValueError( + "server_url not found in toolset config" + ) + + site_id: str = str(auth_config.get("siteId", "")) + + # Try PAT auth first + token_name: str = str(auth_config.get("tokenName", "")) + token_secret: str = str(auth_config.get("tokenSecret", "")) + + if token_name and token_secret: + pat_config = TableauPATConfig( + server_url=server_url, + token_name=token_name, + token_secret=token_secret, + site_id=site_id, + ) + client = pat_config.create_client() + client.get_sdk() # type: ignore[reportUnknownMemberType] + return cls(client) + + # Fall back to password auth + username: str = str(auth_config.get("username", "")) + password: str = str(auth_config.get("password", "")) + + if username and password: + password_config = TableauPasswordConfig( + server_url=server_url, + username=username, + password=password, + site_id=site_id, + ) + client = password_config.create_client() + client.get_sdk() # type: ignore[reportUnknownMemberType] + return cls(client) + + raise ValueError( + "Either tokenName+tokenSecret or " + "username+password required in toolset config" + ) + + except Exception as e: + logger.error( + f"Failed to build Tableau client from toolset: {e!s}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Tableau.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get Tableau connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error(f"Failed to get Tableau connector config: {e}") + raise ValueError( + f"Failed to get Tableau connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/webex/webex.py b/backend/python/app/sources/client/webex/webex.py new file mode 100644 index 000000000..dbe857b8d --- /dev/null +++ b/backend/python/app/sources/client/webex/webex.py @@ -0,0 +1,482 @@ +"""Webex client implementation. + +This module provides a client for interacting with the Webex API using the +official wxc_sdk package (WebexSimpleApi). + +Authentication Reference: https://developer.webex.com/docs/getting-started +SDK Reference: https://github.com/jeokrohn/wxc_sdk + +Supports: +1. Direct token authentication (pre-generated access token) +2. OAuth 2.0 access token authentication +""" + +import logging +from enum import Enum +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +# --------------------------------------------------------------------------- +# Webex SDK import (untyped third-party package) +# --------------------------------------------------------------------------- +from wxc_sdk import WebexSimpleApi # type: ignore[import-untyped] + +from app.config.configuration_service import ConfigurationService +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class WebexAuthType(str, Enum): + """Authentication types supported by the Webex connector.""" + + TOKEN = "TOKEN" + OAUTH = "OAUTH" + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class WebexResponse(BaseModel): + """Standardized Webex API response wrapper.""" + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | None = Field( + default=None, description="Response data" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary.""" + return self.model_dump(exclude_none=True) + + +# --------------------------------------------------------------------------- +# SDK client wrappers +# --------------------------------------------------------------------------- + + +class WebexClientViaToken: + """Webex client via pre-generated access token. + + Wraps the wxc_sdk WebexSimpleApi with a direct access token. + + Args: + access_token: Webex access token + """ + + def __init__(self, access_token: str) -> None: + access_token = access_token.strip() if access_token else "" + if not access_token: + raise ValueError("Webex access token cannot be empty") + + self.access_token = access_token + self._api: Any = None # WebexSimpleApi + + def create_client(self) -> Any: # WebexSimpleApi + """Create and return the WebexSimpleApi instance. + + Returns: + WebexSimpleApi instance + """ + self._api = WebexSimpleApi(tokens=self.access_token) # type: ignore[no-untyped-call] + return self._api # type: ignore[reportUnknownVariableType] + + def get_sdk(self) -> Any: # WebexSimpleApi + """Get the WebexSimpleApi instance, creating it if needed. + + Returns: + WebexSimpleApi instance + """ + if self._api is None: # type: ignore[reportUnknownMemberType] + return self.create_client() # type: ignore[reportUnknownVariableType] + return self._api # type: ignore[reportUnknownVariableType] + + def get_token(self) -> str: + """Get the access token.""" + return self.access_token + + +class WebexClientViaOAuth: + """Webex client via OAuth 2.0 access token. + + Uses an OAuth-obtained access token to create the WebexSimpleApi instance. + Supports token refresh via client_id and client_secret. + + Args: + access_token: OAuth access token + client_id: OAuth client ID (for token refresh) + client_secret: OAuth client secret (for token refresh) + redirect_uri: OAuth redirect URI + """ + + def __init__( + self, + access_token: str, + client_id: str | None = None, + client_secret: str | None = None, + redirect_uri: str | None = None, + ) -> None: + access_token = access_token.strip() if access_token else "" + if not access_token: + raise ValueError("Webex OAuth access token cannot be empty") + + self.access_token = access_token + self.client_id = client_id + self.client_secret = client_secret + self.redirect_uri = redirect_uri + self._api: Any = None # WebexSimpleApi + + def create_client(self) -> Any: # WebexSimpleApi + """Create and return the WebexSimpleApi instance. + + Returns: + WebexSimpleApi instance + """ + self._api = WebexSimpleApi(tokens=self.access_token) # type: ignore[no-untyped-call] + return self._api # type: ignore[reportUnknownVariableType] + + def get_sdk(self) -> Any: # WebexSimpleApi + """Get the WebexSimpleApi instance, creating it if needed. + + Returns: + WebexSimpleApi instance + """ + if self._api is None: # type: ignore[reportUnknownMemberType] + return self.create_client() # type: ignore[reportUnknownVariableType] + return self._api # type: ignore[reportUnknownVariableType] + + def get_token(self) -> str: + """Get the access token.""" + return self.access_token + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class WebexTokenConfig(BaseModel): + """Configuration for Webex client via pre-generated access token. + + Args: + access_token: Webex access token + """ + + access_token: str = Field(..., description="Webex access token") + + def create_client(self) -> WebexClientViaToken: + """Create a Webex client.""" + return WebexClientViaToken(access_token=self.access_token) + + +class WebexOAuthConfig(BaseModel): + """Configuration for Webex client via OAuth 2.0. + + Args: + access_token: OAuth access token + client_id: OAuth client ID + client_secret: OAuth client secret + redirect_uri: OAuth redirect URI + """ + + access_token: str = Field(..., description="OAuth access token") + client_id: str | None = Field(default=None, description="OAuth client ID") + client_secret: str | None = Field( + default=None, description="OAuth client secret" + ) + redirect_uri: str | None = Field( + default=None, description="OAuth redirect URI" + ) + + def create_client(self) -> WebexClientViaOAuth: + """Create a Webex OAuth client.""" + return WebexClientViaOAuth( + access_token=self.access_token, + client_id=self.client_id, + client_secret=self.client_secret, + redirect_uri=self.redirect_uri, + ) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class WebexAuthConfig(BaseModel): + """Auth section of the Webex connector configuration from etcd.""" + + authType: WebexAuthType = WebexAuthType.TOKEN + clientId: str | None = None + clientSecret: str | None = None + redirectUri: str | None = None + token: str | None = None + oauthConfigId: str | None = None + + class Config: + extra = "allow" + + +class WebexCredentialsConfig(BaseModel): + """Credentials section of the Webex connector configuration.""" + + access_token: str | None = None + refresh_token: str | None = None + + class Config: + extra = "allow" + + +class WebexConnectorConfig(BaseModel): + """Top-level Webex connector configuration from etcd.""" + + auth: WebexAuthConfig = Field(default_factory=WebexAuthConfig) + credentials: WebexCredentialsConfig = Field( + default_factory=WebexCredentialsConfig + ) + + class Config: + extra = "allow" + + +class WebexSharedOAuthConfigEntry(BaseModel): + """A single entry from the shared OAuth config list in etcd. + + Handles both camelCase and snake_case key variants from the config store. + """ + + entry_id: str | None = Field(default=None, alias="_id") + clientId: str | None = None + client_id: str | None = None + clientSecret: str | None = None + client_secret: str | None = None + redirectUri: str | None = None + redirect_uri: str | None = None + + class Config: + extra = "allow" + populate_by_name = True + + def resolved_client_id(self, fallback: str = "") -> str: + return self.clientId or self.client_id or fallback + + def resolved_client_secret(self, fallback: str = "") -> str: + return self.clientSecret or self.client_secret or fallback + + def resolved_redirect_uri(self, fallback: str = "") -> str: + return self.redirectUri or self.redirect_uri or fallback + + +class WebexSharedOAuthWrapper(BaseModel): + """Wrapper for a shared OAuth config entry with nested config.""" + + entry_id: str | None = Field(default=None, alias="_id") + config: WebexSharedOAuthConfigEntry = Field( + default_factory=WebexSharedOAuthConfigEntry + ) + + class Config: + extra = "allow" + populate_by_name = True + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class WebexClient(IClient): + """Builder class for Webex clients with different authentication methods. + + Supports: + - Direct token authentication + - OAuth 2.0 access token authentication + """ + + def __init__( + self, + client: WebexClientViaToken | WebexClientViaOAuth, + ) -> None: + """Initialize with a Webex client object.""" + super().__init__() + self.client = client + + @override + def get_client(self) -> WebexClientViaToken | WebexClientViaOAuth: + """Return the Webex client object.""" + return self.client + + def get_sdk(self) -> Any: # WebexSimpleApi + """Return the underlying WebexSimpleApi SDK instance.""" + return self.client.get_sdk() # type: ignore[reportUnknownVariableType,reportUnknownMemberType] + + @classmethod + def build_with_config( + cls, + config: WebexTokenConfig | WebexOAuthConfig, + ) -> "WebexClient": + """Build WebexClient with configuration. + + Args: + config: WebexTokenConfig or WebexOAuthConfig instance + + Returns: + WebexClient instance + """ + client = config.create_client() + _ = client.get_sdk() # type: ignore[reportUnknownVariableType,reportUnknownMemberType] # Eagerly initialize + return cls(client) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "WebexClient": + """Build WebexClient using configuration service. + + Supports token and OAuth authentication strategies. + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + WebexClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get Webex connector configuration" + ) + + connector_config = WebexConnectorConfig.model_validate(raw_config) + + if connector_config.auth.authType == WebexAuthType.TOKEN: + token = connector_config.auth.token or "" + if not token: + raise ValueError( + "Token required for TOKEN auth type" + ) + webex_client = WebexClientViaToken(access_token=token) + _ = webex_client.get_sdk() # type: ignore[reportUnknownVariableType,reportUnknownMemberType] + return cls(webex_client) + + elif connector_config.auth.authType == WebexAuthType.OAUTH: + access_token = connector_config.credentials.access_token or "" + client_id = connector_config.auth.clientId or "" + client_secret = connector_config.auth.clientSecret or "" + redirect_uri = connector_config.auth.redirectUri or "" + + # Try shared OAuth config if credentials are missing + oauth_config_id = connector_config.auth.oauthConfigId + if oauth_config_id and not (client_id and client_secret): + shared_cfg = await cls._find_shared_oauth_config( + config_service, oauth_config_id, logger + ) + if shared_cfg: + client_id = shared_cfg.resolved_client_id(client_id) + client_secret = shared_cfg.resolved_client_secret( + client_secret + ) + redirect_uri = shared_cfg.resolved_redirect_uri( + redirect_uri + ) + + if not access_token: + raise ValueError( + "Access token required for OAuth auth type" + ) + + webex_client = WebexClientViaOAuth( # type: ignore[assignment] + access_token=access_token, + client_id=client_id, + client_secret=client_secret, + redirect_uri=redirect_uri, + ) + _ = webex_client.get_sdk() # type: ignore[reportUnknownVariableType,reportUnknownMemberType] + return cls(webex_client) + + else: + raise ValueError( + f"Invalid auth type: {connector_config.auth.authType}" + ) + + except Exception as e: + logger.error( + f"Failed to build Webex client from services: {str(e)}" + ) + raise + + @staticmethod + async def _find_shared_oauth_config( + config_service: ConfigurationService, + oauth_config_id: str, + logger: logging.Logger, + ) -> WebexSharedOAuthConfigEntry | None: + """Look up shared OAuth config by ID from the config store. + + Args: + config_service: Configuration service instance + oauth_config_id: The shared OAuth config ID to match + logger: Logger instance + + Returns: + Matched WebexSharedOAuthConfigEntry or None + """ + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/webex", default=[] + ) + entries: list[object] = list(raw) if isinstance(raw, list) else [] # type: ignore[arg-type] + for entry in entries: + wrapper = WebexSharedOAuthWrapper.model_validate(entry) + if wrapper.entry_id == oauth_config_id: + return wrapper.config + except Exception as e: + logger.warning(f"Failed to fetch shared OAuth config: {e}") + return None + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Webex.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get Webex connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error(f"Failed to get Webex connector config: {e}") + raise ValueError( + f"Failed to get Webex connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/wordpress/wordpress.py b/backend/python/app/sources/client/wordpress/wordpress.py new file mode 100644 index 000000000..aaff8f3a7 --- /dev/null +++ b/backend/python/app/sources/client/wordpress/wordpress.py @@ -0,0 +1,645 @@ +"""WordPress client implementation. + +This module provides clients for interacting with the WordPress REST API using: +1. OAuth 2.0 access token authentication (WordPress.com) +2. Application Password authentication (self-hosted WordPress) +3. Pre-generated Bearer token authentication + +WordPress.com OAuth Reference: https://developer.wordpress.com/docs/oauth2/ +WordPress REST API Reference: https://developer.wordpress.org/rest-api/reference/ +""" + +import base64 +import json +import logging +from enum import Enum +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class WordPressAuthType(str, Enum): + """Authentication types supported by the WordPress connector.""" + + OAUTH = "OAUTH" + APPLICATION_PASSWORD = "APPLICATION_PASSWORD" + TOKEN = "TOKEN" + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class WordPressResponse(BaseModel): + """Standardized WordPress API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client classes +# --------------------------------------------------------------------------- + + +class WordPressRESTClientViaOAuth(HTTPClient): + """WordPress REST client via OAuth 2.0 (WordPress.com). + + OAuth tokens are passed as Bearer tokens in the Authorization header. + For WordPress.com sites, the base URL uses the WordPress.com REST API + endpoint with the site ID. + + Args: + access_token: The OAuth access token + site_id: WordPress.com site ID or domain + client_id: OAuth client ID (for token refresh) + client_secret: OAuth client secret (for token refresh) + base_url: API base URL (auto-constructed for WordPress.com) + """ + + def __init__( + self, + access_token: str, + site_id: str, + client_id: str | None = None, + client_secret: str | None = None, + base_url: str | None = None, + ) -> None: + super().__init__(access_token, "Bearer") + self.base_url = ( + base_url + or f"https://public-api.wordpress.com/wp/v2/sites/{site_id}" + ) + self.access_token = access_token + self.site_id = site_id + self.client_id = client_id + self.client_secret = client_secret + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +class WordPressRESTClientViaApplicationPassword(HTTPClient): + """WordPress REST client via Application Password (self-hosted). + + Uses HTTP Basic Authentication with the WordPress username and an + application password. This is the recommended auth method for + self-hosted WordPress sites. + + Args: + site_url: The WordPress site URL (e.g., "example.com" or + "example.com/wordpress") + username: WordPress username + application_password: Application password generated in WordPress + base_url: API base URL (auto-constructed from site_url) + """ + + def __init__( + self, + site_url: str, + username: str, + application_password: str, + base_url: str | None = None, + ) -> None: + # Initialize with empty token; we override the header below + super().__init__("", token_type="Basic") + # Strip protocol if provided + clean_url = site_url.rstrip("/") + if not clean_url.startswith(("http://", "https://")): + clean_url = f"https://{clean_url}" + self.base_url = base_url or f"{clean_url}/wp-json/wp/v2" + self.site_url = site_url + self.username = username + self.application_password = application_password + # Basic Auth: base64(username:application_password) + credentials = base64.b64encode( + f"{username}:{application_password}".encode() + ).decode("utf-8") + self.headers["Authorization"] = f"Basic {credentials}" + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +class WordPressRESTClientViaToken(HTTPClient): + """WordPress REST client via pre-generated Bearer token. + + Simple authentication using a pre-generated token passed directly + in the Authorization header. + + Args: + token: The pre-generated Bearer token + site_url: The WordPress site URL + base_url: API base URL (auto-constructed from site_url) + """ + + def __init__( + self, + token: str, + site_url: str, + base_url: str | None = None, + ) -> None: + super().__init__(token, token_type="Bearer") + # Strip protocol if provided + clean_url = site_url.rstrip("/") + if not clean_url.startswith(("http://", "https://")): + clean_url = f"https://{clean_url}" + self.base_url = base_url or f"{clean_url}/wp-json/wp/v2" + self.site_url = site_url + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class WordPressOAuthConfig(BaseModel): + """Configuration for WordPress client via OAuth 2.0 (WordPress.com). + + Args: + access_token: The OAuth access token + site_id: WordPress.com site ID or domain + client_id: OAuth client ID + client_secret: OAuth client secret + base_url: API base URL (optional override) + """ + + access_token: str + site_id: str + client_id: str | None = None + client_secret: str | None = None + base_url: str | None = None + + def create_client(self) -> WordPressRESTClientViaOAuth: + return WordPressRESTClientViaOAuth( + self.access_token, + self.site_id, + self.client_id, + self.client_secret, + self.base_url, + ) + + +class WordPressApplicationPasswordConfig(BaseModel): + """Configuration for WordPress client via Application Password. + + Args: + site_url: The WordPress site URL + username: WordPress username + application_password: Application password + base_url: API base URL (optional override) + """ + + site_url: str + username: str + application_password: str + base_url: str | None = None + + def create_client(self) -> WordPressRESTClientViaApplicationPassword: + return WordPressRESTClientViaApplicationPassword( + self.site_url, + self.username, + self.application_password, + self.base_url, + ) + + +class WordPressTokenConfig(BaseModel): + """Configuration for WordPress client via pre-generated Bearer token. + + Args: + token: The pre-generated Bearer token + site_url: The WordPress site URL + base_url: API base URL (optional override) + """ + + token: str + site_url: str + base_url: str | None = None + + def create_client(self) -> WordPressRESTClientViaToken: + return WordPressRESTClientViaToken( + self.token, + self.site_url, + self.base_url, + ) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class WordPressAuthConfig(BaseModel): + """Auth section of the WordPress connector configuration from etcd.""" + + authType: WordPressAuthType = WordPressAuthType.OAUTH + siteUrl: str | None = None + siteId: str | None = None + username: str | None = None + applicationPassword: str | None = None + clientId: str | None = None + clientSecret: str | None = None + redirectUri: str | None = None + token: str | None = None + oauthConfigId: str | None = None + + class Config: + extra = "allow" + + +class WordPressCredentialsConfig(BaseModel): + """Credentials section of the WordPress connector configuration.""" + + access_token: str | None = None + refresh_token: str | None = None + + class Config: + extra = "allow" + + +class WordPressConnectorConfig(BaseModel): + """Top-level WordPress connector configuration from etcd.""" + + auth: WordPressAuthConfig = Field(default_factory=WordPressAuthConfig) + credentials: WordPressCredentialsConfig = Field( + default_factory=WordPressCredentialsConfig + ) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class WordPressClient(IClient): + """Builder class for WordPress clients with different authentication methods. + + Supports: + - OAuth 2.0 (WordPress.com) + - Application Password (self-hosted WordPress) + - Pre-generated Bearer token + """ + + def __init__( + self, + client: ( + WordPressRESTClientViaOAuth + | WordPressRESTClientViaApplicationPassword + | WordPressRESTClientViaToken + ), + ) -> None: + """Initialize with a WordPress client object.""" + super().__init__() + self.client = client + + @override + def get_client( + self, + ) -> ( + WordPressRESTClientViaOAuth + | WordPressRESTClientViaApplicationPassword + | WordPressRESTClientViaToken + ): + """Return the WordPress client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: ( + WordPressOAuthConfig + | WordPressApplicationPasswordConfig + | WordPressTokenConfig + ), + ) -> "WordPressClient": + """Build WordPressClient with configuration. + + Args: + config: WordPressOAuthConfig, WordPressApplicationPasswordConfig, + or WordPressTokenConfig instance + + Returns: + WordPressClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "WordPressClient": + """Build WordPressClient using configuration service. + + Supports three authentication strategies: + 1. OAUTH: OAuth 2.0 with WordPress.com access token + 2. APPLICATION_PASSWORD: Basic Auth with username + app password + 3. TOKEN: Pre-generated Bearer token + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + WordPressClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get WordPress connector configuration" + ) + + connector_config = WordPressConnectorConfig.model_validate( + raw_config + ) + + if connector_config.auth.authType == WordPressAuthType.OAUTH: + access_token = connector_config.credentials.access_token or "" + site_id = connector_config.auth.siteId or "" + client_id = connector_config.auth.clientId or "" + client_secret = connector_config.auth.clientSecret or "" + + # Try shared OAuth config if credentials are missing + oauth_config_id = connector_config.auth.oauthConfigId + if oauth_config_id and not (client_id and client_secret): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/wordpress", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + if not access_token: + raise ValueError( + "Access token required for OAuth auth type" + ) + if not site_id: + raise ValueError( + "Site ID required for OAuth auth type " + "(WordPress.com site ID or domain)" + ) + + oauth_cfg = WordPressOAuthConfig( + access_token=access_token, + site_id=site_id, + client_id=client_id, + client_secret=client_secret, + ) + return cls(oauth_cfg.create_client()) + + elif ( + connector_config.auth.authType + == WordPressAuthType.APPLICATION_PASSWORD + ): + site_url = connector_config.auth.siteUrl or "" + username = connector_config.auth.username or "" + app_password = ( + connector_config.auth.applicationPassword or "" + ) + + if not (site_url and username and app_password): + raise ValueError( + "site_url, username, and application_password are " + "required for APPLICATION_PASSWORD auth type" + ) + + app_pw_cfg = WordPressApplicationPasswordConfig( + site_url=site_url, + username=username, + application_password=app_password, + ) + return cls(app_pw_cfg.create_client()) + + elif connector_config.auth.authType == WordPressAuthType.TOKEN: + token = connector_config.auth.token or "" + site_url = connector_config.auth.siteUrl or "" + + if not token: + raise ValueError( + "Token required for TOKEN auth type" + ) + if not site_url: + raise ValueError( + "Site URL required for TOKEN auth type" + ) + + token_config = WordPressTokenConfig( + token=token, + site_url=site_url, + ) + return cls(token_config.create_client()) + + else: + raise ValueError( + f"Invalid auth type: {connector_config.auth.authType}" + ) + + except Exception as e: + logger.error( + f"Failed to build WordPress client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "WordPressClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service for shared OAuth config + + Returns: + WordPressClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("credentials", {}) or {} + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + access_token: str = str(credentials.get("access_token", "")) + if not access_token: + raise ValueError( + "Access token not found in toolset config" + ) + + site_id: str = str(auth_config.get("siteId", "")) + client_id: str = str(auth_config.get("clientId", "")) + client_secret: str = str(auth_config.get("clientSecret", "")) + + # Try shared OAuth config + oauth_config_id: str | None = cast( + str | None, auth_config.get("oauthConfigId") + ) + if oauth_config_id and config_service and not ( + client_id and client_secret + ): + try: + oauth_configs_raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/wordpress", default=[] + ) + oauth_configs: list[Any] = ( + cast(list[Any], oauth_configs_raw) + if isinstance(oauth_configs_raw, list) + else [] + ) + for cfg in oauth_configs: + c: dict[str, Any] = cast(dict[str, Any], cfg) + if c.get("_id") == oauth_config_id: + shared: dict[str, Any] = cast( + dict[str, Any], c.get("config", {}) + ) + client_id = str( + shared.get("clientId") + or shared.get("client_id") + or client_id + ) + client_secret = str( + shared.get("clientSecret") + or shared.get("client_secret") + or client_secret + ) + break + except Exception as e: + logger.warning( + f"Failed to fetch shared OAuth config: {e}" + ) + + oauth_cfg = WordPressOAuthConfig( + access_token=access_token, + site_id=site_id, + client_id=client_id, + client_secret=client_secret, + ) + return cls(oauth_cfg.create_client()) + + except Exception as e: + logger.error( + f"Failed to build WordPress client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for WordPress.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get WordPress connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error(f"Failed to get WordPress connector config: {e}") + raise ValueError( + f"Failed to get WordPress connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/workplace/workplace.py b/backend/python/app/sources/client/workplace/workplace.py new file mode 100644 index 000000000..253c99c05 --- /dev/null +++ b/backend/python/app/sources/client/workplace/workplace.py @@ -0,0 +1,325 @@ +"""Facebook Workplace (Meta Workplace) client implementation. + +This module provides a client for interacting with the Facebook Workplace API +using Access Token (Bearer) authentication generated from the Workplace admin +panel. + +Base URL: https://graph.facebook.com/v18.0 + +Authentication: Access tokens are generated from the Workplace admin panel +and passed as Bearer tokens. + +API Reference: https://developers.facebook.com/docs/workplace/reference +""" + +import base64 +import json +import logging +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override + +from app.config.configuration_service import ConfigurationService +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class WorkplaceResponse(BaseModel): + """Standardized Workplace API response wrapper. + + The data field supports JSON responses (dict/list) and binary file + downloads (bytes). When serializing to dict/JSON, binary data is + automatically base64-encoded. + """ + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | bytes | None = Field( + default=None, description="Response data (JSON) or file content (bytes)" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary. + + Binary data is base64-encoded for safe serialization. + """ + result = self.model_dump(exclude_none=True) + if isinstance(result.get("data"), bytes): + result["data"] = base64.b64encode(result["data"]).decode("utf-8") + return result + + def to_json(self) -> str: + """Convert response to JSON string. + + Binary data is base64-encoded for safe serialization. + """ + if isinstance(self.data, bytes): + result = self.model_dump(exclude_none=True) + result["data"] = base64.b64encode(self.data).decode("utf-8") + return json.dumps(result) + return self.model_dump_json(exclude_none=True) + + +# --------------------------------------------------------------------------- +# REST client class +# --------------------------------------------------------------------------- + + +class WorkplaceRESTClientViaToken(HTTPClient): + """Workplace REST client via Access Token (Bearer). + + Simple authentication using an access token generated from the + Workplace admin panel, passed as a Bearer token in the + Authorization header. + + Args: + token: The access token from Workplace admin panel + base_url: API base URL (default: https://graph.facebook.com/v18.0) + """ + + def __init__( + self, + token: str, + base_url: str = "https://graph.facebook.com/v18.0", + ) -> None: + super().__init__(token, token_type="Bearer") + self.base_url = base_url + self.headers["Content-Type"] = "application/json" + + def get_base_url(self) -> str: + """Get the base URL.""" + return self.base_url + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class WorkplaceTokenConfig(BaseModel): + """Configuration for Workplace client via Access Token. + + Args: + token: The access token from Workplace admin panel + base_url: API base URL (default: https://graph.facebook.com/v18.0) + """ + + token: str + base_url: str = "https://graph.facebook.com/v18.0" + + def create_client(self) -> WorkplaceRESTClientViaToken: + return WorkplaceRESTClientViaToken(self.token, self.base_url) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class WorkplaceAuthConfig(BaseModel): + """Auth section of the Workplace connector configuration from etcd.""" + + accessToken: str | None = None + token: str | None = None + + class Config: + extra = "allow" + + +class WorkplaceCredentialsConfig(BaseModel): + """Credentials section of the Workplace connector configuration.""" + + access_token: str | None = None + + class Config: + extra = "allow" + + +class WorkplaceConnectorConfig(BaseModel): + """Top-level Workplace connector configuration from etcd.""" + + auth: WorkplaceAuthConfig = Field(default_factory=WorkplaceAuthConfig) + credentials: WorkplaceCredentialsConfig = Field( + default_factory=WorkplaceCredentialsConfig + ) + + class Config: + extra = "allow" + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class WorkplaceClient(IClient): + """Builder class for Workplace clients. + + Supports: + - Access Token (Bearer) authentication from Workplace admin panel + """ + + def __init__( + self, + client: WorkplaceRESTClientViaToken, + ) -> None: + """Initialize with a Workplace client object.""" + super().__init__() + self.client = client + + @override + def get_client(self) -> WorkplaceRESTClientViaToken: + """Return the Workplace client object.""" + return self.client + + def get_base_url(self) -> str: + """Return the base URL.""" + return self.client.get_base_url() + + @classmethod + def build_with_config( + cls, + config: WorkplaceTokenConfig, + ) -> "WorkplaceClient": + """Build WorkplaceClient with configuration. + + Args: + config: WorkplaceTokenConfig instance + + Returns: + WorkplaceClient instance + """ + return cls(config.create_client()) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "WorkplaceClient": + """Build WorkplaceClient using configuration service. + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + WorkplaceClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get Workplace connector configuration" + ) + + connector_config = WorkplaceConnectorConfig.model_validate( + raw_config + ) + + token = ( + connector_config.auth.accessToken + or connector_config.auth.token + or connector_config.credentials.access_token + or "" + ) + if not token: + raise ValueError( + "Access token required for Workplace" + ) + + token_config = WorkplaceTokenConfig(token=token) + return cls(token_config.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Workplace client from services: {str(e)}" + ) + raise + + @classmethod + async def build_from_toolset( + cls, + toolset_config: dict[str, Any], + logger: logging.Logger, + config_service: ConfigurationService | None = None, + ) -> "WorkplaceClient": + """Build client from per-user toolset configuration. + + Args: + toolset_config: Per-user toolset configuration dict + logger: Logger instance + config_service: Optional configuration service (unused) + + Returns: + WorkplaceClient instance + """ + try: + credentials: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("credentials", {}) or {} + ) + auth_config: dict[str, Any] = cast( + dict[str, Any], toolset_config.get("auth", {}) or {} + ) + + access_token: str = str( + credentials.get("access_token", "") + or auth_config.get("accessToken", "") + or auth_config.get("token", "") + ) + if not access_token: + raise ValueError( + "Access token not found in toolset config" + ) + + token_config = WorkplaceTokenConfig(token=access_token) + return cls(token_config.create_client()) + + except Exception as e: + logger.error( + f"Failed to build Workplace client from toolset: {str(e)}" + ) + raise + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Workplace.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get Workplace connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error( + f"Failed to get Workplace connector config: {e}" + ) + raise ValueError( + f"Failed to get Workplace connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/client/zoho/zoho.py b/backend/python/app/sources/client/zoho/zoho.py new file mode 100644 index 000000000..987914521 --- /dev/null +++ b/backend/python/app/sources/client/zoho/zoho.py @@ -0,0 +1,595 @@ +"""Zoho CRM client implementation. + +This module provides a client for interacting with the Zoho CRM API using the +official Zoho CRM SDK (zohocrmsdk8-0). The SDK uses a global Initializer pattern, +so this client wraps that initialization and exposes operation classes. + +Authentication Reference: https://www.zoho.com/crm/developer/docs/api/v7/auth-request.html +SDK Reference: https://github.com/zoho/zohocrm-python-sdk-7.0 + +Supports: +1. OAuth with grant_token (initial authorization) +2. OAuth with refresh_token (token renewal) +""" + +import logging +from enum import Enum +from typing import Any, cast + +from pydantic import BaseModel, Field # type: ignore +from typing_extensions import override +from zohocrmsdk.src.com.zoho.api.authenticator import ( # type: ignore[reportMissingImports,reportUnknownVariableType] + OAuthToken, # type: ignore[reportUnknownVariableType] +) + +# --------------------------------------------------------------------------- +# Zoho CRM SDK imports (untyped third-party package) +# --------------------------------------------------------------------------- +from zohocrmsdk.src.com.zoho.crm.api import Initializer # type: ignore[reportMissingImports,reportUnknownVariableType] +from zohocrmsdk.src.com.zoho.crm.api.dc import ( # type: ignore[reportMissingImports,reportUnknownVariableType] + AUDataCenter, # type: ignore[reportUnknownVariableType] + CADataCenter, # type: ignore[reportUnknownVariableType] + CNDataCenter, # type: ignore[reportUnknownVariableType] + EUDataCenter, # type: ignore[reportUnknownVariableType] + INDataCenter, # type: ignore[reportUnknownVariableType] + JPDataCenter, # type: ignore[reportUnknownVariableType] + USDataCenter, # type: ignore[reportUnknownVariableType] +) +from zohocrmsdk.src.com.zoho.crm.api.modules import ( # type: ignore[reportMissingImports,reportUnknownVariableType] + ModulesOperations, # type: ignore[reportUnknownVariableType] +) +from zohocrmsdk.src.com.zoho.crm.api.org import ( # type: ignore[reportMissingImports,reportUnknownVariableType] + OrgOperations, # type: ignore[reportUnknownVariableType] +) +from zohocrmsdk.src.com.zoho.crm.api.profiles import ( # type: ignore[reportMissingImports,reportUnknownVariableType] + ProfilesOperations, # type: ignore[reportUnknownVariableType] +) +from zohocrmsdk.src.com.zoho.crm.api.record import ( # type: ignore[reportMissingImports,reportUnknownVariableType] + RecordOperations, # type: ignore[reportUnknownVariableType] +) +from zohocrmsdk.src.com.zoho.crm.api.roles import ( # type: ignore[reportMissingImports,reportUnknownVariableType] + RolesOperations, # type: ignore[reportUnknownVariableType] +) +from zohocrmsdk.src.com.zoho.crm.api.users import ( # type: ignore[reportMissingImports,reportUnknownVariableType] + UsersOperations, # type: ignore[reportUnknownVariableType] +) + +from app.config.configuration_service import ConfigurationService +from app.sources.client.iclient import IClient + +# --------------------------------------------------------------------------- +# Enums +# --------------------------------------------------------------------------- + + +class ZohoAuthType(str, Enum): + """Authentication types supported by the Zoho CRM connector.""" + + GRANT_TOKEN = "GRANT_TOKEN" + REFRESH_TOKEN = "REFRESH_TOKEN" + OAUTH = "OAUTH" + + +class ZohoDomain(str, Enum): + """Zoho data center domains.""" + + US = "US" + EU = "EU" + IN = "IN" + CN = "CN" + AU = "AU" + JP = "JP" + CA = "CA" + + +# --------------------------------------------------------------------------- +# Domain resolver +# --------------------------------------------------------------------------- + +_DATA_CENTER_MAP: dict[str, Any] = { + "US": USDataCenter, + "EU": EUDataCenter, + "IN": INDataCenter, + "CN": CNDataCenter, + "AU": AUDataCenter, + "JP": JPDataCenter, + "CA": CADataCenter, +} + + +def _resolve_environment(domain: str) -> object: + """Resolve a Zoho data center domain string to an SDK environment object. + + Args: + domain: One of US, EU, IN, CN, AU, JP, CA + + Returns: + The SDK PRODUCTION environment for the given data center + """ + dc_class = _DATA_CENTER_MAP.get(domain.upper()) + if dc_class is None: + raise ValueError( + f"Unsupported Zoho domain: {domain}. " + f"Supported: {', '.join(_DATA_CENTER_MAP.keys())}" + ) + return dc_class.PRODUCTION() # type: ignore[no-any-return] + + +# --------------------------------------------------------------------------- +# Response model +# --------------------------------------------------------------------------- + + +class ZohoResponse(BaseModel): + """Standardized Zoho CRM API response wrapper.""" + + success: bool = Field(..., description="Whether the request was successful") + data: dict[str, object] | list[object] | None = Field( + default=None, description="Response data" + ) + error: str | None = Field(default=None, description="Error message if failed") + message: str | None = Field( + default=None, description="Additional message information" + ) + + class Config: + """Pydantic configuration.""" + + extra = "allow" + + def to_dict(self) -> dict[str, object]: + """Convert response to dictionary.""" + return self.model_dump(exclude_none=True) + + +# --------------------------------------------------------------------------- +# SDK client wrapper +# --------------------------------------------------------------------------- + + +class ZohoClientViaOAuth: + """Zoho CRM client via OAuth. + + Wraps the Zoho CRM SDK global Initializer and provides access to + SDK operation classes (RecordOperations, UsersOperations, etc.). + + The Zoho SDK uses a global Initializer pattern, so this class manages + initialization state and prevents re-initialization. + + Args: + client_id: OAuth client ID + client_secret: OAuth client secret + domain: Zoho data center domain (US, EU, IN, CN, AU, JP, CA) + grant_token: OAuth grant token (for initial authorization) + refresh_token: OAuth refresh token (for token renewal) + """ + + def __init__( + self, + client_id: str, + client_secret: str, + domain: str = "US", + grant_token: str | None = None, + refresh_token: str | None = None, + ) -> None: + if not client_id or not client_secret: + raise ValueError("client_id and client_secret are required") + if not grant_token and not refresh_token: + raise ValueError( + "Either grant_token or refresh_token must be provided" + ) + + self.client_id = client_id + self.client_secret = client_secret + self.domain = domain + self.grant_token = grant_token + self.refresh_token = refresh_token + self._initialized = False + + def initialize(self) -> None: + """Initialize the Zoho CRM SDK global state. + + This sets up the SDK Initializer with the OAuth token and + data center environment. Must be called before using any + operation classes. + """ + if self._initialized: + return + + environment = _resolve_environment(self.domain) + + token_kwargs: dict[str, str] = { + "client_id": self.client_id, + "client_secret": self.client_secret, + } + if self.grant_token: + token_kwargs["grant_token"] = self.grant_token + if self.refresh_token: + token_kwargs["refresh_token"] = self.refresh_token + + token = OAuthToken(**token_kwargs) # type: ignore[no-untyped-call] + + Initializer.initialize( # type: ignore[no-untyped-call] + environment=environment, + token=token, + ) + self._initialized = True + + def ensure_initialized(self) -> None: + """Ensure the SDK is initialized. Calls initialize() if needed.""" + if not self._initialized: + self.initialize() + + def get_record_operations(self, module_api_name: str) -> RecordOperations: # type: ignore[no-any-return] + """Get RecordOperations instance for a given module. + + Args: + module_api_name: The API name of the module (e.g., 'Leads', 'Contacts') + + Returns: + RecordOperations instance + """ + self.ensure_initialized() + return RecordOperations(module_api_name) # type: ignore[no-untyped-call] + + def get_users_operations(self) -> UsersOperations: # type: ignore[no-any-return] + """Get UsersOperations instance. + + Returns: + UsersOperations instance + """ + self.ensure_initialized() + return UsersOperations() # type: ignore[no-untyped-call] + + def get_modules_operations(self) -> ModulesOperations: # type: ignore[no-any-return] + """Get ModulesOperations instance. + + Returns: + ModulesOperations instance + """ + self.ensure_initialized() + return ModulesOperations() # type: ignore[no-untyped-call] + + def get_roles_operations(self) -> RolesOperations: # type: ignore[no-any-return] + """Get RolesOperations instance. + + Returns: + RolesOperations instance + """ + self.ensure_initialized() + return RolesOperations() # type: ignore[no-untyped-call] + + def get_profiles_operations(self) -> ProfilesOperations: # type: ignore[no-any-return] + """Get ProfilesOperations instance. + + Returns: + ProfilesOperations instance + """ + self.ensure_initialized() + return ProfilesOperations() # type: ignore[no-untyped-call] + + def get_org_operations(self) -> OrgOperations: # type: ignore[no-any-return] + """Get OrgOperations instance. + + Returns: + OrgOperations instance + """ + self.ensure_initialized() + return OrgOperations() # type: ignore[no-untyped-call] + + def get_domain(self) -> str: + """Get the configured Zoho domain.""" + return self.domain + + +# --------------------------------------------------------------------------- +# Configuration models (Pydantic) +# --------------------------------------------------------------------------- + + +class ZohoGrantTokenConfig(BaseModel): + """Configuration for Zoho CRM client via grant token. + + Args: + client_id: OAuth client ID + client_secret: OAuth client secret + grant_token: OAuth grant token + domain: Zoho data center domain + """ + + client_id: str = Field(..., description="OAuth client ID") + client_secret: str = Field(..., description="OAuth client secret") + grant_token: str = Field(..., description="OAuth grant token") + domain: str = Field(default="US", description="Zoho data center domain") + + def create_client(self) -> ZohoClientViaOAuth: + """Create a Zoho CRM client.""" + return ZohoClientViaOAuth( + client_id=self.client_id, + client_secret=self.client_secret, + domain=self.domain, + grant_token=self.grant_token, + ) + + +class ZohoRefreshTokenConfig(BaseModel): + """Configuration for Zoho CRM client via refresh token. + + Args: + client_id: OAuth client ID + client_secret: OAuth client secret + refresh_token: OAuth refresh token + domain: Zoho data center domain + """ + + client_id: str = Field(..., description="OAuth client ID") + client_secret: str = Field(..., description="OAuth client secret") + refresh_token: str = Field(..., description="OAuth refresh token") + domain: str = Field(default="US", description="Zoho data center domain") + + def create_client(self) -> ZohoClientViaOAuth: + """Create a Zoho CRM client.""" + return ZohoClientViaOAuth( + client_id=self.client_id, + client_secret=self.client_secret, + domain=self.domain, + refresh_token=self.refresh_token, + ) + + +# --------------------------------------------------------------------------- +# Connector configuration models for build_from_services +# --------------------------------------------------------------------------- + + +class ZohoAuthConfig(BaseModel): + """Auth section of the Zoho CRM connector configuration from etcd.""" + + authType: ZohoAuthType = ZohoAuthType.OAUTH + clientId: str | None = None + clientSecret: str | None = None + grantToken: str | None = None + domain: str | None = Field(default="US") + oauthConfigId: str | None = None + + class Config: + extra = "allow" + + +class ZohoCredentialsConfig(BaseModel): + """Credentials section of the Zoho CRM connector configuration.""" + + access_token: str | None = None + refresh_token: str | None = None + + class Config: + extra = "allow" + + +class ZohoConnectorConfig(BaseModel): + """Top-level Zoho CRM connector configuration from etcd.""" + + auth: ZohoAuthConfig = Field(default_factory=ZohoAuthConfig) + credentials: ZohoCredentialsConfig = Field( + default_factory=ZohoCredentialsConfig + ) + + class Config: + extra = "allow" + + +class ZohoSharedOAuthConfigEntry(BaseModel): + """A single entry from the shared OAuth config list in etcd. + + Handles both camelCase and snake_case key variants from the config store. + """ + + entry_id: str | None = Field(default=None, alias="_id") + clientId: str | None = None + client_id: str | None = None + clientSecret: str | None = None + client_secret: str | None = None + + class Config: + extra = "allow" + populate_by_name = True + + def resolved_client_id(self, fallback: str = "") -> str: + return self.clientId or self.client_id or fallback + + def resolved_client_secret(self, fallback: str = "") -> str: + return self.clientSecret or self.client_secret or fallback + + +class ZohoSharedOAuthWrapper(BaseModel): + """Wrapper for a shared OAuth config entry with nested config.""" + + entry_id: str | None = Field(default=None, alias="_id") + config: ZohoSharedOAuthConfigEntry = Field( + default_factory=ZohoSharedOAuthConfigEntry + ) + + class Config: + extra = "allow" + populate_by_name = True + + +# --------------------------------------------------------------------------- +# Client builder +# --------------------------------------------------------------------------- + + +class ZohoClient(IClient): + """Builder class for Zoho CRM clients. + + Wraps ZohoClientViaOAuth and manages SDK initialization. + """ + + def __init__(self, client: ZohoClientViaOAuth) -> None: + """Initialize with a Zoho CRM client object.""" + super().__init__() + self.client = client + + @override + def get_client(self) -> ZohoClientViaOAuth: + """Return the Zoho CRM client object.""" + return self.client + + def get_domain(self) -> str: + """Return the configured Zoho domain.""" + return self.client.get_domain() + + @classmethod + def build_with_config( + cls, + config: ZohoGrantTokenConfig | ZohoRefreshTokenConfig, + ) -> "ZohoClient": + """Build ZohoClient with configuration. + + Args: + config: ZohoGrantTokenConfig or ZohoRefreshTokenConfig instance + + Returns: + ZohoClient instance + """ + client = config.create_client() + client.initialize() + return cls(client) + + @classmethod + async def build_from_services( + cls, + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> "ZohoClient": + """Build ZohoClient using configuration service. + + Supports OAuth authentication with grant_token or refresh_token. + + Args: + logger: Logger instance + config_service: Configuration service instance + connector_instance_id: Optional connector instance ID + + Returns: + ZohoClient instance + """ + try: + raw_config = await cls._get_connector_config( + logger, config_service, connector_instance_id + ) + if not raw_config: + raise ValueError( + "Failed to get Zoho CRM connector configuration" + ) + + connector_config = ZohoConnectorConfig.model_validate(raw_config) + + client_id = connector_config.auth.clientId or "" + client_secret = connector_config.auth.clientSecret or "" + domain = connector_config.auth.domain or "US" + + # Try shared OAuth config if credentials are missing + oauth_config_id = connector_config.auth.oauthConfigId + if oauth_config_id and not (client_id and client_secret): + shared_cfg = await cls._find_shared_oauth_config( + config_service, oauth_config_id, logger + ) + if shared_cfg: + client_id = shared_cfg.resolved_client_id(client_id) + client_secret = shared_cfg.resolved_client_secret( + client_secret + ) + + if not (client_id and client_secret): + raise ValueError( + "client_id and client_secret are required " + "for Zoho CRM authentication" + ) + + # Prefer refresh_token from credentials, fall back to grant_token + refresh_token = connector_config.credentials.refresh_token or "" + grant_token = connector_config.auth.grantToken or "" + + if refresh_token: + zoho_client = ZohoClientViaOAuth( + client_id=client_id, + client_secret=client_secret, + domain=domain, + refresh_token=refresh_token, + ) + elif grant_token: + zoho_client = ZohoClientViaOAuth( + client_id=client_id, + client_secret=client_secret, + domain=domain, + grant_token=grant_token, + ) + else: + raise ValueError( + "Either refresh_token or grant_token is required " + "for Zoho CRM authentication" + ) + + zoho_client.initialize() + return cls(zoho_client) + + except Exception as e: + logger.error( + f"Failed to build Zoho CRM client from services: {str(e)}" + ) + raise + + @staticmethod + async def _find_shared_oauth_config( + config_service: ConfigurationService, + oauth_config_id: str, + logger: logging.Logger, + ) -> ZohoSharedOAuthConfigEntry | None: + """Look up shared OAuth config by ID from the config store. + + Args: + config_service: Configuration service instance + oauth_config_id: The shared OAuth config ID to match + logger: Logger instance + + Returns: + Matched ZohoSharedOAuthConfigEntry or None + """ + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + "/services/oauth/zoho", default=[] + ) + entries: list[object] = list(raw) if isinstance(raw, list) else [] # type: ignore[arg-type] + for entry in entries: + wrapper = ZohoSharedOAuthWrapper.model_validate(entry) + if wrapper.entry_id == oauth_config_id: + return wrapper.config + except Exception as e: + logger.warning(f"Failed to fetch shared OAuth config: {e}") + return None + + @staticmethod + async def _get_connector_config( + logger: logging.Logger, + config_service: ConfigurationService, + connector_instance_id: str | None = None, + ) -> dict[str, Any]: + """Fetch connector config from etcd for Zoho CRM.""" + try: + raw = await config_service.get_config( # type: ignore[reportUnknownMemberType] + f"/services/connectors/{connector_instance_id}/config" + ) + if not raw: + raise ValueError( + f"Failed to get Zoho CRM connector configuration " + f"for instance {connector_instance_id}" + ) + return cast(dict[str, Any], raw) + except Exception as e: + logger.error(f"Failed to get Zoho CRM connector config: {e}") + raise ValueError( + f"Failed to get Zoho CRM connector configuration " + f"for instance {connector_instance_id}" + ) from e diff --git a/backend/python/app/sources/external/adobeaem/adobeaem.py b/backend/python/app/sources/external/adobeaem/adobeaem.py new file mode 100644 index 000000000..1469830d9 --- /dev/null +++ b/backend/python/app/sources/external/adobeaem/adobeaem.py @@ -0,0 +1,305 @@ +# ruff: noqa +""" +Adobe Experience Manager (AEM as Cloud Service) REST API DataSource - Auto-generated API wrapper + +Generated from AEM Assets HTTP API and QueryBuilder API documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.adobeaem.adobeaem import AdobeAEMClient, AdobeAEMResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class AdobeAEMDataSource: + """Adobe AEM REST API DataSource + + Provides async wrapper methods for AEM REST API operations: + - Assets management (list, get) + - DAM content browsing + - User/authorizable search + - QueryBuilder queries + - Package management + + The base URL is https://{instance}.adobeaemcloud.com. + + All methods return AdobeAEMResponse objects. + """ + + def __init__(self, client: AdobeAEMClient) -> None: + """Initialize with AdobeAEMClient. + + Args: + client: AdobeAEMClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'AdobeAEMDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> AdobeAEMClient: + """Return the underlying AdobeAEMClient.""" + return self._client + + # ----------------------------------------------------------------------- + # Assets API + # ----------------------------------------------------------------------- + + async def list_assets( + self, + *, + limit: int | None = None, + start: int | None = None, + orderby: str | None = None, + ) -> AdobeAEMResponse: + """List assets via the Assets HTTP API. + + Args: + limit: Maximum number of assets to return + start: Offset for pagination + orderby: Field to order results by + + Returns: + AdobeAEMResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if start is not None: + query_params['start'] = str(start) + if orderby is not None: + query_params['orderby'] = orderby + + url = self.base_url + "/api/assets.json" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AdobeAEMResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_assets" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AdobeAEMResponse(success=False, error=str(e), message="Failed to execute list_assets") + + async def get_asset( + self, + path: str, + ) -> AdobeAEMResponse: + """Get a specific asset by path. + + Args: + path: The asset path (e.g., "my-folder/my-asset.png") + + Returns: + AdobeAEMResponse with operation result + """ + # Ensure path doesn't start with / + clean_path = path.lstrip('/') + url = self.base_url + "/api/assets/{path}.json".format(path=clean_path) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AdobeAEMResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_asset" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AdobeAEMResponse(success=False, error=str(e), message="Failed to execute get_asset") + + # ----------------------------------------------------------------------- + # DAM Content + # ----------------------------------------------------------------------- + + async def get_dam_content( + self, + *, + limit: int | None = None, + start: int | None = None, + ) -> AdobeAEMResponse: + """Browse DAM content. + + Args: + limit: Maximum number of results to return + start: Offset for pagination + + Returns: + AdobeAEMResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if start is not None: + query_params['start'] = str(start) + + url = self.base_url + "/content/dam.json" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AdobeAEMResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_dam_content" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AdobeAEMResponse(success=False, error=str(e), message="Failed to execute get_dam_content") + + # ----------------------------------------------------------------------- + # Authorizables (Users/Groups) + # ----------------------------------------------------------------------- + + async def search_authorizables( + self, + query: str, + ) -> AdobeAEMResponse: + """Search for authorizables (users/groups). + + Args: + query: Search query string + + Returns: + AdobeAEMResponse with operation result + """ + query_params: dict[str, Any] = {'query': query} + + url = self.base_url + "/libs/granite/security/search/authorizables.json" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AdobeAEMResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed search_authorizables" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AdobeAEMResponse(success=False, error=str(e), message="Failed to execute search_authorizables") + + # ----------------------------------------------------------------------- + # QueryBuilder + # ----------------------------------------------------------------------- + + async def query_builder( + self, + *, + path: str | None = None, + type: str | None = None, + p_limit: int | None = None, + orderby: str | None = None, + fulltext: str | None = None, + ) -> AdobeAEMResponse: + """Execute a QueryBuilder query. + + Args: + path: Content path to search under + type: Node type to filter (e.g., "dam:Asset", "cq:Page") + p_limit: Maximum number of results + orderby: Sort field (e.g., "@jcr:content/jcr:lastModified") + fulltext: Full-text search query + + Returns: + AdobeAEMResponse with operation result + """ + query_params: dict[str, Any] = {} + if path is not None: + query_params['path'] = path + if type is not None: + query_params['type'] = type + if p_limit is not None: + query_params['p.limit'] = str(p_limit) + if orderby is not None: + query_params['orderby'] = orderby + if fulltext is not None: + query_params['fulltext'] = fulltext + + url = self.base_url + "/bin/querybuilder.json" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AdobeAEMResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed query_builder" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AdobeAEMResponse(success=False, error=str(e), message="Failed to execute query_builder") + + # ----------------------------------------------------------------------- + # Package Manager + # ----------------------------------------------------------------------- + + async def get_package_list( + self, + ) -> AdobeAEMResponse: + """List packages via CRX Package Manager. + + Returns: + AdobeAEMResponse with operation result + """ + query_params: dict[str, Any] = {'cmd': 'ls'} + + url = self.base_url + "/crx/packmgr/service.jsp" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AdobeAEMResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_package_list" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AdobeAEMResponse(success=False, error=str(e), message="Failed to execute get_package_list") diff --git a/backend/python/app/sources/external/adobeaem/code_generator.py b/backend/python/app/sources/external/adobeaem/code_generator.py new file mode 100644 index 000000000..dffe1a7c5 --- /dev/null +++ b/backend/python/app/sources/external/adobeaem/code_generator.py @@ -0,0 +1,243 @@ +# ruff: noqa +""" +Adobe Experience Manager (AEM) DataSource Code Generator + +Defines AEM API endpoint specifications and generates the DataSource +wrapper class (adobeaem.py) from them. + +Endpoints: + /api/assets.json, /api/assets/{path}.json, /content/dam.json, + /libs/granite/security/search/authorizables.json, + /bin/querybuilder.json, /crx/packmgr/service.jsp +""" + +from __future__ import annotations + +ENDPOINTS = [ + # Assets API + {"method": "GET", "path": "/api/assets.json", "name": "list_assets", "section": "Assets API", + "doc": "List assets via the Assets HTTP API", + "extra_query": [("limit", "limit", "int | None", "Maximum number of assets to return", True), + ("start", "start", "int | None", "Offset for pagination", True), + ("orderby", "orderby", "str | None", "Field to order results by", True)]}, + {"method": "GET", "path": "/api/assets/{path}.json", "name": "get_asset", "section": "Assets API", + "doc": "Get a specific asset by path", "path_params": ["path"], + "path_transform": {"path": "lstrip('/')"}}, + # DAM Content + {"method": "GET", "path": "/content/dam.json", "name": "get_dam_content", "section": "DAM Content", + "doc": "Browse DAM content", + "extra_query": [("limit", "limit", "int | None", "Maximum number of results to return", True), + ("start", "start", "int | None", "Offset for pagination", True)]}, + # Authorizables + {"method": "GET", "path": "/libs/granite/security/search/authorizables.json", + "name": "search_authorizables", "section": "Authorizables (Users/Groups)", + "doc": "Search for authorizables (users/groups)", + "query_params": [("query", "query", "str", "Search query string")]}, + # QueryBuilder + {"method": "GET", "path": "/bin/querybuilder.json", "name": "query_builder", "section": "QueryBuilder", + "doc": "Execute a QueryBuilder query", + "extra_query": [("path", "path", "str | None", "Content path to search under", True), + ("type", "type", "str | None", 'Node type to filter (e.g., "dam:Asset", "cq:Page")', True), + ("p_limit", "p.limit", "int | None", "Maximum number of results", True), + ("orderby", "orderby", "str | None", 'Sort field (e.g., "@jcr:content/jcr:lastModified")', True), + ("fulltext", "fulltext", "str | None", "Full-text search query", True)]}, + # Package Manager + {"method": "GET", "path": "/crx/packmgr/service.jsp", "name": "get_package_list", "section": "Package Manager", + "doc": "List packages via CRX Package Manager", + "fixed_query": {"cmd": "ls"}}, +] + + +def _gen_method(ep: dict) -> str: + """Generate a single async method from an endpoint spec.""" + name = ep["name"] + method = ep["method"] + path = ep["path"] + doc = ep["doc"] + path_params = ep.get("path_params", []) + body_params = ep.get("body_params", []) + query_params = ep.get("query_params", []) + extra_query = ep.get("extra_query", []) + fixed_query = ep.get("fixed_query", {}) + has_query = query_params or extra_query or fixed_query + + sig_parts = ["self"] + for p in path_params: + sig_parts.append(f"{p}: str") + for qp in query_params: + sig_parts.append(f"{qp[0]}: {qp[2]}") + for bp in body_params: + sig_parts.append(f"{bp[0]}: {bp[2]}") + if extra_query: + sig_parts.append("*") + for eq in extra_query: + sig_parts.append(f"{eq[0]}: {eq[2]} = None") + + sig = ",\n ".join(sig_parts) + + doc_args = "" + if path_params or query_params or body_params or extra_query: + doc_args = "\n Args:\n" + for p in path_params: + doc_args += f" {p}: The {p.replace('_', ' ')}\n" + for qp in query_params: + doc_args += f" {qp[0]}: {qp[3]}\n" + for bp in body_params: + doc_args += f" {bp[0]}: {bp[3]}\n" + for eq in extra_query: + doc_args += f" {eq[0]}: {eq[3]}\n" + + query_block = "" + if has_query: + lines = ["", " query_params: dict[str, Any] = {}"] + for k, v in fixed_query.items(): + lines.append(f" query_params['{k}'] = '{v}'") + for qp in query_params: + lines.append(f" query_params['{qp[1]}'] = {qp[0]}") + for eq in extra_query: + lines.append(f" if {eq[0]} is not None:") + if "int" in eq[2]: + lines.append(f" query_params['{eq[1]}'] = str({eq[0]})") + else: + lines.append(f" query_params['{eq[1]}'] = {eq[0]}") + query_block = "\n".join(lines) + "\n" + + path_transform = ep.get("path_transform", {}) + if path_params: + if path_transform: + transform_lines = [] + for p in path_params: + if p in path_transform: + transform_lines.append(f" clean_{p} = {p}.{path_transform[p]}") + if transform_lines: + url_line = "\n".join(transform_lines) + "\n" + fmt_args = ", ".join(f"{p}=clean_{p}" if p in path_transform else f"{p}={p}" for p in path_params) + else: + fmt_args = ", ".join(f"{p}={p}" for p in path_params) + url_line = "" + url_line += f' url = self.base_url + "{path}".format({fmt_args})' + else: + fmt_args = ", ".join(f"{p}={p}" for p in path_params) + url_line = f' url = self.base_url + "{path}".format({fmt_args})' + else: + url_line = f' url = self.base_url + "{path}"' + + body_block = "" + if body_params: + lines = ["\n body: dict[str, Any] = {"] + for bp in body_params: + lines.append(f' "{bp[1]}": {bp[0]},') + lines.append(" }") + body_block = "\n".join(lines) + + req_extra = "" + if has_query: + req_extra += "\n query=query_params," + if body_params: + req_extra += "\n body=body," + + return f''' + async def {name}( + {sig}, + ) -> AdobeAEMResponse: + """{doc} + + HTTP {method} {path} +{doc_args} + Returns: + AdobeAEMResponse with operation result + """ +{query_block} +{url_line} + + try: + request = HTTPRequest( + method="{method}", + url=url, + headers={{"Content-Type": "application/json"}},{req_extra} + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AdobeAEMResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed {name}" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {{response.status}}" + ) + except Exception as e: + return AdobeAEMResponse(success=False, error=str(e), message="Failed to execute {name}") +''' + + +def generate_datasource() -> str: + """Generate the full Adobe AEM DataSource module code.""" + header = '''# ruff: noqa +""" +Adobe Experience Manager (AEM as Cloud Service) REST API DataSource - Auto-generated API wrapper + +Generated from AEM Assets HTTP API and QueryBuilder API documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.adobeaem.adobeaem import AdobeAEMClient, AdobeAEMResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class AdobeAEMDataSource: + """Adobe AEM REST API DataSource + + Provides async wrapper methods for AEM REST API operations: + - Assets management (list, get) + - DAM content browsing + - User/authorizable search + - QueryBuilder queries + - Package management + + The base URL is https://{instance}.adobeaemcloud.com. + + All methods return AdobeAEMResponse objects. + """ + + def __init__(self, client: AdobeAEMClient) -> None: + """Initialize with AdobeAEMClient. + + Args: + client: AdobeAEMClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'AdobeAEMDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> AdobeAEMClient: + """Return the underlying AdobeAEMClient.""" + return self._client +''' + + methods = [] + current_section = None + for ep in ENDPOINTS: + section = ep.get("section", "") + if section and section != current_section: + current_section = section + methods.append(f"\n # {'-' * 71}\n # {section}\n # {'-' * 71}") + methods.append(_gen_method(ep)) + + return header + "\n".join(methods) + "\n" + + +if __name__ == "__main__": + print(generate_datasource()) diff --git a/backend/python/app/sources/external/adobeaem/example.py b/backend/python/app/sources/external/adobeaem/example.py new file mode 100644 index 000000000..77ce58978 --- /dev/null +++ b/backend/python/app/sources/external/adobeaem/example.py @@ -0,0 +1,151 @@ +# ruff: noqa + +""" +Adobe Experience Manager (AEM as Cloud Service) API Usage Examples + +This example demonstrates how to use the AEM DataSource to interact with +the AEM API, covering: +- Authentication (Bearer Token) +- Initializing the Client and DataSource +- Listing and retrieving DAM assets +- Browsing DAM content +- Searching authorizables (users/groups) +- Running QueryBuilder queries +- Listing packages + +Prerequisites: +1. Obtain a Bearer token from Adobe Developer Console or via service account JWT +2. Set AEM_TOKEN and AEM_INSTANCE environment variables + AEM_INSTANCE should be the instance identifier + (e.g., "author-p12345-e67890" for author-p12345-e67890.adobeaemcloud.com) +""" + +import asyncio +import json +import os + +from app.sources.client.adobeaem.adobeaem import ( + AdobeAEMClient, + AdobeAEMTokenConfig, + AdobeAEMResponse, +) +from app.sources.external.adobeaem.adobeaem import AdobeAEMDataSource + +# --- Configuration --- +TOKEN = os.getenv("AEM_TOKEN") +INSTANCE = os.getenv("AEM_INSTANCE", "") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: AdobeAEMResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + if isinstance(data, list): + print(f" Found {len(data)} items.") + if data: + print(f" Sample: {json.dumps(data[0], indent=2)[:400]}...") + elif isinstance(data, dict): + for key in ("entities", "assets", "hits", "results", + "authorizables", "packages"): + if key in data: + items = data[key] + if isinstance(items, list): + print(f" Found {len(items)} {key}.") + if items: + print(f" Sample: {json.dumps(items[0], indent=2)[:400]}...") + return + print(f" Data: {json.dumps(data, indent=2)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + if not INSTANCE: + print(" AEM_INSTANCE environment variable is required.") + print(" Example: export AEM_INSTANCE=author-p12345-e67890") + return + + # 1. Initialize Client + print_section("Initializing AEM Client") + + if not TOKEN: + print(" No valid authentication method found.") + print(" Please set AEM_TOKEN environment variable.") + return + + print(" Using Bearer Token authentication") + config = AdobeAEMTokenConfig(token=TOKEN, instance=INSTANCE) + client = AdobeAEMClient.build_with_config(config) + data_source = AdobeAEMDataSource(client) + print(f"Client initialized successfully (instance: {INSTANCE}).") + + try: + # 2. List Assets + print_section("Assets") + assets_resp = await data_source.list_assets(limit=10) + print_result("List Assets", assets_resp) + + # Try to get a specific asset path + asset_path = None + if assets_resp.success and assets_resp.data and isinstance(assets_resp.data, dict): + entities = assets_resp.data.get("entities", []) + if isinstance(entities, list) and entities: + props = entities[0].get("properties", {}) + if isinstance(props, dict): + asset_path = props.get("name", "") + if asset_path: + print(f" Using Asset path: {asset_path}") + + if asset_path: + print_section("Asset Details") + asset_resp = await data_source.get_asset(asset_path) + print_result("Get Asset", asset_resp) + + # 3. Browse DAM Content + print_section("DAM Content") + dam_resp = await data_source.get_dam_content(limit=10) + print_result("Get DAM Content", dam_resp) + + # 4. Search Authorizables + print_section("Search Authorizables") + auth_resp = await data_source.search_authorizables(query="admin") + print_result("Search Authorizables", auth_resp) + + # 5. QueryBuilder Query + print_section("QueryBuilder - DAM Assets") + qb_resp = await data_source.query_builder( + path="/content/dam", + type="dam:Asset", + p_limit=10, + ) + print_result("QueryBuilder Query", qb_resp) + + # 6. Package List + print_section("Packages") + pkg_resp = await data_source.get_package_list() + print_result("Get Packages", pkg_resp) + + finally: + # Cleanup: Close the HTTP client session + print("\nClosing client connection...") + inner_client = client.get_client() + if hasattr(inner_client, "close"): + await inner_client.close() + + print("\n" + "=" * 80) + print(" All AEM API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/adobeaem/run_generator.py b/backend/python/app/sources/external/adobeaem/run_generator.py new file mode 100644 index 000000000..1e16aafec --- /dev/null +++ b/backend/python/app/sources/external/adobeaem/run_generator.py @@ -0,0 +1,24 @@ +"""Runner script to generate the Adobe AEM DataSource wrapper. + +Execute this script to regenerate adobeaem.py from the endpoint definitions +in code_generator.py. + +Usage: + python -m app.sources.external.adobeaem.run_generator +""" + +from app.sources.external.adobeaem.code_generator import generate_datasource + + +def main() -> None: + """Generate the Adobe AEM DataSource file.""" + code = generate_datasource() + output_path = __file__.replace("run_generator.py", "adobeaem.py") + with open(output_path, "w") as f: + f.write(code) + print(f"Generated Adobe AEM DataSource -> {output_path}") + print(f" Total characters: {len(code)}") + + +if __name__ == "__main__": + main() diff --git a/backend/python/app/sources/external/affinity/affinity.py b/backend/python/app/sources/external/affinity/affinity.py new file mode 100644 index 000000000..ff7561bd8 --- /dev/null +++ b/backend/python/app/sources/external/affinity/affinity.py @@ -0,0 +1,833 @@ +""" +Affinity REST API DataSource - Auto-generated API wrapper + +Generated from Affinity REST API documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.affinity.affinity import AffinityClient, AffinityResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class AffinityDataSource: + """Affinity REST API DataSource + + Provides async wrapper methods for Affinity REST API operations: + - Lists and list entries + - Persons + - Organizations + - Opportunities + - Notes + - Entity files + - Fields + - Relationship strengths + - Who Am I (authentication check) + + The base URL is https://api.affinity.co by default. + All methods return AffinityResponse objects. + """ + + def __init__(self, client: AffinityClient) -> None: + """Initialize with AffinityClient. + + Args: + client: AffinityClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip("/") + except AttributeError as exc: + raise ValueError( + "HTTP client does not have get_base_url method" + ) from exc + + def get_data_source(self) -> "AffinityDataSource": + """Return the data source instance.""" + return self + + def get_client(self) -> AffinityClient: + """Return the underlying AffinityClient.""" + return self._client + + # ------------------------------------------------------------------ + # Who Am I + # ------------------------------------------------------------------ + + async def whoami(self) -> AffinityResponse: + """Get the current authenticated user + + HTTP GET /whoami + + Returns: + AffinityResponse with current user data + """ + url = self.base_url + "/whoami" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AffinityResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed whoami" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return AffinityResponse( + success=False, + error=str(e), + message="Failed to execute whoami", + ) + + # ------------------------------------------------------------------ + # Lists + # ------------------------------------------------------------------ + + async def get_lists(self) -> AffinityResponse: + """Get all lists + + HTTP GET /lists + + Returns: + AffinityResponse with all lists + """ + url = self.base_url + "/lists" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AffinityResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_lists" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return AffinityResponse( + success=False, + error=str(e), + message="Failed to execute get_lists", + ) + + async def get_list( + self, + list_id: int | str, + ) -> AffinityResponse: + """Get a specific list by ID + + HTTP GET /lists/{list_id} + + Args: + list_id: The list ID + + Returns: + AffinityResponse with list data + """ + url = self.base_url + "/lists/{list_id}".format(list_id=list_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AffinityResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_list" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return AffinityResponse( + success=False, + error=str(e), + message="Failed to execute get_list", + ) + + # ------------------------------------------------------------------ + # List Entries + # ------------------------------------------------------------------ + + async def get_list_entries( + self, + list_id: int | str, + *, + page_size: int | None = None, + page_token: str | None = None, + ) -> AffinityResponse: + """Get entries in a list + + HTTP GET /lists/{list_id}/list-entries + + Args: + list_id: The list ID + page_size: Number of entries per page + page_token: Token for pagination + + Returns: + AffinityResponse with list entries + """ + query_params: dict[str, Any] = {} + if page_size is not None: + query_params["page_size"] = str(page_size) + if page_token is not None: + query_params["page_token"] = page_token + + url = self.base_url + "/lists/{list_id}/list-entries".format( + list_id=list_id + ) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AffinityResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_list_entries" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return AffinityResponse( + success=False, + error=str(e), + message="Failed to execute get_list_entries", + ) + + async def get_list_entry( + self, + list_id: int | str, + list_entry_id: int | str, + ) -> AffinityResponse: + """Get a specific list entry + + HTTP GET /lists/{list_id}/list-entries/{list_entry_id} + + Args: + list_id: The list ID + list_entry_id: The list entry ID + + Returns: + AffinityResponse with list entry data + """ + url = self.base_url + "/lists/{list_id}/list-entries/{list_entry_id}".format( + list_id=list_id, list_entry_id=list_entry_id + ) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AffinityResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_list_entry" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return AffinityResponse( + success=False, + error=str(e), + message="Failed to execute get_list_entry", + ) + + # ------------------------------------------------------------------ + # Persons + # ------------------------------------------------------------------ + + async def get_persons( + self, + *, + term: str | None = None, + page_size: int | None = None, + page_token: str | None = None, + ) -> AffinityResponse: + """Get all persons + + HTTP GET /persons + + Args: + term: Search term to filter persons + page_size: Number of results per page + page_token: Token for pagination + + Returns: + AffinityResponse with persons list + """ + query_params: dict[str, Any] = {} + if term is not None: + query_params["term"] = term + if page_size is not None: + query_params["page_size"] = str(page_size) + if page_token is not None: + query_params["page_token"] = page_token + + url = self.base_url + "/persons" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AffinityResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_persons" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return AffinityResponse( + success=False, + error=str(e), + message="Failed to execute get_persons", + ) + + async def get_person( + self, + person_id: int | str, + ) -> AffinityResponse: + """Get a person by ID + + HTTP GET /persons/{person_id} + + Args: + person_id: The person ID + + Returns: + AffinityResponse with person data + """ + url = self.base_url + "/persons/{person_id}".format( + person_id=person_id + ) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AffinityResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_person" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return AffinityResponse( + success=False, + error=str(e), + message="Failed to execute get_person", + ) + + # ------------------------------------------------------------------ + # Organizations + # ------------------------------------------------------------------ + + async def get_organizations( + self, + *, + term: str | None = None, + page_size: int | None = None, + page_token: str | None = None, + ) -> AffinityResponse: + """Get all organizations + + HTTP GET /organizations + + Args: + term: Search term to filter organizations + page_size: Number of results per page + page_token: Token for pagination + + Returns: + AffinityResponse with organizations list + """ + query_params: dict[str, Any] = {} + if term is not None: + query_params["term"] = term + if page_size is not None: + query_params["page_size"] = str(page_size) + if page_token is not None: + query_params["page_token"] = page_token + + url = self.base_url + "/organizations" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AffinityResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_organizations" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return AffinityResponse( + success=False, + error=str(e), + message="Failed to execute get_organizations", + ) + + async def get_organization( + self, + organization_id: int | str, + ) -> AffinityResponse: + """Get an organization by ID + + HTTP GET /organizations/{organization_id} + + Args: + organization_id: The organization ID + + Returns: + AffinityResponse with organization data + """ + url = self.base_url + "/organizations/{organization_id}".format( + organization_id=organization_id + ) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AffinityResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_organization" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return AffinityResponse( + success=False, + error=str(e), + message="Failed to execute get_organization", + ) + + # ------------------------------------------------------------------ + # Opportunities + # ------------------------------------------------------------------ + + async def get_opportunities( + self, + *, + term: str | None = None, + page_size: int | None = None, + page_token: str | None = None, + ) -> AffinityResponse: + """Get all opportunities + + HTTP GET /opportunities + + Args: + term: Search term to filter opportunities + page_size: Number of results per page + page_token: Token for pagination + + Returns: + AffinityResponse with opportunities list + """ + query_params: dict[str, Any] = {} + if term is not None: + query_params["term"] = term + if page_size is not None: + query_params["page_size"] = str(page_size) + if page_token is not None: + query_params["page_token"] = page_token + + url = self.base_url + "/opportunities" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AffinityResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_opportunities" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return AffinityResponse( + success=False, + error=str(e), + message="Failed to execute get_opportunities", + ) + + async def get_opportunity( + self, + opportunity_id: int | str, + ) -> AffinityResponse: + """Get an opportunity by ID + + HTTP GET /opportunities/{opportunity_id} + + Args: + opportunity_id: The opportunity ID + + Returns: + AffinityResponse with opportunity data + """ + url = self.base_url + "/opportunities/{opportunity_id}".format( + opportunity_id=opportunity_id + ) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AffinityResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_opportunity" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return AffinityResponse( + success=False, + error=str(e), + message="Failed to execute get_opportunity", + ) + + # ------------------------------------------------------------------ + # Notes + # ------------------------------------------------------------------ + + async def get_notes( + self, + *, + person_id: int | None = None, + organization_id: int | None = None, + opportunity_id: int | None = None, + page_size: int | None = None, + page_token: str | None = None, + ) -> AffinityResponse: + """Get all notes + + HTTP GET /notes + + Args: + person_id: Filter notes by person ID + organization_id: Filter notes by organization ID + opportunity_id: Filter notes by opportunity ID + page_size: Number of results per page + page_token: Token for pagination + + Returns: + AffinityResponse with notes list + """ + query_params: dict[str, Any] = {} + if person_id is not None: + query_params["person_id"] = str(person_id) + if organization_id is not None: + query_params["organization_id"] = str(organization_id) + if opportunity_id is not None: + query_params["opportunity_id"] = str(opportunity_id) + if page_size is not None: + query_params["page_size"] = str(page_size) + if page_token is not None: + query_params["page_token"] = page_token + + url = self.base_url + "/notes" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AffinityResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_notes" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return AffinityResponse( + success=False, + error=str(e), + message="Failed to execute get_notes", + ) + + async def get_note( + self, + note_id: int | str, + ) -> AffinityResponse: + """Get a note by ID + + HTTP GET /notes/{note_id} + + Args: + note_id: The note ID + + Returns: + AffinityResponse with note data + """ + url = self.base_url + "/notes/{note_id}".format(note_id=note_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AffinityResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_note" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return AffinityResponse( + success=False, + error=str(e), + message="Failed to execute get_note", + ) + + # ------------------------------------------------------------------ + # Entity Files + # ------------------------------------------------------------------ + + async def get_entity_files( + self, + *, + person_id: int | None = None, + organization_id: int | None = None, + opportunity_id: int | None = None, + page_size: int | None = None, + page_token: str | None = None, + ) -> AffinityResponse: + """Get entity files + + HTTP GET /entity-files + + Args: + person_id: Filter by person ID + organization_id: Filter by organization ID + opportunity_id: Filter by opportunity ID + page_size: Number of results per page + page_token: Token for pagination + + Returns: + AffinityResponse with entity files + """ + query_params: dict[str, Any] = {} + if person_id is not None: + query_params["person_id"] = str(person_id) + if organization_id is not None: + query_params["organization_id"] = str(organization_id) + if opportunity_id is not None: + query_params["opportunity_id"] = str(opportunity_id) + if page_size is not None: + query_params["page_size"] = str(page_size) + if page_token is not None: + query_params["page_token"] = page_token + + url = self.base_url + "/entity-files" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AffinityResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_entity_files" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return AffinityResponse( + success=False, + error=str(e), + message="Failed to execute get_entity_files", + ) + + # ------------------------------------------------------------------ + # Fields + # ------------------------------------------------------------------ + + async def get_fields( + self, + *, + list_id: int | None = None, + value_type: int | None = None, + ) -> AffinityResponse: + """Get all fields + + HTTP GET /fields + + Args: + list_id: Filter fields by list ID + value_type: Filter fields by value type + + Returns: + AffinityResponse with fields + """ + query_params: dict[str, Any] = {} + if list_id is not None: + query_params["list_id"] = str(list_id) + if value_type is not None: + query_params["value_type"] = str(value_type) + + url = self.base_url + "/fields" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AffinityResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_fields" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return AffinityResponse( + success=False, + error=str(e), + message="Failed to execute get_fields", + ) + + # ------------------------------------------------------------------ + # Relationship Strengths + # ------------------------------------------------------------------ + + async def get_relationship_strengths( + self, + *, + person_id: int | None = None, + organization_id: int | None = None, + page_size: int | None = None, + page_token: str | None = None, + ) -> AffinityResponse: + """Get relationship strengths + + HTTP GET /relationship-strengths + + Args: + person_id: Filter by person ID + organization_id: Filter by organization ID + page_size: Number of results per page + page_token: Token for pagination + + Returns: + AffinityResponse with relationship strength data + """ + query_params: dict[str, Any] = {} + if person_id is not None: + query_params["person_id"] = str(person_id) + if organization_id is not None: + query_params["organization_id"] = str(organization_id) + if page_size is not None: + query_params["page_size"] = str(page_size) + if page_token is not None: + query_params["page_token"] = page_token + + url = self.base_url + "/relationship-strengths" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AffinityResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_relationship_strengths" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return AffinityResponse( + success=False, + error=str(e), + message="Failed to execute get_relationship_strengths", + ) diff --git a/backend/python/app/sources/external/aha/aha.py b/backend/python/app/sources/external/aha/aha.py new file mode 100644 index 000000000..623c9053d --- /dev/null +++ b/backend/python/app/sources/external/aha/aha.py @@ -0,0 +1,732 @@ +""" +Aha! REST API DataSource - Auto-generated API wrapper + +Generated from Aha! REST API v1 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.aha.aha import AhaClient, AhaResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class AhaDataSource: + """Aha! REST API DataSource + + Provides async wrapper methods for Aha! REST API operations: + - User profile and management + - Product management + - Feature CRUD operations + - Idea management + - Release management + - Goal operations + - Epic management + - Integration listing + + The base URL is https://{subdomain}.aha.io/api/v1. + + All methods return AhaResponse objects. + """ + + def __init__(self, client: AhaClient) -> None: + """Initialize with AhaClient. + + Args: + client: AhaClient instance with configured authentication and subdomain + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'AhaDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> AhaClient: + """Return the underlying AhaClient.""" + return self._client + + async def get_current_user( + self + ) -> AhaResponse: + """Get the current authenticated user details (API v1) + + Returns: + AhaResponse with operation result + """ + url = self.base_url + "/me" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AhaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_current_user" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AhaResponse(success=False, error=str(e), message="Failed to execute get_current_user") + + async def list_users( + self, + page: int | None = None, + per_page: int | None = None + ) -> AhaResponse: + """List all users in the account (API v1) + + Args: + page: Page number for pagination + per_page: Number of results per page + + Returns: + AhaResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/users" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AhaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_users" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AhaResponse(success=False, error=str(e), message="Failed to execute list_users") + + async def get_user( + self, + user_id: str + ) -> AhaResponse: + """Get a specific user by ID (API v1) + + Args: + user_id: The user ID + + Returns: + AhaResponse with operation result + """ + url = self.base_url + "/users/{user_id}".format(user_id=user_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AhaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_user" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AhaResponse(success=False, error=str(e), message="Failed to execute get_user") + + async def list_products( + self, + page: int | None = None, + per_page: int | None = None + ) -> AhaResponse: + """List all products in the account (API v1) + + Args: + page: Page number for pagination + per_page: Number of results per page + + Returns: + AhaResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/products" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AhaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_products" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AhaResponse(success=False, error=str(e), message="Failed to execute list_products") + + async def get_product( + self, + product_id: str + ) -> AhaResponse: + """Get a specific product by ID (API v1) + + Args: + product_id: The product ID + + Returns: + AhaResponse with operation result + """ + url = self.base_url + "/products/{product_id}".format(product_id=product_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AhaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_product" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AhaResponse(success=False, error=str(e), message="Failed to execute get_product") + + async def list_product_features( + self, + product_id: str, + page: int | None = None, + per_page: int | None = None, + q: str | None = None, + assigned_to_user: str | None = None + ) -> AhaResponse: + """List all features for a product (API v1) + + Args: + product_id: The product ID + page: Page number for pagination + per_page: Number of results per page + q: Search query string + assigned_to_user: Filter by assigned user + + Returns: + AhaResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + if q is not None: + query_params['q'] = q + if assigned_to_user is not None: + query_params['assigned_to_user'] = assigned_to_user + + url = self.base_url + "/products/{product_id}/features".format(product_id=product_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AhaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_product_features" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AhaResponse(success=False, error=str(e), message="Failed to execute list_product_features") + + async def get_feature( + self, + feature_id: str + ) -> AhaResponse: + """Get a specific feature by ID (API v1) + + Args: + feature_id: The feature ID + + Returns: + AhaResponse with operation result + """ + url = self.base_url + "/features/{feature_id}".format(feature_id=feature_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AhaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_feature" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AhaResponse(success=False, error=str(e), message="Failed to execute get_feature") + + async def create_feature( + self, + product_id: str, + name: str, + description: str | None = None, + workflow_status: str | None = None, + assigned_to_user: str | None = None, + due_date: str | None = None, + start_date: str | None = None, + release: str | None = None, + tags: str | None = None + ) -> AhaResponse: + """Create a new feature in a product (API v1) + + Args: + product_id: The product ID + name: The name of the feature + description: The feature description + workflow_status: The workflow status + assigned_to_user: User to assign the feature to + due_date: Due date in YYYY-MM-DD format + start_date: Start date in YYYY-MM-DD format + release: Release to associate the feature with + tags: Comma-separated list of tags + + Returns: + AhaResponse with operation result + """ + url = self.base_url + "/products/{product_id}/features".format(product_id=product_id) + + body: dict[str, Any] = {} + body['name'] = name + if description is not None: + body['description'] = description + if workflow_status is not None: + body['workflow_status'] = workflow_status + if assigned_to_user is not None: + body['assigned_to_user'] = assigned_to_user + if due_date is not None: + body['due_date'] = due_date + if start_date is not None: + body['start_date'] = start_date + if release is not None: + body['release'] = release + if tags is not None: + body['tags'] = tags + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AhaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed create_feature" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AhaResponse(success=False, error=str(e), message="Failed to execute create_feature") + + async def update_feature( + self, + feature_id: str, + name: str | None = None, + description: str | None = None, + workflow_status: str | None = None, + assigned_to_user: str | None = None, + due_date: str | None = None, + start_date: str | None = None, + release: str | None = None, + tags: str | None = None + ) -> AhaResponse: + """Update an existing feature (API v1) + + Args: + feature_id: The feature ID + name: The name of the feature + description: The feature description + workflow_status: The workflow status + assigned_to_user: User to assign the feature to + due_date: Due date in YYYY-MM-DD format + start_date: Start date in YYYY-MM-DD format + release: Release to associate the feature with + tags: Comma-separated list of tags + + Returns: + AhaResponse with operation result + """ + url = self.base_url + "/features/{feature_id}".format(feature_id=feature_id) + + body: dict[str, Any] = {} + if name is not None: + body['name'] = name + if description is not None: + body['description'] = description + if workflow_status is not None: + body['workflow_status'] = workflow_status + if assigned_to_user is not None: + body['assigned_to_user'] = assigned_to_user + if due_date is not None: + body['due_date'] = due_date + if start_date is not None: + body['start_date'] = start_date + if release is not None: + body['release'] = release + if tags is not None: + body['tags'] = tags + + try: + request = HTTPRequest( + method="PUT", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AhaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed update_feature" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AhaResponse(success=False, error=str(e), message="Failed to execute update_feature") + + async def list_product_ideas( + self, + product_id: str, + page: int | None = None, + per_page: int | None = None + ) -> AhaResponse: + """List all ideas for a product (API v1) + + Args: + product_id: The product ID + page: Page number for pagination + per_page: Number of results per page + + Returns: + AhaResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/products/{product_id}/ideas".format(product_id=product_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AhaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_product_ideas" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AhaResponse(success=False, error=str(e), message="Failed to execute list_product_ideas") + + async def get_idea( + self, + idea_id: str + ) -> AhaResponse: + """Get a specific idea by ID (API v1) + + Args: + idea_id: The idea ID + + Returns: + AhaResponse with operation result + """ + url = self.base_url + "/ideas/{idea_id}".format(idea_id=idea_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AhaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_idea" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AhaResponse(success=False, error=str(e), message="Failed to execute get_idea") + + async def list_product_releases( + self, + product_id: str, + page: int | None = None, + per_page: int | None = None + ) -> AhaResponse: + """List all releases for a product (API v1) + + Args: + product_id: The product ID + page: Page number for pagination + per_page: Number of results per page + + Returns: + AhaResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/products/{product_id}/releases".format(product_id=product_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AhaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_product_releases" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AhaResponse(success=False, error=str(e), message="Failed to execute list_product_releases") + + async def get_release( + self, + release_id: str + ) -> AhaResponse: + """Get a specific release by ID (API v1) + + Args: + release_id: The release ID + + Returns: + AhaResponse with operation result + """ + url = self.base_url + "/releases/{release_id}".format(release_id=release_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AhaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_release" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AhaResponse(success=False, error=str(e), message="Failed to execute get_release") + + async def list_product_goals( + self, + product_id: str + ) -> AhaResponse: + """List all goals for a product (API v1) + + Args: + product_id: The product ID + + Returns: + AhaResponse with operation result + """ + url = self.base_url + "/products/{product_id}/goals".format(product_id=product_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AhaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_product_goals" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AhaResponse(success=False, error=str(e), message="Failed to execute list_product_goals") + + async def get_goal( + self, + goal_id: str + ) -> AhaResponse: + """Get a specific goal by ID (API v1) + + Args: + goal_id: The goal ID + + Returns: + AhaResponse with operation result + """ + url = self.base_url + "/goals/{goal_id}".format(goal_id=goal_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AhaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_goal" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AhaResponse(success=False, error=str(e), message="Failed to execute get_goal") + + async def list_product_epics( + self, + product_id: str, + page: int | None = None, + per_page: int | None = None + ) -> AhaResponse: + """List all epics for a product (API v1) + + Args: + product_id: The product ID + page: Page number for pagination + per_page: Number of results per page + + Returns: + AhaResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/products/{product_id}/epics".format(product_id=product_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AhaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_product_epics" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AhaResponse(success=False, error=str(e), message="Failed to execute list_product_epics") + + async def get_epic( + self, + epic_id: str + ) -> AhaResponse: + """Get a specific epic by ID (API v1) + + Args: + epic_id: The epic ID + + Returns: + AhaResponse with operation result + """ + url = self.base_url + "/epics/{epic_id}".format(epic_id=epic_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AhaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_epic" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AhaResponse(success=False, error=str(e), message="Failed to execute get_epic") + + async def list_product_integrations( + self, + product_id: str + ) -> AhaResponse: + """List all integrations for a product (API v1) + + Args: + product_id: The product ID + + Returns: + AhaResponse with operation result + """ + url = self.base_url + "/products/{product_id}/integrations".format(product_id=product_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AhaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_product_integrations" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AhaResponse(success=False, error=str(e), message="Failed to execute list_product_integrations") diff --git a/backend/python/app/sources/external/aha/example.py b/backend/python/app/sources/external/aha/example.py new file mode 100644 index 000000000..b906fbc4f --- /dev/null +++ b/backend/python/app/sources/external/aha/example.py @@ -0,0 +1,245 @@ +# ruff: noqa + +""" +Aha! API Usage Examples + +This example demonstrates how to use the Aha! DataSource to interact with +the Aha! API (v1), covering: +- Authentication (OAuth2, API Key) +- Initializing the Client and DataSource +- Fetching User Details +- Listing Products, Features, Ideas +- Working with Releases, Goals, Epics + +Prerequisites: +For OAuth2: +1. Create an Aha! OAuth app at https://www.aha.io/api +2. Set AHA_CLIENT_ID and AHA_CLIENT_SECRET environment variables +3. The OAuth flow will automatically open a browser for authorization + +For API Key: +1. Go to Settings > Account > API keys in your Aha! account +2. Set AHA_API_KEY environment variable + +Subdomain: +Set AHA_SUBDOMAIN environment variable (e.g., 'yourcompany' for yourcompany.aha.io) + +API Reference: https://www.aha.io/api +""" + +import asyncio +import json +import os + +from app.sources.client.aha.aha import ( + AhaClient, + AhaOAuthConfig, + AhaTokenConfig, + AhaResponse, +) +from app.sources.external.aha.aha import AhaDataSource +from app.sources.external.utils.oauth import perform_oauth_flow + +# --- Configuration --- +# OAuth2 credentials (highest priority) +CLIENT_ID = os.getenv("AHA_CLIENT_ID") +CLIENT_SECRET = os.getenv("AHA_CLIENT_SECRET") + +# API Key (second priority) +API_KEY = os.getenv("AHA_API_KEY") + +# Subdomain (required) +SUBDOMAIN = os.getenv("AHA_SUBDOMAIN", "") + +# OAuth redirect URI +REDIRECT_URI = os.getenv("AHA_REDIRECT_URI", "http://localhost:8080/callback") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: AhaResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + # Handle list-type responses + for key in ("products", "features", "ideas", "releases", "goals", + "epics", "users", "integrations"): + if isinstance(data, dict) and key in data: + items = data[key] + print(f" Found {len(items)} {key}.") + if items: + print(f" Sample: {json.dumps(items[0], indent=2)[:400]}...") + return + # Generic response + print(f" Data: {json.dumps(data, indent=2)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + # 1. Initialize Client + print_section("Initializing Aha! Client") + + if not SUBDOMAIN: + print(" AHA_SUBDOMAIN is required.") + print(" Please set AHA_SUBDOMAIN environment variable (e.g., 'yourcompany').") + return + + config = None + + # Priority 1: OAuth2 + if CLIENT_ID and CLIENT_SECRET: + print(" Using OAuth2 authentication") + try: + print("Starting OAuth flow...") + token_response = perform_oauth_flow( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + auth_endpoint=f"https://{SUBDOMAIN}.aha.io/oauth/authorize", + token_endpoint=f"https://{SUBDOMAIN}.aha.io/oauth/token", + redirect_uri=REDIRECT_URI, + scopes=[], + scope_delimiter=" ", + auth_method="body", + ) + + access_token = token_response.get("access_token") + if not access_token: + raise Exception("No access_token found in OAuth response") + + config = AhaOAuthConfig( + subdomain=SUBDOMAIN, + access_token=access_token, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + ) + print(" OAuth authentication successful") + except Exception as e: + print(f" OAuth flow failed: {e}") + print(" Falling back to other authentication methods...") + + # Priority 2: API Key + if config is None and API_KEY: + print(" Using API Key authentication") + config = AhaTokenConfig(subdomain=SUBDOMAIN, api_key=API_KEY) + + if config is None: + print(" No valid authentication method found.") + print(" Please set one of the following:") + print(" - AHA_CLIENT_ID and AHA_CLIENT_SECRET (for OAuth2)") + print(" - AHA_API_KEY (for API Key)") + return + + client = AhaClient.build_with_config(config) + data_source = AhaDataSource(client) + print(f"Client initialized successfully (subdomain: {SUBDOMAIN}).") + + try: + # 2. Get Current User + print_section("Current User") + user_resp = await data_source.get_current_user() + print_result("Get Current User", user_resp) + + # 3. List Products + print_section("Products") + products_resp = await data_source.list_products(per_page=10) + print_result("List Products", products_resp) + + # Extract first product ID for further exploration + product_id = None + if products_resp.success and products_resp.data: + products = products_resp.data.get("products", []) + if not products and isinstance(products_resp.data, list): + products = products_resp.data + if products: + product_id = str(products[0].get("id") or products[0].get("product_id", "")) + print(f" Using Product: {products[0].get('name', 'Unknown')} (ID: {product_id})") + + if not product_id: + print(" No products found. Skipping product-specific operations.") + return + + # 4. Get Product Details + print_section("Product Details") + product_resp = await data_source.get_product(product_id=product_id) + print_result("Get Product", product_resp) + + # 5. List Features + print_section("Features") + features_resp = await data_source.list_product_features(product_id=product_id, per_page=10) + print_result("List Features", features_resp) + + # Get a specific feature if available + if features_resp.success and features_resp.data: + features = features_resp.data.get("features", []) + if not features and isinstance(features_resp.data, list): + features = features_resp.data + if features: + feature_id = str(features[0].get("id") or features[0].get("feature_id", "")) + print_section("Feature Details") + feature_resp = await data_source.get_feature(feature_id=feature_id) + print_result("Get Feature", feature_resp) + + # 6. List Ideas + print_section("Ideas") + ideas_resp = await data_source.list_product_ideas(product_id=product_id, per_page=10) + print_result("List Ideas", ideas_resp) + + # 7. List Releases + print_section("Releases") + releases_resp = await data_source.list_product_releases(product_id=product_id, per_page=10) + print_result("List Releases", releases_resp) + + # Get a specific release if available + if releases_resp.success and releases_resp.data: + releases = releases_resp.data.get("releases", []) + if not releases and isinstance(releases_resp.data, list): + releases = releases_resp.data + if releases: + release_id = str(releases[0].get("id") or releases[0].get("release_id", "")) + print_section("Release Details") + release_resp = await data_source.get_release(release_id=release_id) + print_result("Get Release", release_resp) + + # 8. List Goals + print_section("Goals") + goals_resp = await data_source.list_product_goals(product_id=product_id) + print_result("List Goals", goals_resp) + + # 9. List Epics + print_section("Epics") + epics_resp = await data_source.list_product_epics(product_id=product_id, per_page=10) + print_result("List Epics", epics_resp) + + # 10. List Users + print_section("Users") + users_resp = await data_source.list_users(per_page=10) + print_result("List Users", users_resp) + + # 11. List Integrations + print_section("Integrations") + integrations_resp = await data_source.list_product_integrations(product_id=product_id) + print_result("List Integrations", integrations_resp) + + finally: + # Cleanup: Close the HTTP client session + print("\nClosing client connection...") + inner_client = client.get_client() + if hasattr(inner_client, "close"): + await inner_client.close() + + print("\n" + "=" * 80) + print(" All Aha! API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/amplitude/amplitude.py b/backend/python/app/sources/external/amplitude/amplitude.py new file mode 100644 index 000000000..d463244f0 --- /dev/null +++ b/backend/python/app/sources/external/amplitude/amplitude.py @@ -0,0 +1,700 @@ +""" +Amplitude REST API DataSource - Auto-generated API wrapper + +Generated from Amplitude REST API v2/v3 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.amplitude.amplitude import AmplitudeClient, AmplitudeResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class AmplitudeDataSource: + """Amplitude REST API DataSource + + Provides async wrapper methods for Amplitude REST API operations: + - Event Segmentation queries + - User Search and Activity + - User Deletion management + - Raw Data Export + - Event Upload + - Cohort management + - Chart queries + - Annotations and Releases + - Taxonomy (Event Types, User Properties, Event Properties) + + Uses two base URLs: + - v2: https://amplitude.com/api/2 + - v3: https://analytics.amplitude.com/api/3 + + All methods return AmplitudeResponse objects. + """ + + def __init__(self, client: AmplitudeClient) -> None: + """Initialize with AmplitudeClient. + + Args: + client: AmplitudeClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + try: + self.base_url_v3 = self.http.get_base_url_v3().rstrip('/') + except AttributeError: + self.base_url_v3 = 'https://analytics.amplitude.com/api/3' + + def get_data_source(self) -> 'AmplitudeDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> AmplitudeClient: + """Return the underlying AmplitudeClient.""" + return self._client + + async def get_event_segmentation( + self, + event: str, + start: str, + end: str, + m: str | None = None, + i: str | None = None, + g: str | None = None, + limit: int | None = None + ) -> AmplitudeResponse: + """Get event segmentation data for analytics queries (API v2) + + Args: + event: Event JSON object (required). Defines the event to segment on + start: Start date (required), e.g. '20230101' + end: End date (required), e.g. '20230131' + m: Metric type (e.g. 'uniques', 'totals', 'avg') + i: Interval: '-300000', '3600000', '86400000', or '604800000' + g: Group by property + limit: Limit the number of group by values returned + + Returns: + AmplitudeResponse with operation result + """ + query_params: dict[str, Any] = {} + query_params['e'] = event + query_params['start'] = start + query_params['end'] = end + if m is not None: + query_params['m'] = m + if i is not None: + query_params['i'] = i + if g is not None: + query_params['g'] = g + if limit is not None: + query_params['limit'] = str(limit) + + url = self.base_url + "/events/segmentation" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AmplitudeResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_event_segmentation" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AmplitudeResponse(success=False, error=str(e), message="Failed to execute get_event_segmentation") + + async def search_user( + self, + user: str + ) -> AmplitudeResponse: + """Search for a user by email or Amplitude ID (API v2) + + Args: + user: User email address or Amplitude ID (required) + + Returns: + AmplitudeResponse with operation result + """ + query_params: dict[str, Any] = {} + query_params['user'] = user + + url = self.base_url + "/usersearch" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AmplitudeResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed search_user" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AmplitudeResponse(success=False, error=str(e), message="Failed to execute search_user") + + async def get_user_activity( + self, + user: str, + offset: int | None = None, + limit: int | None = None + ) -> AmplitudeResponse: + """Get a user's event activity (API v2) + + Args: + user: Amplitude user ID (required) + offset: Offset for pagination + limit: Number of events to return (max 1000) + + Returns: + AmplitudeResponse with operation result + """ + query_params: dict[str, Any] = {} + query_params['user'] = user + if offset is not None: + query_params['offset'] = str(offset) + if limit is not None: + query_params['limit'] = str(limit) + + url = self.base_url + "/useractivity" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AmplitudeResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_user_activity" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AmplitudeResponse(success=False, error=str(e), message="Failed to execute get_user_activity") + + async def get_user_deletion_jobs( + self, + start_day: str | None = None, + end_day: str | None = None + ) -> AmplitudeResponse: + """Get user deletion jobs within a date range (API v2) + + Args: + start_day: Start date for deletion jobs (e.g. '2023-01-01') + end_day: End date for deletion jobs (e.g. '2023-01-31') + + Returns: + AmplitudeResponse with operation result + """ + query_params: dict[str, Any] = {} + if start_day is not None: + query_params['start_day'] = start_day + if end_day is not None: + query_params['end_day'] = end_day + + url = self.base_url + "/deletions/users" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AmplitudeResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_user_deletion_jobs" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AmplitudeResponse(success=False, error=str(e), message="Failed to execute get_user_deletion_jobs") + + async def create_user_deletion( + self, + amplitude_ids: list[int] | None = None, + user_ids: list[str] | None = None, + requester: str | None = None + ) -> AmplitudeResponse: + """Create a user deletion job to delete user data (API v2) + + Args: + amplitude_ids: List of Amplitude user IDs to delete + user_ids: List of user IDs to delete + requester: Email of the requester + + Returns: + AmplitudeResponse with operation result + """ + url = self.base_url + "/deletions/users" + + body: dict[str, Any] = {} + if amplitude_ids is not None: + body['amplitude_ids'] = amplitude_ids + if user_ids is not None: + body['user_ids'] = user_ids + if requester is not None: + body['requester'] = requester + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AmplitudeResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed create_user_deletion" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AmplitudeResponse(success=False, error=str(e), message="Failed to execute create_user_deletion") + + async def export_raw_data( + self, + start: str, + end: str + ) -> AmplitudeResponse: + """Export raw event data for a date range (returns zipped JSON) (API v2) + + Args: + start: Start date hour (required), e.g. '20230101T00' + end: End date hour (required), e.g. '20230102T00' + + Returns: + AmplitudeResponse with operation result + """ + query_params: dict[str, Any] = {} + query_params['start'] = start + query_params['end'] = end + + url = self.base_url + "/export" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AmplitudeResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed export_raw_data" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AmplitudeResponse(success=False, error=str(e), message="Failed to execute export_raw_data") + + async def upload_events( + self, + api_key: str, + events: list[dict[str, Any]] + ) -> AmplitudeResponse: + """Upload events to Amplitude (batch upload) (API v2) + + Args: + api_key: Amplitude API key + events: List of event objects to upload + + Returns: + AmplitudeResponse with operation result + """ + url = self.base_url + "/events/upload" + + body: dict[str, Any] = {} + body['api_key'] = api_key + body['events'] = events + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AmplitudeResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed upload_events" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AmplitudeResponse(success=False, error=str(e), message="Failed to execute upload_events") + + async def list_cohorts( + self + ) -> AmplitudeResponse: + """List all cohorts in the project (API v3) + + Returns: + AmplitudeResponse with operation result + """ + url = self.base_url_v3 + "/cohorts" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AmplitudeResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_cohorts" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AmplitudeResponse(success=False, error=str(e), message="Failed to execute list_cohorts") + + async def get_cohort( + self, + cohort_id: str + ) -> AmplitudeResponse: + """Get details of a specific cohort (API v3) + + Args: + cohort_id: The cohort ID + + Returns: + AmplitudeResponse with operation result + """ + url = self.base_url_v3 + "/cohorts/{cohort_id}".format(cohort_id=cohort_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AmplitudeResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_cohort" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AmplitudeResponse(success=False, error=str(e), message="Failed to execute get_cohort") + + async def query_chart( + self, + chart_id: str + ) -> AmplitudeResponse: + """Query a saved chart by ID (API v3) + + Args: + chart_id: The chart ID + + Returns: + AmplitudeResponse with operation result + """ + url = self.base_url_v3 + "/charts/{chart_id}/query".format(chart_id=chart_id) + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AmplitudeResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed query_chart" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AmplitudeResponse(success=False, error=str(e), message="Failed to execute query_chart") + + async def list_annotations( + self + ) -> AmplitudeResponse: + """List all annotations (API v2) + + Returns: + AmplitudeResponse with operation result + """ + url = self.base_url + "/annotations" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AmplitudeResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_annotations" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AmplitudeResponse(success=False, error=str(e), message="Failed to execute list_annotations") + + async def create_annotation( + self, + date: str, + label: str, + details: str | None = None + ) -> AmplitudeResponse: + """Create a new annotation (API v2) + + Args: + date: Date of the annotation (e.g. '2023-01-15') + label: Label/title of the annotation + details: Additional details for the annotation + + Returns: + AmplitudeResponse with operation result + """ + url = self.base_url + "/annotations" + + body: dict[str, Any] = {} + body['date'] = date + body['label'] = label + if details is not None: + body['details'] = details + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AmplitudeResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed create_annotation" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AmplitudeResponse(success=False, error=str(e), message="Failed to execute create_annotation") + + async def list_releases( + self + ) -> AmplitudeResponse: + """List all releases (API v2) + + Returns: + AmplitudeResponse with operation result + """ + url = self.base_url + "/releases" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AmplitudeResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_releases" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AmplitudeResponse(success=False, error=str(e), message="Failed to execute list_releases") + + async def create_release( + self, + version: str, + release_start: str, + release_end: str | None = None, + title: str | None = None, + description: str | None = None, + platforms: list[str] | None = None, + created_by: str | None = None, + chart_id: str | None = None + ) -> AmplitudeResponse: + """Create a new release (API v2) + + Args: + version: Release version string + release_start: Release start date (e.g. '2023-01-15') + release_end: Release end date (e.g. '2023-01-16') + title: Title of the release + description: Description of the release + platforms: List of platforms for this release + created_by: Email of the release creator + chart_id: Chart ID to associate with the release + + Returns: + AmplitudeResponse with operation result + """ + url = self.base_url + "/releases" + + body: dict[str, Any] = {} + body['version'] = version + body['release_start'] = release_start + if release_end is not None: + body['release_end'] = release_end + if title is not None: + body['title'] = title + if description is not None: + body['description'] = description + if platforms is not None: + body['platforms'] = platforms + if created_by is not None: + body['created_by'] = created_by + if chart_id is not None: + body['chart_id'] = chart_id + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AmplitudeResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed create_release" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AmplitudeResponse(success=False, error=str(e), message="Failed to execute create_release") + + async def list_event_types( + self + ) -> AmplitudeResponse: + """List all event types in the project's taxonomy (API v2) + + Returns: + AmplitudeResponse with operation result + """ + url = self.base_url + "/taxonomy/event-type" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AmplitudeResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_event_types" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AmplitudeResponse(success=False, error=str(e), message="Failed to execute list_event_types") + + async def get_event_type( + self, + event_type: str + ) -> AmplitudeResponse: + """Get a specific event type from the taxonomy (API v2) + + Args: + event_type: The event type name + + Returns: + AmplitudeResponse with operation result + """ + url = self.base_url + "/taxonomy/event-type/{event_type}".format(event_type=event_type) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AmplitudeResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_event_type" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AmplitudeResponse(success=False, error=str(e), message="Failed to execute get_event_type") + + async def list_user_properties( + self + ) -> AmplitudeResponse: + """List all user properties in the project's taxonomy (API v2) + + Returns: + AmplitudeResponse with operation result + """ + url = self.base_url + "/taxonomy/user-property" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AmplitudeResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_user_properties" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AmplitudeResponse(success=False, error=str(e), message="Failed to execute list_user_properties") + + async def list_event_properties( + self + ) -> AmplitudeResponse: + """List all event properties in the project's taxonomy (API v2) + + Returns: + AmplitudeResponse with operation result + """ + url = self.base_url + "/taxonomy/event-property" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AmplitudeResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_event_properties" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AmplitudeResponse(success=False, error=str(e), message="Failed to execute list_event_properties") diff --git a/backend/python/app/sources/external/amplitude/example.py b/backend/python/app/sources/external/amplitude/example.py new file mode 100644 index 000000000..9bb7fb69e --- /dev/null +++ b/backend/python/app/sources/external/amplitude/example.py @@ -0,0 +1,148 @@ +# ruff: noqa + +""" +Amplitude API Usage Examples + +This example demonstrates how to use the Amplitude DataSource to interact with +the Amplitude Analytics API, covering: +- Authentication (API Key + Secret Key via Basic Auth) +- Initializing the Client and DataSource +- Listing Event Types (Taxonomy) +- Listing Cohorts +- Listing User Properties +- Listing Annotations + +Prerequisites: +1. Create an Amplitude project at https://analytics.amplitude.com +2. Go to Settings > Projects > [Your Project] > General +3. Copy the API Key and Secret Key +4. Set environment variables: + - AMPLITUDE_API_KEY: Your project's API key + - AMPLITUDE_SECRET_KEY: Your project's secret key + +API Reference: https://www.docs.developers.amplitude.com/analytics/apis/ +""" + +import asyncio +import json +import os + +from app.sources.client.amplitude.amplitude import ( + AmplitudeApiKeyConfig, + AmplitudeClient, + AmplitudeResponse, +) +from app.sources.external.amplitude.amplitude import AmplitudeDataSource + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: AmplitudeResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + if isinstance(data, dict): + # Try to show a summary + for key in ("data", "events", "cohorts", "annotations", + "releases", "matches", "userData"): + if key in data: + items = data[key] + if isinstance(items, list): + print(f" Found {len(items)} {key}.") + if items: + print(f" Sample: {json.dumps(items[0], indent=2, default=str)[:400]}...") + elif isinstance(items, dict): + print(f" {key}: {json.dumps(items, indent=2, default=str)[:400]}...") + else: + print(f" {key}: {items}") + return + # Generic dict response + print(f" Data: {json.dumps(data, indent=2, default=str)[:500]}...") + elif isinstance(data, list): + print(f" Found {len(data)} items.") + if data: + print(f" Sample: {json.dumps(data[0], indent=2, default=str)[:400]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + # 1. Initialize Client + print_section("Initializing Amplitude Client") + + api_key = os.getenv("AMPLITUDE_API_KEY") + secret_key = os.getenv("AMPLITUDE_SECRET_KEY") + + if not api_key: + print(" AMPLITUDE_API_KEY environment variable is not set.") + print(" Please set it to your Amplitude project's API key.") + return + + if not secret_key: + print(" AMPLITUDE_SECRET_KEY environment variable is not set.") + print(" Please set it to your Amplitude project's secret key.") + return + + config = AmplitudeApiKeyConfig( + api_key=api_key, + secret_key=secret_key, + ) + client = AmplitudeClient.build_with_config(config) + data_source = AmplitudeDataSource(client) + print(f" Client initialized successfully.") + print(f" Base URL (v2): {client.get_base_url()}") + print(f" Base URL (v3): {client.get_base_url_v3()}") + + try: + # 2. List Event Types (Taxonomy) + print_section("Event Types (Taxonomy)") + event_types_resp = await data_source.list_event_types() + print_result("List Event Types", event_types_resp) + + # 3. List Cohorts + print_section("Cohorts") + cohorts_resp = await data_source.list_cohorts() + print_result("List Cohorts", cohorts_resp) + + # 4. List User Properties (Taxonomy) + print_section("User Properties (Taxonomy)") + user_props_resp = await data_source.list_user_properties() + print_result("List User Properties", user_props_resp) + + # 5. List Event Properties (Taxonomy) + print_section("Event Properties (Taxonomy)") + event_props_resp = await data_source.list_event_properties() + print_result("List Event Properties", event_props_resp) + + # 6. List Annotations + print_section("Annotations") + annotations_resp = await data_source.list_annotations() + print_result("List Annotations", annotations_resp) + + # 7. List Releases + print_section("Releases") + releases_resp = await data_source.list_releases() + print_result("List Releases", releases_resp) + + finally: + # Cleanup: Close the HTTP client session + print("\nClosing client connection...") + inner_client = client.get_client() + if hasattr(inner_client, "close"): + await inner_client.close() + + print("\n" + "=" * 80) + print(" All Amplitude API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/ariba/ariba.py b/backend/python/app/sources/external/ariba/ariba.py new file mode 100644 index 000000000..df7ae65b9 --- /dev/null +++ b/backend/python/app/sources/external/ariba/ariba.py @@ -0,0 +1,574 @@ +# ruff: noqa +""" +SAP Ariba REST API DataSource - Auto-generated API wrapper + +Generated from SAP Ariba REST API documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. + +Note: Each method calls ensure_token() to auto-fetch a client_credentials + OAuth token before making the API request. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.ariba.ariba import AribaClient, AribaResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class AribaDataSource: + """SAP Ariba REST API DataSource + + Provides async wrapper methods for SAP Ariba REST API operations: + - Sourcing Projects management + - Purchase Orders management + - Invoices management + - Requisitions management + - Suppliers management + - Contracts management + + All methods return AribaResponse objects. + Token is automatically fetched via client_credentials OAuth. + """ + + def __init__(self, client: AribaClient) -> None: + """Initialize with AribaClient. + + Args: + client: AribaClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'AribaDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> AribaClient: + """Return the underlying AribaClient.""" + return self._client + + # ----------------------------------------------------------------------- + # Sourcing Projects + # ----------------------------------------------------------------------- + + async def list_sourcing_projects( + self, + *, + limit: int | None = None, + offset: int | None = None + ) -> AribaResponse: + """List all sourcing projects + + HTTP GET /sourcing-projects/v4/prod/sourcing-projects + + Args: + limit: Maximum number of results + offset: Offset for pagination + + Returns: + AribaResponse with operation result + """ + await self.http.ensure_token() + + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/sourcing-projects/v4/prod/sourcing-projects" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AribaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_sourcing_projects" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AribaResponse(success=False, error=str(e), message="Failed to execute list_sourcing_projects") + + + async def get_sourcing_project( + self, + project_id: str + ) -> AribaResponse: + """Get a specific sourcing project by ID + + HTTP GET /sourcing-projects/v4/prod/sourcing-projects/{project_id} + + Args: + project_id: The project id + + Returns: + AribaResponse with operation result + """ + await self.http.ensure_token() + + url = self.base_url + "/sourcing-projects/v4/prod/sourcing-projects/{project_id}".format(project_id=project_id) + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AribaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_sourcing_project" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AribaResponse(success=False, error=str(e), message="Failed to execute get_sourcing_project") + + + # ----------------------------------------------------------------------- + # Purchase Orders + # ----------------------------------------------------------------------- + + async def list_purchase_orders( + self, + *, + limit: int | None = None, + offset: int | None = None + ) -> AribaResponse: + """List all purchase orders + + HTTP GET /procurement/v3/prod/purchase-orders + + Args: + limit: Maximum number of results + offset: Offset for pagination + + Returns: + AribaResponse with operation result + """ + await self.http.ensure_token() + + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/procurement/v3/prod/purchase-orders" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AribaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_purchase_orders" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AribaResponse(success=False, error=str(e), message="Failed to execute list_purchase_orders") + + + async def get_purchase_order( + self, + order_id: str + ) -> AribaResponse: + """Get a specific purchase order by ID + + HTTP GET /procurement/v3/prod/purchase-orders/{order_id} + + Args: + order_id: The order id + + Returns: + AribaResponse with operation result + """ + await self.http.ensure_token() + + url = self.base_url + "/procurement/v3/prod/purchase-orders/{order_id}".format(order_id=order_id) + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AribaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_purchase_order" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AribaResponse(success=False, error=str(e), message="Failed to execute get_purchase_order") + + + # ----------------------------------------------------------------------- + # Invoices + # ----------------------------------------------------------------------- + + async def list_invoices( + self, + *, + limit: int | None = None, + offset: int | None = None + ) -> AribaResponse: + """List all invoices + + HTTP GET /procurement/v3/prod/invoices + + Args: + limit: Maximum number of results + offset: Offset for pagination + + Returns: + AribaResponse with operation result + """ + await self.http.ensure_token() + + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/procurement/v3/prod/invoices" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AribaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_invoices" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AribaResponse(success=False, error=str(e), message="Failed to execute list_invoices") + + + async def get_invoice( + self, + invoice_id: str + ) -> AribaResponse: + """Get a specific invoice by ID + + HTTP GET /procurement/v3/prod/invoices/{invoice_id} + + Args: + invoice_id: The invoice id + + Returns: + AribaResponse with operation result + """ + await self.http.ensure_token() + + url = self.base_url + "/procurement/v3/prod/invoices/{invoice_id}".format(invoice_id=invoice_id) + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AribaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_invoice" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AribaResponse(success=False, error=str(e), message="Failed to execute get_invoice") + + + # ----------------------------------------------------------------------- + # Requisitions + # ----------------------------------------------------------------------- + + async def list_requisitions( + self, + *, + limit: int | None = None, + offset: int | None = None + ) -> AribaResponse: + """List all requisitions + + HTTP GET /procurement/v3/prod/requisitions + + Args: + limit: Maximum number of results + offset: Offset for pagination + + Returns: + AribaResponse with operation result + """ + await self.http.ensure_token() + + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/procurement/v3/prod/requisitions" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AribaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_requisitions" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AribaResponse(success=False, error=str(e), message="Failed to execute list_requisitions") + + + async def get_requisition( + self, + requisition_id: str + ) -> AribaResponse: + """Get a specific requisition by ID + + HTTP GET /procurement/v3/prod/requisitions/{requisition_id} + + Args: + requisition_id: The requisition id + + Returns: + AribaResponse with operation result + """ + await self.http.ensure_token() + + url = self.base_url + "/procurement/v3/prod/requisitions/{requisition_id}".format(requisition_id=requisition_id) + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AribaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_requisition" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AribaResponse(success=False, error=str(e), message="Failed to execute get_requisition") + + + # ----------------------------------------------------------------------- + # Suppliers + # ----------------------------------------------------------------------- + + async def list_suppliers( + self, + *, + limit: int | None = None, + offset: int | None = None + ) -> AribaResponse: + """List all suppliers + + HTTP GET /supplier-management/v4/prod/suppliers + + Args: + limit: Maximum number of results + offset: Offset for pagination + + Returns: + AribaResponse with operation result + """ + await self.http.ensure_token() + + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/supplier-management/v4/prod/suppliers" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AribaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_suppliers" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AribaResponse(success=False, error=str(e), message="Failed to execute list_suppliers") + + + async def get_supplier( + self, + supplier_id: str + ) -> AribaResponse: + """Get a specific supplier by ID + + HTTP GET /supplier-management/v4/prod/suppliers/{supplier_id} + + Args: + supplier_id: The supplier id + + Returns: + AribaResponse with operation result + """ + await self.http.ensure_token() + + url = self.base_url + "/supplier-management/v4/prod/suppliers/{supplier_id}".format(supplier_id=supplier_id) + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AribaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_supplier" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AribaResponse(success=False, error=str(e), message="Failed to execute get_supplier") + + + # ----------------------------------------------------------------------- + # Contracts + # ----------------------------------------------------------------------- + + async def list_contracts( + self, + *, + limit: int | None = None, + offset: int | None = None + ) -> AribaResponse: + """List all contracts + + HTTP GET /contract-management/v2/prod/contracts + + Args: + limit: Maximum number of results + offset: Offset for pagination + + Returns: + AribaResponse with operation result + """ + await self.http.ensure_token() + + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/contract-management/v2/prod/contracts" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AribaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_contracts" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AribaResponse(success=False, error=str(e), message="Failed to execute list_contracts") + + + async def get_contract( + self, + contract_id: str + ) -> AribaResponse: + """Get a specific contract by ID + + HTTP GET /contract-management/v2/prod/contracts/{contract_id} + + Args: + contract_id: The contract id + + Returns: + AribaResponse with operation result + """ + await self.http.ensure_token() + + url = self.base_url + "/contract-management/v2/prod/contracts/{contract_id}".format(contract_id=contract_id) + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AribaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_contract" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return AribaResponse(success=False, error=str(e), message="Failed to execute get_contract") + diff --git a/backend/python/app/sources/external/ariba/code_generator.py b/backend/python/app/sources/external/ariba/code_generator.py new file mode 100644 index 000000000..596247abf --- /dev/null +++ b/backend/python/app/sources/external/ariba/code_generator.py @@ -0,0 +1,235 @@ +# ruff: noqa +""" +SAP Ariba DataSource Code Generator + +Defines SAP Ariba API endpoint specifications and generates the DataSource +wrapper class (ariba.py) from them. + +Endpoints: + /sourcing-projects/v4/prod/sourcing-projects, + /sourcing-projects/v4/prod/sourcing-projects/{id}, + /procurement/v3/prod/purchase-orders, + /procurement/v3/prod/purchase-orders/{id}, + /procurement/v3/prod/invoices, /procurement/v3/prod/invoices/{id}, + /procurement/v3/prod/requisitions, /procurement/v3/prod/requisitions/{id}, + /supplier-management/v4/prod/suppliers, + /supplier-management/v4/prod/suppliers/{id}, + /contract-management/v2/prod/contracts, + /contract-management/v2/prod/contracts/{id} + +Note: The Ariba client uses client_credentials OAuth with auto token fetch. + The DataSource calls ensure_token() before each request. +""" + +from __future__ import annotations + +ENDPOINTS = [ + # Sourcing Projects + {"method": "GET", "path": "/sourcing-projects/v4/prod/sourcing-projects", "name": "list_sourcing_projects", + "section": "Sourcing Projects", "doc": "List all sourcing projects", + "query_params": [("limit", "int", "Maximum number of results"), ("offset", "int", "Offset for pagination")]}, + {"method": "GET", "path": "/sourcing-projects/v4/prod/sourcing-projects/{project_id}", "name": "get_sourcing_project", + "section": "Sourcing Projects", "doc": "Get a specific sourcing project by ID", "path_params": ["project_id"]}, + # Purchase Orders + {"method": "GET", "path": "/procurement/v3/prod/purchase-orders", "name": "list_purchase_orders", + "section": "Purchase Orders", "doc": "List all purchase orders", + "query_params": [("limit", "int", "Maximum number of results"), ("offset", "int", "Offset for pagination")]}, + {"method": "GET", "path": "/procurement/v3/prod/purchase-orders/{order_id}", "name": "get_purchase_order", + "section": "Purchase Orders", "doc": "Get a specific purchase order by ID", "path_params": ["order_id"]}, + # Invoices + {"method": "GET", "path": "/procurement/v3/prod/invoices", "name": "list_invoices", + "section": "Invoices", "doc": "List all invoices", + "query_params": [("limit", "int", "Maximum number of results"), ("offset", "int", "Offset for pagination")]}, + {"method": "GET", "path": "/procurement/v3/prod/invoices/{invoice_id}", "name": "get_invoice", + "section": "Invoices", "doc": "Get a specific invoice by ID", "path_params": ["invoice_id"]}, + # Requisitions + {"method": "GET", "path": "/procurement/v3/prod/requisitions", "name": "list_requisitions", + "section": "Requisitions", "doc": "List all requisitions", + "query_params": [("limit", "int", "Maximum number of results"), ("offset", "int", "Offset for pagination")]}, + {"method": "GET", "path": "/procurement/v3/prod/requisitions/{requisition_id}", "name": "get_requisition", + "section": "Requisitions", "doc": "Get a specific requisition by ID", "path_params": ["requisition_id"]}, + # Suppliers + {"method": "GET", "path": "/supplier-management/v4/prod/suppliers", "name": "list_suppliers", + "section": "Suppliers", "doc": "List all suppliers", + "query_params": [("limit", "int", "Maximum number of results"), ("offset", "int", "Offset for pagination")]}, + {"method": "GET", "path": "/supplier-management/v4/prod/suppliers/{supplier_id}", "name": "get_supplier", + "section": "Suppliers", "doc": "Get a specific supplier by ID", "path_params": ["supplier_id"]}, + # Contracts + {"method": "GET", "path": "/contract-management/v2/prod/contracts", "name": "list_contracts", + "section": "Contracts", "doc": "List all contracts", + "query_params": [("limit", "int", "Maximum number of results"), ("offset", "int", "Offset for pagination")]}, + {"method": "GET", "path": "/contract-management/v2/prod/contracts/{contract_id}", "name": "get_contract", + "section": "Contracts", "doc": "Get a specific contract by ID", "path_params": ["contract_id"]}, +] + + +def _gen_method(ep: dict) -> str: + """Generate a single async method from an endpoint spec.""" + name = ep["name"] + method = ep["method"] + path = ep["path"] + doc = ep["doc"] + path_params = ep.get("path_params", []) + query_params = ep.get("query_params", []) + body_params = ep.get("body_params", []) + + sig_parts = ["self"] + for p in path_params: + sig_parts.append(f"{p}: str") + for bp in body_params: + sig_parts.append(f"{bp[0]}: {bp[2]}") + if query_params: + sig_parts.append("*") + for qp in query_params: + sig_parts.append(f"{qp[0]}: {qp[1]} | None = None") + + sig = ",\n ".join(sig_parts) + + doc_args = "" + if path_params or query_params or body_params: + doc_args = "\n Args:\n" + for p in path_params: + doc_args += f" {p}: The {p.replace('_', ' ')}\n" + for bp in body_params: + doc_args += f" {bp[0]}: {bp[3]}\n" + for qp in query_params: + doc_args += f" {qp[0]}: {qp[2]}\n" + + query_block = "" + if query_params: + lines = ["\n query_params: dict[str, Any] = {}"] + for qp in query_params: + lines.append(f" if {qp[0]} is not None:") + lines.append(f" query_params['{qp[0]}'] = str({qp[0]})") + query_block = "\n".join(lines) + "\n" + + if path_params: + fmt_args = ", ".join(f"{p}={p}" for p in path_params) + url_line = f' url = self.base_url + "{path}".format({fmt_args})' + else: + url_line = f' url = self.base_url + "{path}"' + + body_block = "" + if body_params: + lines = ["\n body: dict[str, Any] = {}"] + for bp in body_params: + lines.append(f' if {bp[0]} is not None:') + lines.append(f' body["{bp[1]}"] = {bp[0]}') + body_block = "\n".join(lines) + + req_extra = "" + if query_params: + req_extra += "\n query=query_params," + if body_params: + req_extra += "\n body=body," + + return f''' + async def {name}( + {sig} + ) -> AribaResponse: + """{doc} + + HTTP {method} {path} +{doc_args} + Returns: + AribaResponse with operation result + """ + await self.http.ensure_token() +{query_block} +{url_line} +{body_block} + + try: + request = HTTPRequest( + method="{method}", + url=url, + headers={{"Content-Type": "application/json"}},{req_extra} + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return AribaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed {name}" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {{response.status}}" + ) + except Exception as e: + return AribaResponse(success=False, error=str(e), message="Failed to execute {name}") +''' + + +def generate_datasource() -> str: + """Generate the full SAP Ariba DataSource module code.""" + header = '''# ruff: noqa +""" +SAP Ariba REST API DataSource - Auto-generated API wrapper + +Generated from SAP Ariba REST API documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. + +Note: Each method calls ensure_token() to auto-fetch a client_credentials + OAuth token before making the API request. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.ariba.ariba import AribaClient, AribaResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class AribaDataSource: + """SAP Ariba REST API DataSource + + Provides async wrapper methods for SAP Ariba REST API operations: + - Sourcing Projects management + - Purchase Orders management + - Invoices management + - Requisitions management + - Suppliers management + - Contracts management + + All methods return AribaResponse objects. + Token is automatically fetched via client_credentials OAuth. + """ + + def __init__(self, client: AribaClient) -> None: + """Initialize with AribaClient. + + Args: + client: AribaClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'AribaDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> AribaClient: + """Return the underlying AribaClient.""" + return self._client +''' + + methods = [] + current_section = None + for ep in ENDPOINTS: + section = ep.get("section", "") + if section and section != current_section: + current_section = section + methods.append(f"\n # {'-' * 71}\n # {section}\n # {'-' * 71}") + methods.append(_gen_method(ep)) + + return header + "\n".join(methods) + "\n" + + +if __name__ == "__main__": + print(generate_datasource()) diff --git a/backend/python/app/sources/external/ariba/example.py b/backend/python/app/sources/external/ariba/example.py new file mode 100644 index 000000000..b65d3b464 --- /dev/null +++ b/backend/python/app/sources/external/ariba/example.py @@ -0,0 +1,136 @@ +# ruff: noqa + +""" +SAP Ariba API Usage Examples + +This example demonstrates how to use the Ariba DataSource to interact with +the SAP Ariba API, covering: +- Authentication (client_credentials OAuth2) +- Initializing the Client and DataSource +- Listing Sourcing Projects, Purchase Orders, Invoices +- Getting Requisitions, Suppliers, Contracts + +Prerequisites: +1. Get your SAP Ariba API client_id and client_secret +2. Set ARIBA_CLIENT_ID and ARIBA_CLIENT_SECRET environment variables +3. Optionally set ARIBA_TOKEN_ENDPOINT and ARIBA_BASE_URL +""" + +import asyncio +import json +import os + +from app.sources.client.ariba.ariba import ( + AribaClient, + AribaClientCredentialsConfig, + AribaResponse, +) +from app.sources.external.ariba.ariba import AribaDataSource + +# --- Configuration --- +CLIENT_ID = os.getenv("ARIBA_CLIENT_ID") +CLIENT_SECRET = os.getenv("ARIBA_CLIENT_SECRET") +TOKEN_ENDPOINT = os.getenv("ARIBA_TOKEN_ENDPOINT", "https://api.ariba.com/v2/oauth/token") +BASE_URL = os.getenv("ARIBA_BASE_URL", "https://openapi.ariba.com/api") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: AribaResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + for key in ("sourcing_projects", "purchase_orders", "invoices", + "requisitions", "suppliers", "contracts", "results", + "items", "records"): + if isinstance(data, dict) and key in data: + items = data[key] + print(f" Found {len(items)} {key}.") + if items: + print(f" Sample: {json.dumps(items[0], indent=2)[:400]}...") + return + if isinstance(data, list): + print(f" Found {len(data)} items.") + if data: + print(f" Sample: {json.dumps(data[0], indent=2)[:400]}...") + return + print(f" Data: {json.dumps(data, indent=2)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + # 1. Initialize Client + print_section("Initializing SAP Ariba Client") + + if not CLIENT_ID or not CLIENT_SECRET: + print(" No valid authentication method found.") + print(" Please set the following:") + print(" - ARIBA_CLIENT_ID") + print(" - ARIBA_CLIENT_SECRET") + return + + print(" Using client_credentials OAuth2 authentication") + config = AribaClientCredentialsConfig( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + token_endpoint=TOKEN_ENDPOINT, + base_url=BASE_URL, + ) + + client = AribaClient.build_with_config(config) + data_source = AribaDataSource(client) + print("Client initialized successfully.") + + try: + # 2. List Sourcing Projects + print_section("Sourcing Projects") + projects_resp = await data_source.list_sourcing_projects(limit=10) + print_result("List Sourcing Projects", projects_resp) + + # 3. List Purchase Orders + print_section("Purchase Orders") + orders_resp = await data_source.list_purchase_orders(limit=10) + print_result("List Purchase Orders", orders_resp) + + # 4. List Invoices + print_section("Invoices") + invoices_resp = await data_source.list_invoices(limit=10) + print_result("List Invoices", invoices_resp) + + # 5. List Requisitions + print_section("Requisitions") + reqs_resp = await data_source.list_requisitions(limit=10) + print_result("List Requisitions", reqs_resp) + + # 6. List Suppliers + print_section("Suppliers") + suppliers_resp = await data_source.list_suppliers(limit=10) + print_result("List Suppliers", suppliers_resp) + + # 7. List Contracts + print_section("Contracts") + contracts_resp = await data_source.list_contracts(limit=10) + print_result("List Contracts", contracts_resp) + + finally: + print("\nClosing client connection...") + inner_client = client.get_client() + if hasattr(inner_client, "close"): + await inner_client.close() + + print("\n" + "=" * 80) + print(" All SAP Ariba API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/ariba/run_generator.py b/backend/python/app/sources/external/ariba/run_generator.py new file mode 100644 index 000000000..6794e09dd --- /dev/null +++ b/backend/python/app/sources/external/ariba/run_generator.py @@ -0,0 +1,24 @@ +"""Runner script to generate the SAP Ariba DataSource wrapper. + +Execute this script to regenerate ariba.py from the endpoint definitions +in code_generator.py. + +Usage: + python -m app.sources.external.ariba.run_generator +""" + +from app.sources.external.ariba.code_generator import generate_datasource + + +def main() -> None: + """Generate the SAP Ariba DataSource file.""" + code = generate_datasource() + output_path = __file__.replace("run_generator.py", "ariba.py") + with open(output_path, "w") as f: + f.write(code) + print(f"Generated SAP Ariba DataSource -> {output_path}") + print(f" Total characters: {len(code)}") + + +if __name__ == "__main__": + main() diff --git a/backend/python/app/sources/external/bamboohr/bamboohr.py b/backend/python/app/sources/external/bamboohr/bamboohr.py new file mode 100644 index 000000000..f4fe3e643 --- /dev/null +++ b/backend/python/app/sources/external/bamboohr/bamboohr.py @@ -0,0 +1,643 @@ +""" +BambooHR REST API DataSource - Auto-generated API wrapper + +Generated from BambooHR REST API v1 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.bamboohr.bamboohr import BambooHRClient, BambooHRResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class BambooHRDataSource: + """BambooHR REST API DataSource + + Provides async wrapper methods for BambooHR REST API operations: + - Employee directory and management + - Employee files + - Metadata (fields, tables, lists, users) + - Custom reports and company reports + - Time off requests and policies + - Changed employees tracking + - Applicant tracking (applications, job summaries) + + The base URL is determined by the BambooHRClient's configured company domain. + + All methods return BambooHRResponse objects. + """ + + def __init__(self, client: BambooHRClient) -> None: + """Initialize with BambooHRClient. + + Args: + client: BambooHRClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'BambooHRDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> BambooHRClient: + """Return the underlying BambooHRClient.""" + return self._client + + async def get_employee_directory( + self + ) -> BambooHRResponse: + """Get employee directory listing all active employees (API v1) + + Returns: + BambooHRResponse with operation result + """ + url = self.base_url + "/employees/directory" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Accept": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return BambooHRResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_employee_directory" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return BambooHRResponse(success=False, error=str(e), message="Failed to execute get_employee_directory") + + async def get_employee( + self, + employee_id: str, + fields: str | None = None + ) -> BambooHRResponse: + """Get a single employee by ID (API v1) + + Args: + employee_id: The employee ID + fields: Comma-separated list of fields to return + + Returns: + BambooHRResponse with operation result + """ + query_params: dict[str, Any] = {} + if fields is not None: + query_params['fields'] = fields + + url = self.base_url + "/employees/{employee_id}".format(employee_id=employee_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Accept": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return BambooHRResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_employee" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return BambooHRResponse(success=False, error=str(e), message="Failed to execute get_employee") + + async def add_employee( + self, + employee_data: dict[str, Any] + ) -> BambooHRResponse: + """Add a new employee (API v1) + + Args: + employee_data: Employee data fields (firstName, lastName, etc.) + + Returns: + BambooHRResponse with operation result + """ + url = self.base_url + "/employees/" + + body: dict[str, Any] = {} + body.update(employee_data) + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Accept": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return BambooHRResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed add_employee" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return BambooHRResponse(success=False, error=str(e), message="Failed to execute add_employee") + + async def update_employee( + self, + employee_id: str, + employee_data: dict[str, Any] + ) -> BambooHRResponse: + """Update an existing employee (API v1) + + Args: + employee_id: The employee ID + employee_data: Employee data fields to update + + Returns: + BambooHRResponse with operation result + """ + url = self.base_url + "/employees/{employee_id}".format(employee_id=employee_id) + + body: dict[str, Any] = {} + body.update(employee_data) + + try: + request = HTTPRequest( + method="PUT", + url=url, + headers={"Accept": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return BambooHRResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed update_employee" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return BambooHRResponse(success=False, error=str(e), message="Failed to execute update_employee") + + async def get_changed_employees( + self, + since: str, + change_type: str | None = None + ) -> BambooHRResponse: + """Get employees that have changed since a given date (API v1) + + Args: + since: ISO 8601 date string (e.g., 2024-01-01T00:00:00Z) + change_type: Type of changes to return (e.g., 'inserted', 'updated', 'deleted') + + Returns: + BambooHRResponse with operation result + """ + query_params: dict[str, Any] = {} + query_params['since'] = since + if change_type is not None: + query_params['type'] = change_type + + url = self.base_url + "/employees/changed" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Accept": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return BambooHRResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_changed_employees" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return BambooHRResponse(success=False, error=str(e), message="Failed to execute get_changed_employees") + + async def list_employee_files( + self, + employee_id: str + ) -> BambooHRResponse: + """List all files for an employee (API v1) + + Args: + employee_id: The employee ID + + Returns: + BambooHRResponse with operation result + """ + url = self.base_url + "/employees/{employee_id}/files/view/".format(employee_id=employee_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Accept": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return BambooHRResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_employee_files" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return BambooHRResponse(success=False, error=str(e), message="Failed to execute list_employee_files") + + async def get_metadata_fields( + self + ) -> BambooHRResponse: + """Get list of all metadata fields (API v1) + + Returns: + BambooHRResponse with operation result + """ + url = self.base_url + "/meta/fields/" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Accept": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return BambooHRResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_metadata_fields" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return BambooHRResponse(success=False, error=str(e), message="Failed to execute get_metadata_fields") + + async def get_metadata_tables( + self + ) -> BambooHRResponse: + """Get list of all metadata tables (API v1) + + Returns: + BambooHRResponse with operation result + """ + url = self.base_url + "/meta/tables/" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Accept": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return BambooHRResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_metadata_tables" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return BambooHRResponse(success=False, error=str(e), message="Failed to execute get_metadata_tables") + + async def get_metadata_lists( + self + ) -> BambooHRResponse: + """Get list of all metadata lists (dropdown options) (API v1) + + Returns: + BambooHRResponse with operation result + """ + url = self.base_url + "/meta/lists/" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Accept": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return BambooHRResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_metadata_lists" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return BambooHRResponse(success=False, error=str(e), message="Failed to execute get_metadata_lists") + + async def get_metadata_users( + self + ) -> BambooHRResponse: + """Get list of all users with access to BambooHR (API v1) + + Returns: + BambooHRResponse with operation result + """ + url = self.base_url + "/meta/users/" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Accept": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return BambooHRResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_metadata_users" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return BambooHRResponse(success=False, error=str(e), message="Failed to execute get_metadata_users") + + async def run_custom_report( + self, + report_data: dict[str, Any], + output_format: str | None = None + ) -> BambooHRResponse: + """Run a custom report with specified fields and filters (API v1) + + Args: + output_format: Output format (e.g., 'JSON', 'CSV', 'XLS', 'XML', 'PDF') + report_data: Report configuration (fields, filters, title, etc.) + + Returns: + BambooHRResponse with operation result + """ + query_params: dict[str, Any] = {} + if output_format is not None: + query_params['format'] = output_format + + url = self.base_url + "/reports/custom" + + body: dict[str, Any] = {} + body.update(report_data) + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Accept": "application/json"}, + query=query_params, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return BambooHRResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed run_custom_report" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return BambooHRResponse(success=False, error=str(e), message="Failed to execute run_custom_report") + + async def get_company_report( + self, + report_id: str, + output_format: str | None = None, + fd: str | None = None + ) -> BambooHRResponse: + """Get a saved company report by ID (API v1) + + Args: + report_id: The report ID + output_format: Output format (e.g., 'JSON', 'CSV', 'XLS', 'XML', 'PDF') + fd: Set to 'yes' to include field data in the response + + Returns: + BambooHRResponse with operation result + """ + query_params: dict[str, Any] = {} + if output_format is not None: + query_params['format'] = output_format + if fd is not None: + query_params['fd'] = fd + + url = self.base_url + "/reports/{report_id}".format(report_id=report_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Accept": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return BambooHRResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_company_report" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return BambooHRResponse(success=False, error=str(e), message="Failed to execute get_company_report") + + async def get_time_off_requests( + self, + start: str | None = None, + end: str | None = None, + status: str | None = None, + action: str | None = None, + employeeId: str | None = None, + time_off_type: str | None = None + ) -> BambooHRResponse: + """Get time off requests within a date range (API v1) + + Args: + start: Start date (YYYY-MM-DD) + end: End date (YYYY-MM-DD) + status: Filter by status (approved, denied, superceded, requested, canceled) + action: Filter by action (view, approve) + employeeId: Filter by employee ID + time_off_type: Filter by time off type ID + + Returns: + BambooHRResponse with operation result + """ + query_params: dict[str, Any] = {} + if start is not None: + query_params['start'] = start + if end is not None: + query_params['end'] = end + if status is not None: + query_params['status'] = status + if action is not None: + query_params['action'] = action + if employeeId is not None: + query_params['employeeId'] = employeeId + if time_off_type is not None: + query_params['type'] = time_off_type + + url = self.base_url + "/time_off/requests/" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Accept": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return BambooHRResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_time_off_requests" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return BambooHRResponse(success=False, error=str(e), message="Failed to execute get_time_off_requests") + + async def get_time_off_policies( + self + ) -> BambooHRResponse: + """Get list of time off policies (API v1) + + Returns: + BambooHRResponse with operation result + """ + url = self.base_url + "/time_off/policies/" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Accept": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return BambooHRResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_time_off_policies" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return BambooHRResponse(success=False, error=str(e), message="Failed to execute get_time_off_policies") + + async def list_applications( + self, + page: int | None = None, + jobId: str | None = None, + applicationStatusId: str | None = None, + applicationStatus: str | None = None, + jobStatusGroups: str | None = None, + newSince: str | None = None, + sortBy: str | None = None, + sortOrder: str | None = None + ) -> BambooHRResponse: + """List applicant tracking applications (API v1) + + Args: + page: Page number for pagination + jobId: Filter by job ID + applicationStatusId: Filter by application status ID + applicationStatus: Filter by application status name + jobStatusGroups: Filter by job status groups (e.g., 'Active', 'Inactive') + newSince: Filter applications created since this date (ISO 8601) + sortBy: Sort field (e.g., 'created_date', 'first_name', 'last_name') + sortOrder: Sort order ('ASC' or 'DESC') + + Returns: + BambooHRResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if jobId is not None: + query_params['jobId'] = jobId + if applicationStatusId is not None: + query_params['applicationStatusId'] = applicationStatusId + if applicationStatus is not None: + query_params['applicationStatus'] = applicationStatus + if jobStatusGroups is not None: + query_params['jobStatusGroups'] = jobStatusGroups + if newSince is not None: + query_params['newSince'] = newSince + if sortBy is not None: + query_params['sortBy'] = sortBy + if sortOrder is not None: + query_params['sortOrder'] = sortOrder + + url = self.base_url + "/applicant_tracking/applications" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Accept": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return BambooHRResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_applications" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return BambooHRResponse(success=False, error=str(e), message="Failed to execute list_applications") + + async def get_application( + self, + application_id: str + ) -> BambooHRResponse: + """Get a specific applicant tracking application (API v1) + + Args: + application_id: The application ID + + Returns: + BambooHRResponse with operation result + """ + url = self.base_url + "/applicant_tracking/applications/{application_id}".format(application_id=application_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Accept": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return BambooHRResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_application" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return BambooHRResponse(success=False, error=str(e), message="Failed to execute get_application") + + async def get_job_summaries( + self + ) -> BambooHRResponse: + """Get job summaries for applicant tracking (API v1) + + Returns: + BambooHRResponse with operation result + """ + url = self.base_url + "/applicant_tracking/job_summaries" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Accept": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return BambooHRResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_job_summaries" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return BambooHRResponse(success=False, error=str(e), message="Failed to execute get_job_summaries") diff --git a/backend/python/app/sources/external/bamboohr/example.py b/backend/python/app/sources/external/bamboohr/example.py new file mode 100644 index 000000000..101cd4802 --- /dev/null +++ b/backend/python/app/sources/external/bamboohr/example.py @@ -0,0 +1,129 @@ +# ruff: noqa + +""" +BambooHR API Usage Examples + +This example demonstrates how to use the BambooHR DataSource to interact with +the BambooHR API, covering: +- Authentication (API Key via HTTP Basic Auth) +- Initializing the Client and DataSource +- Getting the Employee Directory +- Fetching Metadata Fields +- Getting Time Off Policies +- Getting Job Summaries + +Prerequisites: +1. Create a BambooHR API key at BambooHR > Settings > API Keys > Add New Key +2. Set BAMBOOHR_API_KEY environment variable +3. Set BAMBOOHR_COMPANY_DOMAIN environment variable (your company subdomain) +""" + +import asyncio +import json +import os + +from app.sources.client.bamboohr.bamboohr import ( + BambooHRApiKeyConfig, + BambooHRClient, + BambooHRResponse, +) +from app.sources.external.bamboohr.bamboohr import BambooHRDataSource + +# --- Configuration --- +API_KEY = os.getenv("BAMBOOHR_API_KEY") +COMPANY_DOMAIN = os.getenv("BAMBOOHR_COMPANY_DOMAIN") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: BambooHRResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + # Handle list-type responses (employees, fields, policies, etc.) + for key in ("employees", "fields", "tables", "lists", "users", + "requests", "policies", "applications", "jobSummaries"): + if isinstance(data, dict) and key in data: + items = data[key] + print(f" Found {len(items)} {key}.") + if items: + print(f" Sample: {json.dumps(items[0], indent=2)[:400]}...") + return + # Generic response + if isinstance(data, list): + print(f" Found {len(data)} items.") + if data: + print(f" Sample: {json.dumps(data[0], indent=2)[:400]}...") + else: + print(f" Data: {json.dumps(data, indent=2)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + # 1. Initialize Client + print_section("Initializing BambooHR Client") + + if not API_KEY: + print(" No API key found.") + print(" Please set BAMBOOHR_API_KEY environment variable") + return + + if not COMPANY_DOMAIN: + print(" No company domain found.") + print(" Please set BAMBOOHR_COMPANY_DOMAIN environment variable") + return + + print(" Using API Key authentication") + config = BambooHRApiKeyConfig( + company_domain=COMPANY_DOMAIN, + api_key=API_KEY, + ) + + client = BambooHRClient.build_with_config(config) + data_source = BambooHRDataSource(client) + print("Client initialized successfully.") + + try: + # 2. Get Employee Directory + print_section("Employee Directory") + directory_resp = await data_source.get_employee_directory() + print_result("Get Employee Directory", directory_resp) + + # 3. Get Metadata Fields + print_section("Metadata Fields") + fields_resp = await data_source.get_metadata_fields() + print_result("Get Metadata Fields", fields_resp) + + # 4. Get Time Off Policies + print_section("Time Off Policies") + policies_resp = await data_source.get_time_off_policies() + print_result("Get Time Off Policies", policies_resp) + + # 5. Get Job Summaries + print_section("Job Summaries") + jobs_resp = await data_source.get_job_summaries() + print_result("Get Job Summaries", jobs_resp) + + finally: + # Cleanup: Close the HTTP client session + print("\nClosing client connection...") + inner_client = client.get_client() + if hasattr(inner_client, "close"): + await inner_client.close() + + print("\n" + "=" * 80) + print(" All BambooHR API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/benchling/benchling.py b/backend/python/app/sources/external/benchling/benchling.py new file mode 100644 index 000000000..e86de610f --- /dev/null +++ b/backend/python/app/sources/external/benchling/benchling.py @@ -0,0 +1,418 @@ +# ruff: noqa +""" +Benchling SDK DataSource - Auto-generated SDK wrapper + +Generated from Benchling SDK method specifications. +Wraps the official benchling-sdk Python package. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any, Union, cast + +from benchling_sdk.benchling import Benchling # type: ignore[reportMissingImports] + +from app.sources.client.benchling.benchling import BenchlingClient, BenchlingResponse + + +class BenchlingDataSource: + """Benchling SDK DataSource + + Provides typed wrapper methods for Benchling SDK operations: + - Notebook entries + - Folders + - Entity schemas + - Custom entities + - DNA sequences + - AA sequences + - Oligos + - Users + - Projects + + All methods return BenchlingResponse objects. + """ + + def __init__(self, client_or_sdk: Union[BenchlingClient, Benchling, object]) -> None: # type: ignore[reportUnknownParameterType] + """Initialize with BenchlingClient, raw SDK, or any wrapper with ``get_sdk()``. + + Args: + client_or_sdk: BenchlingClient, Benchling SDK instance, or wrapper + """ + super().__init__() + if isinstance(client_or_sdk, Benchling): # type: ignore[reportUnknownMemberType] + self._sdk: Benchling = client_or_sdk # type: ignore[reportUnknownMemberType] + elif hasattr(client_or_sdk, "get_sdk"): # type: ignore[reportUnknownArgumentType] + sdk_obj = getattr(client_or_sdk, "get_sdk")() # type: ignore[reportUnknownArgumentType] + self._sdk = cast(Benchling, sdk_obj) + else: + self._sdk = cast(Benchling, client_or_sdk) + + # ----------------------------------------------------------------------- + # Entries + # ----------------------------------------------------------------------- + + def list_entries( + self, + ) -> BenchlingResponse: + """List notebook entries. + Returns: + BenchlingResponse with operation result + """ + try: + result: Any = list(self._sdk.entries.list()) # type: ignore[reportUnknownMemberType, reportUnknownArgumentType] + return BenchlingResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return BenchlingResponse( + success=False, error=str(e), message="Failed to execute list_entries" + ) + + + def get_entry( + self, + entry_id: str, + ) -> BenchlingResponse: + """Get a single notebook entry by ID. + + Args: + entry_id: The entry ID (e.g. ``etr_xxx``) + + Returns: + BenchlingResponse with operation result + """ + try: + result: Any = self._sdk.entries.get_by_id(entry_id) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + return BenchlingResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return BenchlingResponse( + success=False, error=str(e), message="Failed to execute get_entry" + ) + + + # ----------------------------------------------------------------------- + # Folders + # ----------------------------------------------------------------------- + + def list_folders( + self, + ) -> BenchlingResponse: + """List folders. + Returns: + BenchlingResponse with operation result + """ + try: + result: Any = list(self._sdk.folders.list()) # type: ignore[reportUnknownMemberType, reportUnknownArgumentType] + return BenchlingResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return BenchlingResponse( + success=False, error=str(e), message="Failed to execute list_folders" + ) + + + def get_folder( + self, + folder_id: str, + ) -> BenchlingResponse: + """Get a single folder by ID. + + Args: + folder_id: The folder ID + + Returns: + BenchlingResponse with operation result + """ + try: + result: Any = self._sdk.folders.get_by_id(folder_id) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + return BenchlingResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return BenchlingResponse( + success=False, error=str(e), message="Failed to execute get_folder" + ) + + + # ----------------------------------------------------------------------- + # Schemas + # ----------------------------------------------------------------------- + + def list_entity_schemas( + self, + ) -> BenchlingResponse: + """List entity schemas. + Returns: + BenchlingResponse with operation result + """ + try: + result: Any = list(self._sdk.schemas.list_entity_schemas()) # type: ignore[reportUnknownMemberType, reportUnknownArgumentType] + return BenchlingResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return BenchlingResponse( + success=False, error=str(e), message="Failed to execute list_entity_schemas" + ) + + + def get_entity_schema( + self, + schema_id: str, + ) -> BenchlingResponse: + """Get a single entity schema by ID. + + Args: + schema_id: The schema ID + + Returns: + BenchlingResponse with operation result + """ + try: + result: Any = self._sdk.schemas.get_entity_schema_by_id(schema_id) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + return BenchlingResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return BenchlingResponse( + success=False, error=str(e), message="Failed to execute get_entity_schema" + ) + + + # ----------------------------------------------------------------------- + # Custom Entities + # ----------------------------------------------------------------------- + + def list_custom_entities( + self, + ) -> BenchlingResponse: + """List custom entities. + Returns: + BenchlingResponse with operation result + """ + try: + result: Any = list(self._sdk.custom_entities.list()) # type: ignore[reportUnknownMemberType, reportUnknownArgumentType] + return BenchlingResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return BenchlingResponse( + success=False, error=str(e), message="Failed to execute list_custom_entities" + ) + + + def get_custom_entity( + self, + entity_id: str, + ) -> BenchlingResponse: + """Get a single custom entity by ID. + + Args: + entity_id: The custom entity ID + + Returns: + BenchlingResponse with operation result + """ + try: + result: Any = self._sdk.custom_entities.get_by_id(entity_id) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + return BenchlingResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return BenchlingResponse( + success=False, error=str(e), message="Failed to execute get_custom_entity" + ) + + + # ----------------------------------------------------------------------- + # DNA Sequences + # ----------------------------------------------------------------------- + + def list_dna_sequences( + self, + ) -> BenchlingResponse: + """List DNA sequences. + Returns: + BenchlingResponse with operation result + """ + try: + result: Any = list(self._sdk.dna_sequences.list()) # type: ignore[reportUnknownMemberType, reportUnknownArgumentType] + return BenchlingResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return BenchlingResponse( + success=False, error=str(e), message="Failed to execute list_dna_sequences" + ) + + + def get_dna_sequence( + self, + sequence_id: str, + ) -> BenchlingResponse: + """Get a single DNA sequence by ID. + + Args: + sequence_id: The DNA sequence ID + + Returns: + BenchlingResponse with operation result + """ + try: + result: Any = self._sdk.dna_sequences.get_by_id(sequence_id) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + return BenchlingResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return BenchlingResponse( + success=False, error=str(e), message="Failed to execute get_dna_sequence" + ) + + + # ----------------------------------------------------------------------- + # AA Sequences + # ----------------------------------------------------------------------- + + def list_aa_sequences( + self, + ) -> BenchlingResponse: + """List AA (amino acid) sequences. + Returns: + BenchlingResponse with operation result + """ + try: + result: Any = list(self._sdk.aa_sequences.list()) # type: ignore[reportUnknownMemberType, reportUnknownArgumentType] + return BenchlingResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return BenchlingResponse( + success=False, error=str(e), message="Failed to execute list_aa_sequences" + ) + + + def get_aa_sequence( + self, + sequence_id: str, + ) -> BenchlingResponse: + """Get a single AA sequence by ID. + + Args: + sequence_id: The AA sequence ID + + Returns: + BenchlingResponse with operation result + """ + try: + result: Any = self._sdk.aa_sequences.get_by_id(sequence_id) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + return BenchlingResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return BenchlingResponse( + success=False, error=str(e), message="Failed to execute get_aa_sequence" + ) + + + # ----------------------------------------------------------------------- + # Oligos + # ----------------------------------------------------------------------- + + def list_oligos( + self, + ) -> BenchlingResponse: + """List oligos. + Returns: + BenchlingResponse with operation result + """ + try: + result: Any = list(self._sdk.oligos.list()) # type: ignore[reportUnknownMemberType, reportUnknownArgumentType] + return BenchlingResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return BenchlingResponse( + success=False, error=str(e), message="Failed to execute list_oligos" + ) + + + def get_oligo( + self, + oligo_id: str, + ) -> BenchlingResponse: + """Get a single oligo by ID. + + Args: + oligo_id: The oligo ID + + Returns: + BenchlingResponse with operation result + """ + try: + result: Any = self._sdk.oligos.get_by_id(oligo_id) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + return BenchlingResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return BenchlingResponse( + success=False, error=str(e), message="Failed to execute get_oligo" + ) + + + # ----------------------------------------------------------------------- + # Users + # ----------------------------------------------------------------------- + + def list_users( + self, + ) -> BenchlingResponse: + """List users. + Returns: + BenchlingResponse with operation result + """ + try: + result: Any = list(self._sdk.users.list()) # type: ignore[reportUnknownMemberType, reportUnknownArgumentType] + return BenchlingResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return BenchlingResponse( + success=False, error=str(e), message="Failed to execute list_users" + ) + + + def get_user( + self, + user_id: str, + ) -> BenchlingResponse: + """Get a single user by ID. + + Args: + user_id: The user ID + + Returns: + BenchlingResponse with operation result + """ + try: + result: Any = self._sdk.users.get_by_id(user_id) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + return BenchlingResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return BenchlingResponse( + success=False, error=str(e), message="Failed to execute get_user" + ) + + + # ----------------------------------------------------------------------- + # Projects + # ----------------------------------------------------------------------- + + def list_projects( + self, + ) -> BenchlingResponse: + """List projects. + Returns: + BenchlingResponse with operation result + """ + try: + result: Any = list(self._sdk.projects.list()) # type: ignore[reportUnknownMemberType, reportUnknownArgumentType] + return BenchlingResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return BenchlingResponse( + success=False, error=str(e), message="Failed to execute list_projects" + ) + + + def get_project( + self, + project_id: str, + ) -> BenchlingResponse: + """Get a single project by ID. + + Args: + project_id: The project ID + + Returns: + BenchlingResponse with operation result + """ + try: + result: Any = self._sdk.projects.get_by_id(project_id) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + return BenchlingResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return BenchlingResponse( + success=False, error=str(e), message="Failed to execute get_project" + ) + diff --git a/backend/python/app/sources/external/benchling/code_generator.py b/backend/python/app/sources/external/benchling/code_generator.py new file mode 100644 index 000000000..8cda303eb --- /dev/null +++ b/backend/python/app/sources/external/benchling/code_generator.py @@ -0,0 +1,270 @@ +# ruff: noqa +""" +Benchling DataSource Code Generator + +Defines Benchling SDK method specifications and generates the DataSource +wrapper class (benchling.py) from them. + +Methods wrap the official benchling-sdk Python package. +""" + +from __future__ import annotations + +# Each spec: +# name: method name +# section: section heading +# doc: docstring line +# sdk_call: Python expression using `self._sdk` (the Benchling SDK instance) +# params: list of (param_name, param_type, default_or_None, doc_line) +# - if default_or_None is None -> positional required +# - if default_or_None is a string -> keyword with that default +METHODS = [ + # ---- Entries ---- + { + "name": "list_entries", + "section": "Entries", + "doc": "List notebook entries.", + "sdk_call": "list(self._sdk.entries.list())", + "params": [], + }, + { + "name": "get_entry", + "section": "Entries", + "doc": "Get a single notebook entry by ID.", + "sdk_call": "self._sdk.entries.get_by_id(entry_id)", + "params": [("entry_id", "str", None, "The entry ID (e.g. ``etr_xxx``)")], + }, + # ---- Folders ---- + { + "name": "list_folders", + "section": "Folders", + "doc": "List folders.", + "sdk_call": "list(self._sdk.folders.list())", + "params": [], + }, + { + "name": "get_folder", + "section": "Folders", + "doc": "Get a single folder by ID.", + "sdk_call": "self._sdk.folders.get_by_id(folder_id)", + "params": [("folder_id", "str", None, "The folder ID")], + }, + # ---- Entity Schemas ---- + { + "name": "list_entity_schemas", + "section": "Schemas", + "doc": "List entity schemas.", + "sdk_call": "list(self._sdk.schemas.list_entity_schemas())", + "params": [], + }, + { + "name": "get_entity_schema", + "section": "Schemas", + "doc": "Get a single entity schema by ID.", + "sdk_call": "self._sdk.schemas.get_entity_schema_by_id(schema_id)", + "params": [("schema_id", "str", None, "The schema ID")], + }, + # ---- Custom Entities ---- + { + "name": "list_custom_entities", + "section": "Custom Entities", + "doc": "List custom entities.", + "sdk_call": "list(self._sdk.custom_entities.list())", + "params": [], + }, + { + "name": "get_custom_entity", + "section": "Custom Entities", + "doc": "Get a single custom entity by ID.", + "sdk_call": "self._sdk.custom_entities.get_by_id(entity_id)", + "params": [("entity_id", "str", None, "The custom entity ID")], + }, + # ---- DNA Sequences ---- + { + "name": "list_dna_sequences", + "section": "DNA Sequences", + "doc": "List DNA sequences.", + "sdk_call": "list(self._sdk.dna_sequences.list())", + "params": [], + }, + { + "name": "get_dna_sequence", + "section": "DNA Sequences", + "doc": "Get a single DNA sequence by ID.", + "sdk_call": "self._sdk.dna_sequences.get_by_id(sequence_id)", + "params": [("sequence_id", "str", None, "The DNA sequence ID")], + }, + # ---- AA Sequences ---- + { + "name": "list_aa_sequences", + "section": "AA Sequences", + "doc": "List AA (amino acid) sequences.", + "sdk_call": "list(self._sdk.aa_sequences.list())", + "params": [], + }, + { + "name": "get_aa_sequence", + "section": "AA Sequences", + "doc": "Get a single AA sequence by ID.", + "sdk_call": "self._sdk.aa_sequences.get_by_id(sequence_id)", + "params": [("sequence_id", "str", None, "The AA sequence ID")], + }, + # ---- Oligos ---- + { + "name": "list_oligos", + "section": "Oligos", + "doc": "List oligos.", + "sdk_call": "list(self._sdk.oligos.list())", + "params": [], + }, + { + "name": "get_oligo", + "section": "Oligos", + "doc": "Get a single oligo by ID.", + "sdk_call": "self._sdk.oligos.get_by_id(oligo_id)", + "params": [("oligo_id", "str", None, "The oligo ID")], + }, + # ---- Users ---- + { + "name": "list_users", + "section": "Users", + "doc": "List users.", + "sdk_call": "list(self._sdk.users.list())", + "params": [], + }, + { + "name": "get_user", + "section": "Users", + "doc": "Get a single user by ID.", + "sdk_call": "self._sdk.users.get_by_id(user_id)", + "params": [("user_id", "str", None, "The user ID")], + }, + # ---- Projects ---- + { + "name": "list_projects", + "section": "Projects", + "doc": "List projects.", + "sdk_call": "list(self._sdk.projects.list())", + "params": [], + }, + { + "name": "get_project", + "section": "Projects", + "doc": "Get a single project by ID.", + "sdk_call": "self._sdk.projects.get_by_id(project_id)", + "params": [("project_id", "str", None, "The project ID")], + }, +] + + +def _gen_method(spec: dict) -> str: + """Generate a single method from a spec.""" + name = spec["name"] + doc = spec["doc"] + sdk_call = spec["sdk_call"] + params = spec.get("params", []) + + # Build signature + sig_parts = ["self"] + for p_name, p_type, p_default, _ in params: + if p_default is None: + sig_parts.append(f"{p_name}: {p_type}") + else: + sig_parts.append(f"{p_name}: {p_type} = {p_default}") + + sig = ",\n ".join(sig_parts) + + # Build docstring args section + doc_args = "" + if params: + doc_args = "\n\n Args:\n" + for p_name, _, _, p_doc in params: + doc_args += f" {p_name}: {p_doc}\n" + + return f''' + def {name}( + {sig}, + ) -> BenchlingResponse: + """{doc}{doc_args} + Returns: + BenchlingResponse with operation result + """ + try: + result = {sdk_call} + return BenchlingResponse(success=True, data=result) + except Exception as e: + return BenchlingResponse( + success=False, error=str(e), message="Failed to execute {name}" + ) +''' + + +def generate_datasource() -> str: + """Generate the full Benchling DataSource module code.""" + header = '''# ruff: noqa +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownParameterType=false +""" +Benchling SDK DataSource - Auto-generated SDK wrapper + +Generated from Benchling SDK method specifications. +Wraps the official benchling-sdk Python package. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Union, cast + +from benchling_sdk.benchling import Benchling + +from app.sources.client.benchling.benchling import BenchlingClient, BenchlingResponse + + +class BenchlingDataSource: + """Benchling SDK DataSource + + Provides typed wrapper methods for Benchling SDK operations: + - Notebook entries + - Folders + - Entity schemas + - Custom entities + - DNA sequences + - AA sequences + - Oligos + - Users + - Projects + + All methods return BenchlingResponse objects. + """ + + def __init__(self, client_or_sdk: Union[BenchlingClient, Benchling, object]) -> None: + """Initialize with BenchlingClient, raw SDK, or any wrapper with ``get_sdk()``. + + Args: + client_or_sdk: BenchlingClient, Benchling SDK instance, or wrapper + """ + if isinstance(client_or_sdk, Benchling): + self._sdk: Benchling = client_or_sdk + elif hasattr(client_or_sdk, "get_sdk"): + sdk_obj = getattr(client_or_sdk, "get_sdk")() + self._sdk = cast(Benchling, sdk_obj) + else: + self._sdk = cast(Benchling, client_or_sdk) +''' + + methods = [] + current_section = None + for spec in METHODS: + section = spec.get("section", "") + if section and section != current_section: + current_section = section + methods.append( + f"\n # {'-' * 71}\n # {section}\n # {'-' * 71}" + ) + methods.append(_gen_method(spec)) + + return header + "\n".join(methods) + "\n" + + +if __name__ == "__main__": + print(generate_datasource()) diff --git a/backend/python/app/sources/external/benchling/example.py b/backend/python/app/sources/external/benchling/example.py new file mode 100644 index 000000000..fe20f023e --- /dev/null +++ b/backend/python/app/sources/external/benchling/example.py @@ -0,0 +1,123 @@ +# ruff: noqa + +""" +Benchling API Usage Examples + +This example demonstrates how to use the Benchling DataSource to interact +with the Benchling API via the official benchling-sdk, covering: +- Authentication (API Key) +- Initializing the Client and DataSource +- Listing notebook entries, folders, schemas +- Fetching custom entities, DNA sequences, users, projects + +Prerequisites: +1. Have a Benchling tenant with an API key +2. Set the following environment variables: + - BENCHLING_API_KEY: Your API key + - BENCHLING_TENANT_URL: Full tenant URL (e.g. https://your-tenant.benchling.com) +""" + +import json +import os + +from app.sources.client.benchling.benchling import ( + BenchlingApiKeyConfig, + BenchlingClient, + BenchlingResponse, +) +from app.sources.external.benchling.benchling import BenchlingDataSource + +# --- Configuration --- +API_KEY = os.getenv("BENCHLING_API_KEY") +TENANT_URL = os.getenv("BENCHLING_TENANT_URL", "") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: BenchlingResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + if isinstance(data, list): + print(f" Found {len(data)} items.") + if data: + print(f" Sample: {str(data[0])[:400]}...") + elif isinstance(data, dict): + print(f" Data: {json.dumps(data, indent=2, default=str)[:500]}...") + else: + print(f" Data: {str(data)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +def main() -> None: + # 1. Initialize Client + print_section("Initializing Benchling Client") + + if not API_KEY or not TENANT_URL: + print(" Missing required environment variables.") + print(" Please set:") + print(" - BENCHLING_API_KEY") + print(" - BENCHLING_TENANT_URL (e.g. https://your-tenant.benchling.com)") + return + + print(" Using API Key authentication") + config = BenchlingApiKeyConfig( + api_key=API_KEY, + tenant_url=TENANT_URL, + ) + + client = BenchlingClient.build_with_config(config) + data_source = BenchlingDataSource(client) + print(f" Client initialized successfully (tenant: {TENANT_URL})") + + # 2. List Entries + print_section("Notebook Entries") + entries_resp = data_source.list_entries() + print_result("List Entries", entries_resp) + + # 3. List Folders + print_section("Folders") + folders_resp = data_source.list_folders() + print_result("List Folders", folders_resp) + + # 4. List Entity Schemas + print_section("Entity Schemas") + schemas_resp = data_source.list_entity_schemas() + print_result("List Entity Schemas", schemas_resp) + + # 5. List Custom Entities + print_section("Custom Entities") + entities_resp = data_source.list_custom_entities() + print_result("List Custom Entities", entities_resp) + + # 6. List DNA Sequences + print_section("DNA Sequences") + dna_resp = data_source.list_dna_sequences() + print_result("List DNA Sequences", dna_resp) + + # 7. List Users + print_section("Users") + users_resp = data_source.list_users() + print_result("List Users", users_resp) + + # 8. List Projects + print_section("Projects") + projects_resp = data_source.list_projects() + print_result("List Projects", projects_resp) + + print("\n" + "=" * 80) + print(" All Benchling API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + main() diff --git a/backend/python/app/sources/external/benchling/run_generator.py b/backend/python/app/sources/external/benchling/run_generator.py new file mode 100644 index 000000000..bd9631016 --- /dev/null +++ b/backend/python/app/sources/external/benchling/run_generator.py @@ -0,0 +1,25 @@ +# ruff: noqa: T201 +"""Runner script to generate the Benchling DataSource wrapper. + +Execute this script to regenerate benchling.py from the method definitions +in code_generator.py. + +Usage: + python -m app.sources.external.benchling.run_generator +""" + +from app.sources.external.benchling.code_generator import generate_datasource + + +def main() -> None: + """Generate the Benchling DataSource file.""" + code = generate_datasource() + output_path = __file__.replace("run_generator.py", "benchling.py") + with open(output_path, "w") as f: + f.write(code) + print(f"Generated Benchling DataSource -> {output_path}") + print(f" Total characters: {len(code)}") + + +if __name__ == "__main__": + main() diff --git a/backend/python/app/sources/external/bigquery/bigquery_.py b/backend/python/app/sources/external/bigquery/bigquery_.py new file mode 100644 index 000000000..10cb0b9ac --- /dev/null +++ b/backend/python/app/sources/external/bigquery/bigquery_.py @@ -0,0 +1,103 @@ +# ruff: noqa +from __future__ import annotations + +from google.cloud import bigquery # type: ignore[import-untyped] +from typing import Dict, List, Optional, Union, cast + +from app.sources.client.bigquery.bigquery import BigQueryResponse + +class BigQueryDataSource: + """ + Strict, typed wrapper over google-cloud-bigquery for common BigQuery operations. + + Accepts either a google-cloud-bigquery `Client` instance *or* any object with `.get_sdk() -> bigquery.Client`. + """ + + def __init__(self, client_or_sdk: Union[bigquery.Client, object]) -> None: + super().__init__() + # Support a raw SDK or a wrapper that exposes `.get_sdk()` + if hasattr(client_or_sdk, "get_sdk"): + sdk_obj = getattr(client_or_sdk, "get_sdk")() + self._sdk: bigquery.Client = cast(bigquery.Client, sdk_obj) + else: + self._sdk = cast(bigquery.Client, client_or_sdk) + + # ---- helpers ---- + @staticmethod + def _params(**kwargs: object) -> Dict[str, object]: + # Filter out Nones to avoid overriding SDK defaults + out: Dict[str, object] = {} + for k, v in kwargs.items(): + if v is None: + continue + if isinstance(v, (list, dict)) and len(v) == 0: + continue + out[k] = v + return out + def query(self, query_string: str, project: Optional[str] = None, location: Optional[str] = None) -> BigQueryResponse: + """Execute a SQL query and return results as list of dicts.""" + job_config_kwargs = self._params() + query_kwargs = self._params(project=project, location=location) + query_job = self._sdk.query(query_string, **query_kwargs) + results = query_job.result() + rows = [dict(row) for row in results] + return BigQueryResponse(success=True, data=rows) + def list_datasets(self, project: Optional[str] = None, max_results: Optional[int] = None) -> BigQueryResponse: + """List datasets in the project.""" + params = self._params(project=project, max_results=max_results) + datasets = list(self._sdk.list_datasets(**params)) + return BigQueryResponse(success=True, data=datasets) + def get_dataset(self, dataset_id: str) -> BigQueryResponse: + """Get a dataset by ID.""" + dataset = self._sdk.get_dataset(dataset_id) + return BigQueryResponse(success=True, data=dataset) + def create_dataset(self, dataset_id: str, location: Optional[str] = None, description: Optional[str] = None) -> BigQueryResponse: + """Create a new dataset.""" + dataset_ref = bigquery.Dataset(self._sdk.dataset(dataset_id)) + if location is not None: + dataset_ref.location = location + if description is not None: + dataset_ref.description = description + dataset = self._sdk.create_dataset(dataset_ref) + return BigQueryResponse(success=True, data=dataset) + def delete_dataset(self, dataset_id: str, delete_contents: bool = False) -> BigQueryResponse: + """Delete a dataset.""" + self._sdk.delete_dataset(dataset_id, delete_contents=delete_contents) + return BigQueryResponse(success=True, data=True) + def list_tables(self, dataset_id: str, max_results: Optional[int] = None) -> BigQueryResponse: + """List tables in a dataset.""" + params = self._params(max_results=max_results) + tables = list(self._sdk.list_tables(dataset_id, **params)) + return BigQueryResponse(success=True, data=tables) + def get_table(self, table_ref: str) -> BigQueryResponse: + """Get a table by reference (dataset.table).""" + table = self._sdk.get_table(table_ref) + return BigQueryResponse(success=True, data=table) + def create_table(self, table_ref: str, schema: Optional[List[Dict[str, str]]] = None) -> BigQueryResponse: + """Create a table with optional schema.""" + table = bigquery.Table(table_ref) + if schema is not None and len(schema) > 0: + fields = [bigquery.SchemaField(f['name'], f.get('type', 'STRING'), mode=f.get('mode', 'NULLABLE')) for f in schema] + table.schema = fields + result = self._sdk.create_table(table) + return BigQueryResponse(success=True, data=result) + def delete_table(self, table_ref: str) -> BigQueryResponse: + """Delete a table.""" + self._sdk.delete_table(table_ref) + return BigQueryResponse(success=True, data=True) + def get_table_schema(self, table_ref: str) -> BigQueryResponse: + """Get the schema of a table.""" + table = self._sdk.get_table(table_ref) + schema = table.schema + return BigQueryResponse(success=True, data=schema) + def list_jobs(self, project: Optional[str] = None, max_results: Optional[int] = None, state_filter: Optional[str] = None) -> BigQueryResponse: + """List jobs in the project.""" + params = self._params(project=project, max_results=max_results, state_filter=state_filter) + jobs = list(self._sdk.list_jobs(**params)) + return BigQueryResponse(success=True, data=jobs) + def get_job(self, job_id: str, project: Optional[str] = None, location: Optional[str] = None) -> BigQueryResponse: + """Get a job by ID.""" + params = self._params(project=project, location=location) + job = self._sdk.get_job(job_id, **params) + return BigQueryResponse(success=True, data=job) + diff --git a/backend/python/app/sources/external/bigquery/example.py b/backend/python/app/sources/external/bigquery/example.py new file mode 100644 index 000000000..af91af195 --- /dev/null +++ b/backend/python/app/sources/external/bigquery/example.py @@ -0,0 +1,115 @@ +# ruff: noqa +from __future__ import annotations + +import json +import os + +from dotenv import load_dotenv + +from app.sources.client.bigquery.bigquery import ( + BigQueryClient, + BigQueryOAuthConfig, + BigQueryResponse, + BigQueryServiceAccountConfig, +) +from app.sources.external.bigquery.bigquery_ import BigQueryDataSource + + +def _print_status(title: str, res: BigQueryResponse) -> None: + print(f"\n== {title} ==") + if not res.success: + print("error:", res.error or res.message) + else: + print("ok") + + +def main() -> None: + # Load .env if present + load_dotenv() + + # Minimal envs + auth_type = os.getenv("BIGQUERY_AUTH_TYPE", "SERVICE_ACCOUNT") # SERVICE_ACCOUNT or OAUTH + project_id = os.getenv("BIGQUERY_PROJECT_ID", "") + + if not project_id: + raise RuntimeError("BIGQUERY_PROJECT_ID is required") + + if auth_type == "SERVICE_ACCOUNT": + sa_path = os.getenv("BIGQUERY_SERVICE_ACCOUNT_JSON", "") + if not sa_path: + raise RuntimeError("BIGQUERY_SERVICE_ACCOUNT_JSON path is required for SERVICE_ACCOUNT auth") + with open(sa_path, "r") as f: + sa_json = json.load(f) + client = BigQueryClient.build_with_config( + BigQueryServiceAccountConfig( + service_account_json=sa_json, + project_id=project_id, + ) + ) + else: + access_token = os.getenv("BIGQUERY_ACCESS_TOKEN", "") + if not access_token: + raise RuntimeError("BIGQUERY_ACCESS_TOKEN is required for OAUTH auth") + client = BigQueryClient.build_with_config( + BigQueryOAuthConfig( + access_token=access_token, + project_id=project_id, + ) + ) + + ds = BigQueryDataSource(client) + + # 1) List datasets + datasets_res: BigQueryResponse = ds.list_datasets() + _print_status("List Datasets", datasets_res) + if datasets_res.success and datasets_res.data: + names = [getattr(d, "dataset_id", str(d)) for d in datasets_res.data[:10]] + print("datasets:", names) + + # 2) If datasets exist, list tables in the first one + if datasets_res.success and datasets_res.data and len(datasets_res.data) > 0: + first_dataset = getattr(datasets_res.data[0], "dataset_id", None) + if first_dataset: + tables_res: BigQueryResponse = ds.list_tables(first_dataset) + _print_status(f"List Tables ({first_dataset})", tables_res) + if tables_res.success and tables_res.data: + names = [getattr(t, "table_id", str(t)) for t in tables_res.data[:10]] + print("tables:", names) + + # Get schema for first table + if len(tables_res.data) > 0: + first_table = tables_res.data[0] + table_ref = f"{project_id}.{first_dataset}.{getattr(first_table, 'table_id', '')}" + try: + schema_res: BigQueryResponse = ds.get_table_schema(table_ref) + _print_status(f"Get Table Schema ({table_ref})", schema_res) + if schema_res.success and schema_res.data: + fields = [getattr(f, "name", str(f)) for f in schema_res.data[:10]] + print("schema fields:", fields) + except Exception as e: + print(f"Get table schema failed: {e}") + + # 3) Run a simple query + try: + query_res: BigQueryResponse = ds.query( + f"SELECT 1 AS test_col, 'hello' AS greeting" + ) + _print_status("Simple Query", query_res) + if query_res.success and query_res.data: + print("rows:", query_res.data) + except Exception as e: + print(f"Query failed: {e}") + + # 4) List jobs + try: + jobs_res: BigQueryResponse = ds.list_jobs(max_results=5) + _print_status("List Jobs", jobs_res) + if jobs_res.success and jobs_res.data: + job_ids = [getattr(j, "job_id", str(j)) for j in jobs_res.data[:5]] + print("jobs:", job_ids) + except Exception as e: + print(f"List jobs failed: {e}") + + +if __name__ == "__main__": + main() diff --git a/backend/python/app/sources/external/bynder/bynder.py b/backend/python/app/sources/external/bynder/bynder.py new file mode 100644 index 000000000..d96103351 --- /dev/null +++ b/backend/python/app/sources/external/bynder/bynder.py @@ -0,0 +1,308 @@ +# ruff: noqa +""" +Bynder SDK DataSource - Auto-generated SDK wrapper + +Generated from Bynder SDK method specifications. +Wraps the official bynder-sdk Python package. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any, Union, cast + +from bynder_sdk import BynderClient as BynderSDKClient # type: ignore[reportMissingImports] + +from app.sources.client.bynder.bynder import BynderClient, BynderResponse + + +class BynderDataSource: + """Bynder SDK DataSource + + Provides typed wrapper methods for Bynder SDK operations: + - Media asset management + - Collections management + - Tags management + - Metaproperties management + - Brands management + - Account users management + - Smartfilters + + All methods return BynderResponse objects. + """ + + def __init__(self, client_or_sdk: Union[BynderClient, BynderSDKClient, object]) -> None: # type: ignore[reportUnknownParameterType] + """Initialize with BynderClient, raw SDK, or any wrapper with ``get_sdk()``. + + Args: + client_or_sdk: BynderClient, BynderSDKClient instance, or wrapper + """ + super().__init__() + if isinstance(client_or_sdk, BynderSDKClient): # type: ignore[reportUnknownMemberType] + self._sdk: BynderSDKClient = client_or_sdk # type: ignore[reportUnknownMemberType] + elif hasattr(client_or_sdk, "get_sdk"): # type: ignore[reportUnknownArgumentType] + sdk_obj = getattr(client_or_sdk, "get_sdk")() # type: ignore[reportUnknownArgumentType] + self._sdk = cast(BynderSDKClient, sdk_obj) + else: + self._sdk = cast(BynderSDKClient, client_or_sdk) + + self._asset_bank: Any = self._sdk.asset_bank_client # type: ignore[reportUnknownMemberType] + self._collection_client: Any = self._sdk.collection_client # type: ignore[reportUnknownMemberType] + + # ----------------------------------------------------------------------- + # Media + # ----------------------------------------------------------------------- + + def get_media_list( + self, + *, + limit: int | None = None, + page: int | None = None, + keyword: str | None = None, + type: str | None = None, + ) -> BynderResponse: + """List media assets. + + Args: + limit: Maximum number of results + page: Page number for pagination + keyword: Filter by keyword + type: Filter by media type + + Returns: + BynderResponse with operation result + """ + try: + query: dict[str, object] = {} + if limit is not None: + query['limit'] = limit + if page is not None: + query['page'] = page + if keyword is not None: + query['keyword'] = keyword + if type is not None: + query['type'] = type + result = self._asset_bank.media_list(query) + return BynderResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return BynderResponse( + success=False, error=str(e), message="Failed to execute get_media_list" + ) + + + def get_media( + self, + media_id: str, + ) -> BynderResponse: + """Get a specific media asset by ID. + + Args: + media_id: The media asset ID + + Returns: + BynderResponse with operation result + """ + try: + result = self._asset_bank.media_info(media_id) + return BynderResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return BynderResponse( + success=False, error=str(e), message="Failed to execute get_media" + ) + + + def get_media_download_url( + self, + media_id: str, + ) -> BynderResponse: + """Get the download URL for a media asset. + + Args: + media_id: The media asset ID + + Returns: + BynderResponse with operation result + """ + try: + result = self._asset_bank.media_download_url(media_id) + return BynderResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return BynderResponse( + success=False, error=str(e), message="Failed to execute get_media_download_url" + ) + + + # ----------------------------------------------------------------------- + # Collections + # ----------------------------------------------------------------------- + + def get_collections( + self, + *, + limit: int | None = None, + page: int | None = None, + ) -> BynderResponse: + """List all collections. + + Args: + limit: Maximum number of results + page: Page number for pagination + + Returns: + BynderResponse with operation result + """ + try: + query: dict[str, object] = {} + if limit is not None: + query['limit'] = limit + if page is not None: + query['page'] = page + result = self._collection_client.collections(query) + return BynderResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return BynderResponse( + success=False, error=str(e), message="Failed to execute get_collections" + ) + + + def get_collection( + self, + collection_id: str, + ) -> BynderResponse: + """Get a specific collection by ID. + + Args: + collection_id: The collection ID + + Returns: + BynderResponse with operation result + """ + try: + result = self._collection_client.collection_info(collection_id) + return BynderResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return BynderResponse( + success=False, error=str(e), message="Failed to execute get_collection" + ) + + + # ----------------------------------------------------------------------- + # Tags + # ----------------------------------------------------------------------- + + def get_tags( + self, + ) -> BynderResponse: + """List all tags. + Returns: + BynderResponse with operation result + """ + try: + result = self._asset_bank.tags() + return BynderResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return BynderResponse( + success=False, error=str(e), message="Failed to execute get_tags" + ) + + + # ----------------------------------------------------------------------- + # Metaproperties + # ----------------------------------------------------------------------- + + def get_metaproperties( + self, + ) -> BynderResponse: + """List all metaproperties. + Returns: + BynderResponse with operation result + """ + try: + result = self._asset_bank.meta_properties() + return BynderResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return BynderResponse( + success=False, error=str(e), message="Failed to execute get_metaproperties" + ) + + + def get_metaproperty( + self, + metaproperty_id: str, + ) -> BynderResponse: + """Get a specific metaproperty by ID. + + Args: + metaproperty_id: The metaproperty ID + + Returns: + BynderResponse with operation result + """ + try: + result = self._asset_bank.meta_property_info(metaproperty_id) + return BynderResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return BynderResponse( + success=False, error=str(e), message="Failed to execute get_metaproperty" + ) + + + # ----------------------------------------------------------------------- + # Brands + # ----------------------------------------------------------------------- + + def get_brands( + self, + ) -> BynderResponse: + """List all brands. + Returns: + BynderResponse with operation result + """ + try: + result = self._asset_bank.brands() + return BynderResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return BynderResponse( + success=False, error=str(e), message="Failed to execute get_brands" + ) + + + # ----------------------------------------------------------------------- + # Account Users + # ----------------------------------------------------------------------- + + def get_account_users( + self, + ) -> BynderResponse: + """List all account users. + Returns: + BynderResponse with operation result + """ + try: + result = self._asset_bank.users() + return BynderResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return BynderResponse( + success=False, error=str(e), message="Failed to execute get_account_users" + ) + + + # ----------------------------------------------------------------------- + # Smartfilters + # ----------------------------------------------------------------------- + + def get_smartfilters( + self, + ) -> BynderResponse: + """List all smartfilters. + Returns: + BynderResponse with operation result + """ + try: + result = self._asset_bank.smartfilters() + return BynderResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return BynderResponse( + success=False, error=str(e), message="Failed to execute get_smartfilters" + ) + diff --git a/backend/python/app/sources/external/bynder/code_generator.py b/backend/python/app/sources/external/bynder/code_generator.py new file mode 100644 index 000000000..5f6915b94 --- /dev/null +++ b/backend/python/app/sources/external/bynder/code_generator.py @@ -0,0 +1,243 @@ +# ruff: noqa +""" +Bynder DataSource Code Generator + +Defines Bynder SDK method specifications and generates the DataSource +wrapper class (bynder.py) from them. + +Methods wrap the official bynder-sdk Python package. +""" + +from __future__ import annotations + +# Each spec: +# name: method name +# section: section heading +# doc: docstring line +# sdk_call: Python expression using `self._sdk` (the BynderClient SDK instance) +# params: list of (param_name, param_type, default_or_None, doc_line) +METHODS = [ + # ---- Media ---- + { + "name": "get_media_list", + "section": "Media", + "doc": "List media assets.", + "sdk_call": "self._asset_bank.media_list(query)", + "params": [ + ("limit", "int | None", "None", "Maximum number of results"), + ("page", "int | None", "None", "Page number for pagination"), + ("keyword", "str | None", "None", "Filter by keyword"), + ("type", "str | None", "None", "Filter by media type"), + ], + "build_query": True, + }, + { + "name": "get_media", + "section": "Media", + "doc": "Get a specific media asset by ID.", + "sdk_call": "self._asset_bank.media_info(media_id)", + "params": [("media_id", "str", None, "The media asset ID")], + }, + { + "name": "get_media_download_url", + "section": "Media", + "doc": "Get the download URL for a media asset.", + "sdk_call": "self._asset_bank.media_download_url(media_id)", + "params": [("media_id", "str", None, "The media asset ID")], + }, + # ---- Collections ---- + { + "name": "get_collections", + "section": "Collections", + "doc": "List all collections.", + "sdk_call": "self._collection_client.collections(query)", + "params": [ + ("limit", "int | None", "None", "Maximum number of results"), + ("page", "int | None", "None", "Page number for pagination"), + ], + "build_query": True, + }, + { + "name": "get_collection", + "section": "Collections", + "doc": "Get a specific collection by ID.", + "sdk_call": "self._collection_client.collection_info(collection_id)", + "params": [("collection_id", "str", None, "The collection ID")], + }, + # ---- Tags ---- + { + "name": "get_tags", + "section": "Tags", + "doc": "List all tags.", + "sdk_call": "self._asset_bank.tags()", + "params": [], + }, + # ---- Metaproperties ---- + { + "name": "get_metaproperties", + "section": "Metaproperties", + "doc": "List all metaproperties.", + "sdk_call": "self._asset_bank.meta_properties()", + "params": [], + }, + { + "name": "get_metaproperty", + "section": "Metaproperties", + "doc": "Get a specific metaproperty by ID.", + "sdk_call": "self._asset_bank.meta_property_info(metaproperty_id)", + "params": [("metaproperty_id", "str", None, "The metaproperty ID")], + }, + # ---- Brands ---- + { + "name": "get_brands", + "section": "Brands", + "doc": "List all brands.", + "sdk_call": "self._asset_bank.brands()", + "params": [], + }, + # ---- Account Users ---- + { + "name": "get_account_users", + "section": "Account Users", + "doc": "List all account users.", + "sdk_call": "self._asset_bank.users()", + "params": [], + }, + # ---- Smartfilters ---- + { + "name": "get_smartfilters", + "section": "Smartfilters", + "doc": "List all smartfilters.", + "sdk_call": "self._asset_bank.smartfilters()", + "params": [], + }, +] + + +def _gen_method(spec: dict) -> str: + """Generate a single method from a spec.""" + name = spec["name"] + doc = spec["doc"] + sdk_call = spec["sdk_call"] + params = spec.get("params", []) + build_query = spec.get("build_query", False) + + # Build signature + sig_parts = ["self"] + has_kw_only = False + for p_name, p_type, p_default, _ in params: + if p_default is not None and not has_kw_only: + sig_parts.append("*") + has_kw_only = True + if p_default is None: + sig_parts.append(f"{p_name}: {p_type}") + else: + sig_parts.append(f"{p_name}: {p_type} = {p_default}") + + sig = ",\n ".join(sig_parts) + + # Build docstring args section + doc_args = "" + if params: + doc_args = "\n\n Args:\n" + for p_name, _, _, p_doc in params: + doc_args += f" {p_name}: {p_doc}\n" + + # Build query dict if needed + query_block = "" + if build_query: + lines = [" query: dict[str, object] = {}"] + for p_name, _, p_default, _ in params: + if p_default is not None: + lines.append(f" if {p_name} is not None:") + lines.append(f" query['{p_name}'] = {p_name}") + query_block = "\n".join(lines) + "\n" + + return f''' + def {name}( + {sig}, + ) -> BynderResponse: + """{doc}{doc_args} + Returns: + BynderResponse with operation result + """ + try: +{query_block} result = {sdk_call} + return BynderResponse(success=True, data=result) + except Exception as e: + return BynderResponse( + success=False, error=str(e), message="Failed to execute {name}" + ) +''' + + +def generate_datasource() -> str: + """Generate the full Bynder DataSource module code.""" + header = '''# ruff: noqa +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownParameterType=false +""" +Bynder SDK DataSource - Auto-generated SDK wrapper + +Generated from Bynder SDK method specifications. +Wraps the official bynder-sdk Python package. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Union, cast + +from bynder_sdk import BynderClient as BynderSDKClient + +from app.sources.client.bynder.bynder import BynderClient, BynderResponse + + +class BynderDataSource: + """Bynder SDK DataSource + + Provides typed wrapper methods for Bynder SDK operations: + - Media asset management + - Collections management + - Tags management + - Metaproperties management + - Brands management + - Account users management + - Smartfilters + + All methods return BynderResponse objects. + """ + + def __init__(self, client_or_sdk: Union[BynderClient, BynderSDKClient, object]) -> None: + """Initialize with BynderClient, raw SDK, or any wrapper with ``get_sdk()``. + + Args: + client_or_sdk: BynderClient, BynderSDKClient instance, or wrapper + """ + if isinstance(client_or_sdk, BynderSDKClient): + self._sdk: BynderSDKClient = client_or_sdk + elif hasattr(client_or_sdk, "get_sdk"): + sdk_obj = getattr(client_or_sdk, "get_sdk")() + self._sdk = cast(BynderSDKClient, sdk_obj) + else: + self._sdk = cast(BynderSDKClient, client_or_sdk) + + self._asset_bank = self._sdk.asset_bank_client + self._collection_client = self._sdk.collection_client +''' + + methods = [] + current_section = None + for spec in METHODS: + section = spec.get("section", "") + if section and section != current_section: + current_section = section + methods.append( + f"\n # {'-' * 71}\n # {section}\n # {'-' * 71}" + ) + methods.append(_gen_method(spec)) + + return header + "\n".join(methods) + "\n" + + +if __name__ == "__main__": + print(generate_datasource()) diff --git a/backend/python/app/sources/external/bynder/example.py b/backend/python/app/sources/external/bynder/example.py new file mode 100644 index 000000000..296f45b31 --- /dev/null +++ b/backend/python/app/sources/external/bynder/example.py @@ -0,0 +1,145 @@ +# ruff: noqa + +""" +Bynder API Usage Examples + +This example demonstrates how to use the Bynder DataSource to interact with +the Bynder API via the official bynder-sdk, covering: +- Authentication (Permanent Token, OAuth2) +- Initializing the Client and DataSource +- Listing Media Assets, Collections, Tags +- Fetching Metaproperties, Brands, Account Users + +Prerequisites: +For Permanent Token: +1. Generate a permanent token in Bynder portal settings +2. Set BYNDER_PERMANENT_TOKEN and BYNDER_DOMAIN environment variables + +For OAuth: +1. Register an OAuth2 application in Bynder portal settings +2. Set BYNDER_CLIENT_ID, BYNDER_CLIENT_SECRET, BYNDER_DOMAIN, and + BYNDER_REDIRECT_URI environment variables. Token must include access_token. + +The BYNDER_DOMAIN is the full portal domain (e.g., "portal.getbynder.com"). +""" + +import json +import os + +from app.sources.client.bynder.bynder import ( + BynderClient, + BynderPermanentTokenConfig, + BynderResponse, +) +from app.sources.external.bynder.bynder import BynderDataSource + +# --- Configuration --- +PERMANENT_TOKEN = os.getenv("BYNDER_PERMANENT_TOKEN") +DOMAIN = os.getenv("BYNDER_DOMAIN", "") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: BynderResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + if isinstance(data, list): + print(f" Found {len(data)} items.") + if data: + print(f" Sample: {json.dumps(data[0], indent=2, default=str)[:400]}...") + elif isinstance(data, dict): + print(f" Data: {json.dumps(data, indent=2, default=str)[:500]}...") + else: + print(f" Data: {str(data)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +def main() -> None: + if not DOMAIN: + print(" BYNDER_DOMAIN environment variable is required.") + print(" Example: export BYNDER_DOMAIN=portal.getbynder.com") + return + + # 1. Initialize Client + print_section("Initializing Bynder Client") + + if not PERMANENT_TOKEN: + print(" No valid authentication method found.") + print(" Please set BYNDER_PERMANENT_TOKEN") + return + + print(" Using Permanent Token authentication") + config = BynderPermanentTokenConfig( + domain=DOMAIN, + permanent_token=PERMANENT_TOKEN, + ) + + client = BynderClient.build_with_config(config) + data_source = BynderDataSource(client) + print(f" Client initialized successfully (domain: {DOMAIN})") + + # 2. Get Media + print_section("Media Assets") + media_resp = data_source.get_media_list(limit=10) + print_result("Get Media", media_resp) + + media_id = None + if media_resp.success and media_resp.data: + data = media_resp.data + items = data if isinstance(data, list) else [] + if items: + media_id = str(items[0].get("id")) if isinstance(items[0], dict) else None + if media_id: + print(f" Using Media ID: {media_id}") + + if media_id: + print_section("Media Details") + media_detail_resp = data_source.get_media(media_id) + print_result("Get Media Detail", media_detail_resp) + + print_section("Media Download URL") + download_resp = data_source.get_media_download_url(media_id) + print_result("Get Download URL", download_resp) + + # 3. Get Collections + print_section("Collections") + collections_resp = data_source.get_collections(limit=10) + print_result("Get Collections", collections_resp) + + # 4. Get Tags + print_section("Tags") + tags_resp = data_source.get_tags() + print_result("Get Tags", tags_resp) + + # 5. Get Metaproperties + print_section("Metaproperties") + meta_resp = data_source.get_metaproperties() + print_result("Get Metaproperties", meta_resp) + + # 6. Get Brands + print_section("Brands") + brands_resp = data_source.get_brands() + print_result("Get Brands", brands_resp) + + # 7. Get Account Users + print_section("Account Users") + users_resp = data_source.get_account_users() + print_result("Get Account Users", users_resp) + + print("\n" + "=" * 80) + print(" All Bynder API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + main() diff --git a/backend/python/app/sources/external/bynder/run_generator.py b/backend/python/app/sources/external/bynder/run_generator.py new file mode 100644 index 000000000..517070563 --- /dev/null +++ b/backend/python/app/sources/external/bynder/run_generator.py @@ -0,0 +1,25 @@ +# ruff: noqa: T201 +"""Runner script to generate the Bynder DataSource wrapper. + +Execute this script to regenerate bynder.py from the method definitions +in code_generator.py. + +Usage: + python -m app.sources.external.bynder.run_generator +""" + +from app.sources.external.bynder.code_generator import generate_datasource + + +def main() -> None: + """Generate the Bynder DataSource file.""" + code = generate_datasource() + output_path = __file__.replace("run_generator.py", "bynder.py") + with open(output_path, "w") as f: + f.write(code) + print(f"Generated Bynder DataSource -> {output_path}") + print(f" Total characters: {len(code)}") + + +if __name__ == "__main__": + main() diff --git a/backend/python/app/sources/external/canva/canva.py b/backend/python/app/sources/external/canva/canva.py new file mode 100644 index 000000000..e514c265b --- /dev/null +++ b/backend/python/app/sources/external/canva/canva.py @@ -0,0 +1,692 @@ +""" +Canva Connect REST API DataSource - Auto-generated API wrapper + +Generated from Canva Connect REST API v1 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.canva.canva import CanvaClient, CanvaResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class CanvaDataSource: + """Canva Connect REST API DataSource + + Provides async wrapper methods for Canva Connect REST API operations: + - User profile + - Designs (list, get, create) + - Folders (list, get, create, items) + - Brand templates (list, get) + - Assets (list, upload) + - Comments (list, create) + - Exports (create, get status) + + The base URL is determined by the CanvaClient's configured base URL. + All methods return CanvaResponse objects. + """ + + def __init__(self, client: CanvaClient) -> None: + """Initialize with CanvaClient. + + Args: + client: CanvaClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'CanvaDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> CanvaClient: + """Return the underlying CanvaClient.""" + return self._client + + async def get_current_user( + self + ) -> CanvaResponse: + """Get the profile of the currently authenticated user + + Returns: + CanvaResponse with operation result + """ + url = self.base_url + "/users/me" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CanvaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_current_user" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CanvaResponse(success=False, error=str(e), message="Failed to execute get_current_user") + + async def list_designs( + self, + ownership: str | None = None, + sort_by: str | None = None, + limit: int | None = None, + continuation: str | None = None + ) -> CanvaResponse: + """List designs accessible by the authenticated user + + Args: + ownership: Filter by ownership (owned, shared, any) + sort_by: Sort field (relevance, modified_descending, modified_ascending, title_descending, title_ascending) + limit: Maximum number of results to return + continuation: Continuation token for pagination + + Returns: + CanvaResponse with operation result + """ + query_params: dict[str, Any] = {} + if ownership is not None: + query_params['ownership'] = ownership + if sort_by is not None: + query_params['sort_by'] = sort_by + if limit is not None: + query_params['limit'] = str(limit) + if continuation is not None: + query_params['continuation'] = continuation + + url = self.base_url + "/designs" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CanvaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_designs" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CanvaResponse(success=False, error=str(e), message="Failed to execute list_designs") + + async def get_design( + self, + design_id: str + ) -> CanvaResponse: + """Get metadata for a specific design + + Args: + design_id: The design ID + + Returns: + CanvaResponse with operation result + """ + url = self.base_url + "/designs/{design_id}".format(design_id=design_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CanvaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_design" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CanvaResponse(success=False, error=str(e), message="Failed to execute get_design") + + async def create_design( + self, + design_type: str | None = None, + title: str | None = None, + width: int | None = None, + height: int | None = None, + asset_id: str | None = None + ) -> CanvaResponse: + """Create a new Canva design + + Args: + design_type: Type of design to create + title: Title for the new design + width: Width of the design in pixels + height: Height of the design in pixels + asset_id: Asset ID to use as design content + + Returns: + CanvaResponse with operation result + """ + url = self.base_url + "/designs" + + body: dict[str, Any] = {} + if design_type is not None: + body['design_type'] = design_type + if title is not None: + body['title'] = title + if width is not None: + body['width'] = width + if height is not None: + body['height'] = height + if asset_id is not None: + body['asset_id'] = asset_id + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CanvaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed create_design" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CanvaResponse(success=False, error=str(e), message="Failed to execute create_design") + + async def list_folders( + self, + sort_by: str | None = None, + limit: int | None = None, + continuation: str | None = None + ) -> CanvaResponse: + """List folders accessible by the authenticated user + + Args: + sort_by: Sort field (relevance, modified_descending, modified_ascending, title_descending, title_ascending) + limit: Maximum number of results to return + continuation: Continuation token for pagination + + Returns: + CanvaResponse with operation result + """ + query_params: dict[str, Any] = {} + if sort_by is not None: + query_params['sort_by'] = sort_by + if limit is not None: + query_params['limit'] = str(limit) + if continuation is not None: + query_params['continuation'] = continuation + + url = self.base_url + "/folders" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CanvaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_folders" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CanvaResponse(success=False, error=str(e), message="Failed to execute list_folders") + + async def get_folder( + self, + folder_id: str + ) -> CanvaResponse: + """Get metadata for a specific folder + + Args: + folder_id: The folder ID + + Returns: + CanvaResponse with operation result + """ + url = self.base_url + "/folders/{folder_id}".format(folder_id=folder_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CanvaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_folder" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CanvaResponse(success=False, error=str(e), message="Failed to execute get_folder") + + async def list_folder_items( + self, + folder_id: str, + item_types: str | None = None, + sort_by: str | None = None, + limit: int | None = None, + continuation: str | None = None + ) -> CanvaResponse: + """List items within a specific folder + + Args: + folder_id: The folder ID + item_types: Filter by item type (design, folder, image) + sort_by: Sort field (relevance, modified_descending, modified_ascending, title_descending, title_ascending) + limit: Maximum number of results to return + continuation: Continuation token for pagination + + Returns: + CanvaResponse with operation result + """ + query_params: dict[str, Any] = {} + if item_types is not None: + query_params['item_types'] = item_types + if sort_by is not None: + query_params['sort_by'] = sort_by + if limit is not None: + query_params['limit'] = str(limit) + if continuation is not None: + query_params['continuation'] = continuation + + url = self.base_url + "/folders/{folder_id}/items".format(folder_id=folder_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CanvaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_folder_items" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CanvaResponse(success=False, error=str(e), message="Failed to execute list_folder_items") + + async def create_folder( + self, + name: str, + parent_folder_id: str | None = None + ) -> CanvaResponse: + """Create a new folder + + Args: + name: Name of the folder + parent_folder_id: ID of the parent folder + + Returns: + CanvaResponse with operation result + """ + url = self.base_url + "/folders" + + body: dict[str, Any] = {} + body['name'] = name + if parent_folder_id is not None: + body['parent_folder_id'] = parent_folder_id + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CanvaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed create_folder" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CanvaResponse(success=False, error=str(e), message="Failed to execute create_folder") + + async def list_brand_templates( + self, + dataset: str | None = None, + ownership: str | None = None, + sort_by: str | None = None, + limit: int | None = None, + continuation: str | None = None + ) -> CanvaResponse: + """List brand templates accessible by the authenticated user + + Args: + dataset: Filter by dataset + ownership: Filter by ownership (owned, shared, any) + sort_by: Sort field + limit: Maximum number of results to return + continuation: Continuation token for pagination + + Returns: + CanvaResponse with operation result + """ + query_params: dict[str, Any] = {} + if dataset is not None: + query_params['dataset'] = dataset + if ownership is not None: + query_params['ownership'] = ownership + if sort_by is not None: + query_params['sort_by'] = sort_by + if limit is not None: + query_params['limit'] = str(limit) + if continuation is not None: + query_params['continuation'] = continuation + + url = self.base_url + "/brand-templates" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CanvaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_brand_templates" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CanvaResponse(success=False, error=str(e), message="Failed to execute list_brand_templates") + + async def get_brand_template( + self, + brand_template_id: str + ) -> CanvaResponse: + """Get metadata for a specific brand template + + Args: + brand_template_id: The brand template ID + + Returns: + CanvaResponse with operation result + """ + url = self.base_url + "/brand-templates/{brand_template_id}".format(brand_template_id=brand_template_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CanvaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_brand_template" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CanvaResponse(success=False, error=str(e), message="Failed to execute get_brand_template") + + async def list_assets( + self, + sort_by: str | None = None, + limit: int | None = None, + continuation: str | None = None + ) -> CanvaResponse: + """List assets accessible by the authenticated user + + Args: + sort_by: Sort field + limit: Maximum number of results to return + continuation: Continuation token for pagination + + Returns: + CanvaResponse with operation result + """ + query_params: dict[str, Any] = {} + if sort_by is not None: + query_params['sort_by'] = sort_by + if limit is not None: + query_params['limit'] = str(limit) + if continuation is not None: + query_params['continuation'] = continuation + + url = self.base_url + "/assets" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CanvaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_assets" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CanvaResponse(success=False, error=str(e), message="Failed to execute list_assets") + + async def upload_asset( + self, + name: str, + folder_id: str | None = None + ) -> CanvaResponse: + """Upload an asset to Canva (multipart upload) + + Args: + name: Name of the asset + folder_id: Target folder ID for the asset + + Returns: + CanvaResponse with operation result + """ + url = self.base_url + "/assets/upload" + + body: dict[str, Any] = {} + body['name'] = name + if folder_id is not None: + body['folder_id'] = folder_id + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CanvaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed upload_asset" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CanvaResponse(success=False, error=str(e), message="Failed to execute upload_asset") + + async def list_design_comments( + self, + design_id: str, + limit: int | None = None, + continuation: str | None = None + ) -> CanvaResponse: + """List comments on a specific design + + Args: + design_id: The design ID + limit: Maximum number of results to return + continuation: Continuation token for pagination + + Returns: + CanvaResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if continuation is not None: + query_params['continuation'] = continuation + + url = self.base_url + "/comments/{design_id}".format(design_id=design_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CanvaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_design_comments" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CanvaResponse(success=False, error=str(e), message="Failed to execute list_design_comments") + + async def create_design_comment( + self, + design_id: str, + message: str + ) -> CanvaResponse: + """Create a comment on a specific design + + Args: + design_id: The design ID + message: The comment message text + + Returns: + CanvaResponse with operation result + """ + url = self.base_url + "/comments/{design_id}".format(design_id=design_id) + + body: dict[str, Any] = {} + body['message'] = message + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CanvaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed create_design_comment" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CanvaResponse(success=False, error=str(e), message="Failed to execute create_design_comment") + + async def create_export( + self, + design_id: str, + export_format: str | None = None, + quality: str | None = None, + pages: list[int] | None = None, + width: int | None = None, + height: int | None = None + ) -> CanvaResponse: + """Create an export job to export a design + + Args: + design_id: The design ID to export + export_format: Export format (pdf, jpg, png, gif, pptx, mp4) + quality: Export quality (regular, pro) + pages: List of page indices to export + width: Target width in pixels + height: Target height in pixels + + Returns: + CanvaResponse with operation result + """ + url = self.base_url + "/exports" + + body: dict[str, Any] = {} + body['design_id'] = design_id + if export_format is not None: + body['format'] = export_format + if quality is not None: + body['quality'] = quality + if pages is not None: + body['pages'] = pages + if width is not None: + body['width'] = width + if height is not None: + body['height'] = height + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CanvaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed create_export" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CanvaResponse(success=False, error=str(e), message="Failed to execute create_export") + + async def get_export( + self, + export_id: str + ) -> CanvaResponse: + """Get the status and result of an export job + + Args: + export_id: The export job ID + + Returns: + CanvaResponse with operation result + """ + url = self.base_url + "/exports/{export_id}".format(export_id=export_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CanvaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_export" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CanvaResponse(success=False, error=str(e), message="Failed to execute get_export") diff --git a/backend/python/app/sources/external/canva/example.py b/backend/python/app/sources/external/canva/example.py new file mode 100644 index 000000000..b5564bcc3 --- /dev/null +++ b/backend/python/app/sources/external/canva/example.py @@ -0,0 +1,204 @@ +# ruff: noqa + +""" +Canva Connect API Usage Examples + +This example demonstrates how to use the Canva DataSource to interact with +the Canva Connect API (v1), covering: +- Authentication (OAuth2 with PKCE, Access Token) +- Initializing the Client and DataSource +- Fetching User Profile +- Listing Designs, Folders, and Brand Templates + +Prerequisites: +For OAuth2 (PKCE): +1. Create a Canva integration at https://www.canva.com/developers/ +2. Set CANVA_CLIENT_ID environment variable +3. The OAuth flow will automatically open a browser for authorization + (Canva uses PKCE - no client_secret required) + +For Access Token: +1. Generate an access token from the Canva developer portal +2. Set CANVA_ACCESS_TOKEN environment variable + +OAuth Scopes: +- design:content:read - Read design content +- design:meta:read - Read design metadata +- folder:read - Read folder information +- profile:read - Read user profile +""" + +import asyncio +import json +import os + +from app.sources.client.canva.canva import ( + CanvaClient, + CanvaOAuthConfig, + CanvaResponse, + CanvaTokenConfig, +) +from app.sources.external.canva.canva import CanvaDataSource +from app.sources.external.utils.oauth import perform_oauth_flow + +# --- Configuration --- +# OAuth2 credentials (highest priority) +CLIENT_ID = os.getenv("CANVA_CLIENT_ID") +CLIENT_SECRET = os.getenv("CANVA_CLIENT_SECRET") + +# Pre-generated access token (second priority) +ACCESS_TOKEN = os.getenv("CANVA_ACCESS_TOKEN") + +# OAuth redirect URI +REDIRECT_URI = os.getenv("CANVA_REDIRECT_URI", "http://localhost:8080/callback") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: CanvaResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + # Handle list-type responses (designs, folders, items, templates, assets, comments) + for key in ("designs", "folders", "items", "brand_templates", "assets", + "comments", "exports"): + if isinstance(data, dict) and key in data: + items = data[key] + print(f" Found {len(items)} {key}.") + if items: + print(f" Sample: {json.dumps(items[0], indent=2)[:400]}...") + return + # Generic response + print(f" Data: {json.dumps(data, indent=2)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + # 1. Initialize Client + print_section("Initializing Canva Client") + + config = None + + # Priority 1: OAuth2 (PKCE) + if CLIENT_ID: + print(" Using OAuth2 authentication (PKCE)") + try: + print("Starting OAuth flow...") + # Canva OAuth uses PKCE (no client_secret needed) + # auth_method="body" sends client_id in POST body + token_response = perform_oauth_flow( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + auth_endpoint="https://www.canva.com/api/oauth/authorize", + token_endpoint="https://api.canva.com/rest/v1/oauth/token", + redirect_uri=REDIRECT_URI, + scopes=[ + "design:content:read", + "design:meta:read", + "folder:read", + "profile:read", + ], + scope_delimiter=" ", + auth_method="body", + ) + + access_token = token_response.get("access_token") + if not access_token: + raise Exception("No access_token found in OAuth response") + + config = CanvaOAuthConfig( + access_token=access_token, + client_id=CLIENT_ID, + ) + print(" OAuth authentication successful") + except Exception as e: + print(f" OAuth flow failed: {e}") + print(" Falling back to other authentication methods...") + + # Priority 2: Pre-generated Access Token + if config is None and ACCESS_TOKEN: + print(" Using pre-generated access token") + config = CanvaTokenConfig( + token=ACCESS_TOKEN, + ) + + if config is None: + print(" No valid authentication method found.") + print(" Please set one of the following:") + print(" - CANVA_CLIENT_ID (for OAuth2 with PKCE)") + print(" - CANVA_ACCESS_TOKEN (for pre-generated token)") + return + + client = CanvaClient.build_with_config(config) + data_source = CanvaDataSource(client) + print("Client initialized successfully.") + + try: + # 2. Get Current User + print_section("Current User Profile") + user_resp = await data_source.get_current_user() + print_result("Get Current User", user_resp) + + # 3. List Designs + print_section("Designs") + designs_resp = await data_source.list_designs(limit=5) + print_result("List Designs", designs_resp) + + # Get a specific design if available + if designs_resp.success and designs_resp.data: + items = designs_resp.data.get("items", []) + if items: + design_id = str(items[0].get("id")) + print_section(f"Design Details: {items[0].get('title', 'Untitled')}") + design_resp = await data_source.get_design(design_id=design_id) + print_result("Get Design", design_resp) + + # 4. List Folders + print_section("Folders") + folders_resp = await data_source.list_folders(limit=5) + print_result("List Folders", folders_resp) + + # Get folder items if available + if folders_resp.success and folders_resp.data: + items = folders_resp.data.get("items", []) + if items: + folder_id = str(items[0].get("id")) + print_section(f"Folder Items: {items[0].get('name', 'Unnamed')}") + folder_items_resp = await data_source.list_folder_items( + folder_id=folder_id, limit=5 + ) + print_result("List Folder Items", folder_items_resp) + + # 5. List Brand Templates + print_section("Brand Templates") + templates_resp = await data_source.list_brand_templates(limit=5) + print_result("List Brand Templates", templates_resp) + + # 6. List Assets + print_section("Assets") + assets_resp = await data_source.list_assets(limit=5) + print_result("List Assets", assets_resp) + + finally: + # Cleanup: Close the HTTP client session + print("\nClosing client connection...") + inner_client = client.get_client() + if hasattr(inner_client, "close"): + await inner_client.close() + + print("\n" + "=" * 80) + print(" All Canva API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/coda/coda.py b/backend/python/app/sources/external/coda/coda.py new file mode 100644 index 000000000..0c0b75004 --- /dev/null +++ b/backend/python/app/sources/external/coda/coda.py @@ -0,0 +1,982 @@ +""" +Coda REST API DataSource - Auto-generated API wrapper + +Generated from Coda REST API v1 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.coda.coda import CodaClient, CodaResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class CodaDataSource: + """Coda REST API DataSource + + Provides async wrapper methods for Coda REST API operations: + - User / Account information + - Doc CRUD and management + - Table and Row operations + - Column management + - Page operations + - Formula and Control access + - Permission management + - Category listing + + The base URL is determined by the CodaClient's configured base URL + (default: https://coda.io/apis/v1). + + All methods return CodaResponse objects. + """ + + def __init__(self, client: CodaClient) -> None: + """Initialize with CodaClient. + + Args: + client: CodaClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'CodaDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> CodaClient: + """Return the underlying CodaClient.""" + return self._client + + async def whoami( + self + ) -> CodaResponse: + """Get information about the current user + + Returns: + CodaResponse with operation result + """ + url = self.base_url + "/whoami" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CodaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed whoami" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CodaResponse(success=False, error=str(e), message="Failed to execute whoami") + + async def list_docs( + self, + *, + is_owner: bool | None = None, + query: str | None = None, + source_doc: str | None = None, + is_starred: bool | None = None, + in_gallery: bool | None = None, + workspace_id: str | None = None, + folder_id: str | None = None, + limit: int | None = None, + page_token: str | None = None + ) -> CodaResponse: + """List available Coda docs + + Args: + is_owner: Show only docs owned by the user + query: Search term to filter docs + source_doc: Show only docs copied from the specified source doc + is_starred: Show only starred docs + in_gallery: Show only docs in the gallery + workspace_id: Show only docs in the given workspace + folder_id: Show only docs in the given folder + limit: Maximum number of results to return + page_token: An opaque token for pagination + + Returns: + CodaResponse with operation result + """ + query_params: dict[str, Any] = {} + if is_owner is not None: + query_params['is_owner'] = str(is_owner).lower() + if query is not None: + query_params['query'] = query + if source_doc is not None: + query_params['source_doc'] = source_doc + if is_starred is not None: + query_params['is_starred'] = str(is_starred).lower() + if in_gallery is not None: + query_params['in_gallery'] = str(in_gallery).lower() + if workspace_id is not None: + query_params['workspace_id'] = workspace_id + if folder_id is not None: + query_params['folder_id'] = folder_id + if limit is not None: + query_params['limit'] = str(limit) + if page_token is not None: + query_params['page_token'] = page_token + + url = self.base_url + "/docs" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CodaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_docs" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CodaResponse(success=False, error=str(e), message="Failed to execute list_docs") + + async def get_doc( + self, + doc_id: str + ) -> CodaResponse: + """Get info about a specific doc + + Args: + doc_id: The ID of the doc + + Returns: + CodaResponse with operation result + """ + url = self.base_url + "/docs/{doc_id}".format(doc_id=doc_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CodaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_doc" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CodaResponse(success=False, error=str(e), message="Failed to execute get_doc") + + async def create_doc( + self, + title: str | None = None, + source_doc: str | None = None, + timezone: str | None = None, + folder_id: str | None = None + ) -> CodaResponse: + """Create a new Coda doc + + Args: + title: Title of the new doc + source_doc: ID of a doc to copy + timezone: Timezone for the doc + folder_id: ID of the folder to create the doc in + + Returns: + CodaResponse with operation result + """ + url = self.base_url + "/docs" + + body: dict[str, Any] = {} + if title is not None: + body['title'] = title + if source_doc is not None: + body['source_doc'] = source_doc + if timezone is not None: + body['timezone'] = timezone + if folder_id is not None: + body['folder_id'] = folder_id + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CodaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed create_doc" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CodaResponse(success=False, error=str(e), message="Failed to execute create_doc") + + async def delete_doc( + self, + doc_id: str + ) -> CodaResponse: + """Delete a doc + + Args: + doc_id: The ID of the doc to delete + + Returns: + CodaResponse with operation result + """ + url = self.base_url + "/docs/{doc_id}".format(doc_id=doc_id) + + try: + request = HTTPRequest( + method="DELETE", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CodaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed delete_doc" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CodaResponse(success=False, error=str(e), message="Failed to execute delete_doc") + + async def list_tables( + self, + doc_id: str, + limit: int | None = None, + page_token: str | None = None, + sort_by: str | None = None, + table_types: str | None = None + ) -> CodaResponse: + """List tables in a doc + + Args: + doc_id: The ID of the doc + limit: Maximum number of results to return + page_token: An opaque token for pagination + sort_by: Sort order of the results + table_types: Comma-separated list of table types to include + + Returns: + CodaResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if page_token is not None: + query_params['page_token'] = page_token + if sort_by is not None: + query_params['sort_by'] = sort_by + if table_types is not None: + query_params['table_types'] = table_types + + url = self.base_url + "/docs/{doc_id}/tables".format(doc_id=doc_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CodaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_tables" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CodaResponse(success=False, error=str(e), message="Failed to execute list_tables") + + async def get_table( + self, + doc_id: str, + table_id_or_name: str + ) -> CodaResponse: + """Get info about a specific table + + Args: + doc_id: The ID of the doc + table_id_or_name: The ID or name of the table + + Returns: + CodaResponse with operation result + """ + url = self.base_url + "/docs/{doc_id}/tables/{table_id_or_name}".format(doc_id=doc_id, table_id_or_name=table_id_or_name) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CodaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_table" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CodaResponse(success=False, error=str(e), message="Failed to execute get_table") + + async def list_rows( + self, + doc_id: str, + table_id_or_name: str, + *, + limit: int | None = None, + page_token: str | None = None, + query: str | None = None, + sort_by: str | None = None, + use_column_names: bool | None = None, + value_format: str | None = None, + visible_only: bool | None = None + ) -> CodaResponse: + """List rows in a table + + Args: + doc_id: The ID of the doc + table_id_or_name: The ID or name of the table + limit: Maximum number of results to return + page_token: An opaque token for pagination + query: Search query to filter rows + sort_by: Sort order of the results + use_column_names: Use column names instead of column IDs in the response + value_format: Format of cell values (simple, simpleWithArrays, rich) + visible_only: Show only visible rows + + Returns: + CodaResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if page_token is not None: + query_params['page_token'] = page_token + if query is not None: + query_params['query'] = query + if sort_by is not None: + query_params['sort_by'] = sort_by + if use_column_names is not None: + query_params['use_column_names'] = str(use_column_names).lower() + if value_format is not None: + query_params['value_format'] = value_format + if visible_only is not None: + query_params['visible_only'] = str(visible_only).lower() + + url = self.base_url + "/docs/{doc_id}/tables/{table_id_or_name}/rows".format(doc_id=doc_id, table_id_or_name=table_id_or_name) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CodaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_rows" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CodaResponse(success=False, error=str(e), message="Failed to execute list_rows") + + async def get_row( + self, + doc_id: str, + table_id_or_name: str, + row_id_or_name: str, + *, + use_column_names: bool | None = None, + value_format: str | None = None + ) -> CodaResponse: + """Get a specific row in a table + + Args: + doc_id: The ID of the doc + table_id_or_name: The ID or name of the table + row_id_or_name: The ID or name of the row + use_column_names: Use column names instead of column IDs + value_format: Format of cell values + + Returns: + CodaResponse with operation result + """ + query_params: dict[str, Any] = {} + if use_column_names is not None: + query_params['use_column_names'] = str(use_column_names).lower() + if value_format is not None: + query_params['value_format'] = value_format + + url = self.base_url + "/docs/{doc_id}/tables/{table_id_or_name}/rows/{row_id_or_name}".format(doc_id=doc_id, table_id_or_name=table_id_or_name, row_id_or_name=row_id_or_name) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CodaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_row" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CodaResponse(success=False, error=str(e), message="Failed to execute get_row") + + async def insert_rows( + self, + doc_id: str, + table_id_or_name: str, + rows: list[dict[str, Any]], + key_columns: list[str] | None = None + ) -> CodaResponse: + """Insert or upsert rows in a table + + Args: + doc_id: The ID of the doc + table_id_or_name: The ID or name of the table + rows: Array of row objects to insert + key_columns: Optional column IDs for upsert key matching + + Returns: + CodaResponse with operation result + """ + url = self.base_url + "/docs/{doc_id}/tables/{table_id_or_name}/rows".format(doc_id=doc_id, table_id_or_name=table_id_or_name) + + body: dict[str, Any] = {} + body['rows'] = rows + if key_columns is not None: + body['key_columns'] = key_columns + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CodaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed insert_rows" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CodaResponse(success=False, error=str(e), message="Failed to execute insert_rows") + + async def update_row( + self, + doc_id: str, + table_id_or_name: str, + row_id_or_name: str, + row: dict[str, Any] + ) -> CodaResponse: + """Update a specific row in a table + + Args: + doc_id: The ID of the doc + table_id_or_name: The ID or name of the table + row_id_or_name: The ID or name of the row + row: Row object with cells to update + + Returns: + CodaResponse with operation result + """ + url = self.base_url + "/docs/{doc_id}/tables/{table_id_or_name}/rows/{row_id_or_name}".format(doc_id=doc_id, table_id_or_name=table_id_or_name, row_id_or_name=row_id_or_name) + + body: dict[str, Any] = {} + body['row'] = row + + try: + request = HTTPRequest( + method="PUT", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CodaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed update_row" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CodaResponse(success=False, error=str(e), message="Failed to execute update_row") + + async def delete_row( + self, + doc_id: str, + table_id_or_name: str, + row_id_or_name: str + ) -> CodaResponse: + """Delete a specific row from a table + + Args: + doc_id: The ID of the doc + table_id_or_name: The ID or name of the table + row_id_or_name: The ID or name of the row to delete + + Returns: + CodaResponse with operation result + """ + url = self.base_url + "/docs/{doc_id}/tables/{table_id_or_name}/rows/{row_id_or_name}".format(doc_id=doc_id, table_id_or_name=table_id_or_name, row_id_or_name=row_id_or_name) + + try: + request = HTTPRequest( + method="DELETE", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CodaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed delete_row" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CodaResponse(success=False, error=str(e), message="Failed to execute delete_row") + + async def list_columns( + self, + doc_id: str, + table_id_or_name: str, + *, + limit: int | None = None, + page_token: str | None = None, + visible_only: bool | None = None + ) -> CodaResponse: + """List columns in a table + + Args: + doc_id: The ID of the doc + table_id_or_name: The ID or name of the table + limit: Maximum number of results to return + page_token: An opaque token for pagination + visible_only: Show only visible columns + + Returns: + CodaResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if page_token is not None: + query_params['page_token'] = page_token + if visible_only is not None: + query_params['visible_only'] = str(visible_only).lower() + + url = self.base_url + "/docs/{doc_id}/tables/{table_id_or_name}/columns".format(doc_id=doc_id, table_id_or_name=table_id_or_name) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CodaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_columns" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CodaResponse(success=False, error=str(e), message="Failed to execute list_columns") + + async def get_column( + self, + doc_id: str, + table_id_or_name: str, + column_id_or_name: str + ) -> CodaResponse: + """Get info about a specific column + + Args: + doc_id: The ID of the doc + table_id_or_name: The ID or name of the table + column_id_or_name: The ID or name of the column + + Returns: + CodaResponse with operation result + """ + url = self.base_url + "/docs/{doc_id}/tables/{table_id_or_name}/columns/{column_id_or_name}".format(doc_id=doc_id, table_id_or_name=table_id_or_name, column_id_or_name=column_id_or_name) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CodaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_column" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CodaResponse(success=False, error=str(e), message="Failed to execute get_column") + + async def list_pages( + self, + doc_id: str, + limit: int | None = None, + page_token: str | None = None + ) -> CodaResponse: + """List pages in a doc + + Args: + doc_id: The ID of the doc + limit: Maximum number of results to return + page_token: An opaque token for pagination + + Returns: + CodaResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if page_token is not None: + query_params['page_token'] = page_token + + url = self.base_url + "/docs/{doc_id}/pages".format(doc_id=doc_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CodaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_pages" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CodaResponse(success=False, error=str(e), message="Failed to execute list_pages") + + async def get_page( + self, + doc_id: str, + page_id_or_name: str + ) -> CodaResponse: + """Get info about a specific page + + Args: + doc_id: The ID of the doc + page_id_or_name: The ID or name of the page + + Returns: + CodaResponse with operation result + """ + url = self.base_url + "/docs/{doc_id}/pages/{page_id_or_name}".format(doc_id=doc_id, page_id_or_name=page_id_or_name) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CodaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_page" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CodaResponse(success=False, error=str(e), message="Failed to execute get_page") + + async def update_page( + self, + doc_id: str, + page_id_or_name: str, + name: str | None = None, + subtitle: str | None = None, + icon_name: str | None = None, + image_url: str | None = None + ) -> CodaResponse: + """Update a page in a doc + + Args: + doc_id: The ID of the doc + page_id_or_name: The ID or name of the page + name: New name for the page + subtitle: New subtitle for the page + icon_name: Name of the icon for the page + image_url: URL of the cover image for the page + + Returns: + CodaResponse with operation result + """ + url = self.base_url + "/docs/{doc_id}/pages/{page_id_or_name}".format(doc_id=doc_id, page_id_or_name=page_id_or_name) + + body: dict[str, Any] = {} + if name is not None: + body['name'] = name + if subtitle is not None: + body['subtitle'] = subtitle + if icon_name is not None: + body['icon_name'] = icon_name + if image_url is not None: + body['image_url'] = image_url + + try: + request = HTTPRequest( + method="PUT", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CodaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed update_page" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CodaResponse(success=False, error=str(e), message="Failed to execute update_page") + + async def list_formulas( + self, + doc_id: str, + limit: int | None = None, + page_token: str | None = None, + sort_by: str | None = None + ) -> CodaResponse: + """List named formulas in a doc + + Args: + doc_id: The ID of the doc + limit: Maximum number of results to return + page_token: An opaque token for pagination + sort_by: Sort order of the results + + Returns: + CodaResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if page_token is not None: + query_params['page_token'] = page_token + if sort_by is not None: + query_params['sort_by'] = sort_by + + url = self.base_url + "/docs/{doc_id}/formulas".format(doc_id=doc_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CodaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_formulas" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CodaResponse(success=False, error=str(e), message="Failed to execute list_formulas") + + async def get_formula( + self, + doc_id: str, + formula_id_or_name: str + ) -> CodaResponse: + """Get info about a specific formula + + Args: + doc_id: The ID of the doc + formula_id_or_name: The ID or name of the formula + + Returns: + CodaResponse with operation result + """ + url = self.base_url + "/docs/{doc_id}/formulas/{formula_id_or_name}".format(doc_id=doc_id, formula_id_or_name=formula_id_or_name) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CodaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_formula" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CodaResponse(success=False, error=str(e), message="Failed to execute get_formula") + + async def list_controls( + self, + doc_id: str, + limit: int | None = None, + page_token: str | None = None, + sort_by: str | None = None + ) -> CodaResponse: + """List controls in a doc + + Args: + doc_id: The ID of the doc + limit: Maximum number of results to return + page_token: An opaque token for pagination + sort_by: Sort order of the results + + Returns: + CodaResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if page_token is not None: + query_params['page_token'] = page_token + if sort_by is not None: + query_params['sort_by'] = sort_by + + url = self.base_url + "/docs/{doc_id}/controls".format(doc_id=doc_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CodaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_controls" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CodaResponse(success=False, error=str(e), message="Failed to execute list_controls") + + async def get_control( + self, + doc_id: str, + control_id_or_name: str + ) -> CodaResponse: + """Get info about a specific control + + Args: + doc_id: The ID of the doc + control_id_or_name: The ID or name of the control + + Returns: + CodaResponse with operation result + """ + url = self.base_url + "/docs/{doc_id}/controls/{control_id_or_name}".format(doc_id=doc_id, control_id_or_name=control_id_or_name) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CodaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_control" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CodaResponse(success=False, error=str(e), message="Failed to execute get_control") + + async def list_permissions( + self, + doc_id: str + ) -> CodaResponse: + """List permissions for a doc + + Args: + doc_id: The ID of the doc + + Returns: + CodaResponse with operation result + """ + url = self.base_url + "/docs/{doc_id}/acl/permissions".format(doc_id=doc_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CodaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_permissions" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CodaResponse(success=False, error=str(e), message="Failed to execute list_permissions") + + async def list_categories( + self + ) -> CodaResponse: + """List available doc categories + + Returns: + CodaResponse with operation result + """ + url = self.base_url + "/categories" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CodaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_categories" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CodaResponse(success=False, error=str(e), message="Failed to execute list_categories") diff --git a/backend/python/app/sources/external/coda/example.py b/backend/python/app/sources/external/coda/example.py new file mode 100644 index 000000000..5cad406e6 --- /dev/null +++ b/backend/python/app/sources/external/coda/example.py @@ -0,0 +1,199 @@ +# ruff: noqa + +""" +Coda API Usage Examples + +This example demonstrates how to use the Coda DataSource to interact with +the Coda API, covering: +- Authentication (OAuth2, API Token) +- Initializing the Client and DataSource +- Getting current user info (whoami) +- Listing docs +- Listing categories + +Prerequisites: +For OAuth2: +1. Create a Coda OAuth app at https://coda.io/account +2. Set CODA_CLIENT_ID and CODA_CLIENT_SECRET environment variables +3. The OAuth flow will automatically open a browser for authorization + +For API Token: +1. Log in to Coda +2. Go to https://coda.io/account and generate an API token +3. Set CODA_API_TOKEN environment variable +""" + +import asyncio +import json +import os + +from app.sources.client.coda.coda import ( + CodaClient, + CodaOAuthConfig, + CodaTokenConfig, + CodaResponse, +) +from app.sources.external.coda.coda import CodaDataSource +from app.sources.external.utils.oauth import perform_oauth_flow + +# --- Configuration --- +# OAuth2 credentials (highest priority) +CLIENT_ID = os.getenv("CODA_CLIENT_ID") +CLIENT_SECRET = os.getenv("CODA_CLIENT_SECRET") + +# API Token (second priority) +API_TOKEN = os.getenv("CODA_API_TOKEN") + +# OAuth redirect URI +REDIRECT_URI = os.getenv("CODA_REDIRECT_URI", "http://localhost:8080/callback") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: CodaResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + # Handle list-type responses (items, docs, etc.) + for key in ("items", "docs", "tables", "rows", "columns", + "pages", "formulas", "controls", "permissions"): + if isinstance(data, dict) and key in data: + items = data[key] + print(f" Found {len(items)} {key}.") + if items: + print(f" Sample: {json.dumps(items[0], indent=2)[:400]}...") + return + # Generic response + print(f" Data: {json.dumps(data, indent=2)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + # 1. Initialize Client + print_section("Initializing Coda Client") + + config = None + + # Priority 1: OAuth2 + if CLIENT_ID and CLIENT_SECRET: + print(" Using OAuth2 authentication") + try: + print("Starting OAuth flow...") + # Coda OAuth authorization URL: https://coda.io/oauth2/authorize + # Coda token endpoint: https://coda.io/oauth2/token + token_response = perform_oauth_flow( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + auth_endpoint="https://coda.io/oauth2/authorize", + token_endpoint="https://coda.io/oauth2/token", + redirect_uri=REDIRECT_URI, + scopes=[], + scope_delimiter=" ", + auth_method="header", # Basic Auth with client_id:client_secret + ) + + access_token = token_response.get("access_token") + if not access_token: + raise Exception("No access_token found in OAuth response") + + config = CodaOAuthConfig( + access_token=access_token, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + ) + print(" OAuth authentication successful") + except Exception as e: + print(f" OAuth flow failed: {e}") + print(" Falling back to other authentication methods...") + + # Priority 2: API Token + if config is None and API_TOKEN: + print(" Using API Token authentication") + config = CodaTokenConfig( + token=API_TOKEN, + ) + + if config is None: + print(" No valid authentication method found.") + print(" Please set one of the following:") + print(" - CODA_CLIENT_ID and CODA_CLIENT_SECRET (for OAuth2)") + print(" - CODA_API_TOKEN (for API Token)") + return + + client = CodaClient.build_with_config(config) + data_source = CodaDataSource(client) + print("Client initialized successfully.") + + try: + # 2. Get Current User (whoami) + print_section("Current User (whoami)") + whoami_resp = await data_source.whoami() + print_result("Whoami", whoami_resp) + + # 3. List Docs + print_section("Docs") + docs_resp = await data_source.list_docs() + print_result("List Docs", docs_resp) + + # Extract first doc_id for further exploration + doc_id = None + if docs_resp.success and docs_resp.data: + items = docs_resp.data.get("items", []) + if items: + doc_id = str(items[0].get("id")) + print(f" Using Doc: {items[0].get('name')} (ID: {doc_id})") + + if doc_id: + # 4. Get Doc Details + print_section("Doc Details") + doc_resp = await data_source.get_doc(doc_id=doc_id) + print_result("Get Doc", doc_resp) + + # 5. List Tables in Doc + print_section("Tables") + tables_resp = await data_source.list_tables(doc_id=doc_id) + print_result("List Tables", tables_resp) + + # 6. List Pages in Doc + print_section("Pages") + pages_resp = await data_source.list_pages(doc_id=doc_id) + print_result("List Pages", pages_resp) + + # 7. List Formulas in Doc + print_section("Formulas") + formulas_resp = await data_source.list_formulas(doc_id=doc_id) + print_result("List Formulas", formulas_resp) + + # 8. List Permissions for Doc + print_section("Permissions") + perms_resp = await data_source.list_permissions(doc_id=doc_id) + print_result("List Permissions", perms_resp) + + # 9. List Categories + print_section("Categories") + categories_resp = await data_source.list_categories() + print_result("List Categories", categories_resp) + + finally: + # Cleanup: Close the HTTP client session + print("\nClosing client connection...") + inner_client = client.get_client() + if hasattr(inner_client, "close"): + await inner_client.close() + + print("\n" + "=" * 80) + print(" All Coda API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/coupa/code_generator.py b/backend/python/app/sources/external/coupa/code_generator.py new file mode 100644 index 000000000..5bda4a343 --- /dev/null +++ b/backend/python/app/sources/external/coupa/code_generator.py @@ -0,0 +1,242 @@ +# ruff: noqa +""" +Coupa DataSource Code Generator + +Defines Coupa API endpoint specifications and generates the DataSource +wrapper class (coupa.py) from them. + +Endpoints: + /purchase_orders, /purchase_orders/{id}, /invoices, /invoices/{id}, + /requisitions, /requisitions/{id}, /suppliers, /suppliers/{id}, + /contracts, /contracts/{id}, /users, /users/{id}, + /departments, /departments/{id}, /expense_reports, /expense_reports/{id} + +Note: For OAuth clients, ensure_token() is called if available. +""" + +from __future__ import annotations + +ENDPOINTS = [ + # Purchase Orders + {"method": "GET", "path": "/purchase_orders", "name": "list_purchase_orders", + "section": "Purchase Orders", "doc": "List all purchase orders", + "query_params": [("limit", "int", "Maximum number of results"), ("offset", "int", "Offset for pagination")]}, + {"method": "GET", "path": "/purchase_orders/{order_id}", "name": "get_purchase_order", + "section": "Purchase Orders", "doc": "Get a specific purchase order by ID", "path_params": ["order_id"]}, + # Invoices + {"method": "GET", "path": "/invoices", "name": "list_invoices", + "section": "Invoices", "doc": "List all invoices", + "query_params": [("limit", "int", "Maximum number of results"), ("offset", "int", "Offset for pagination")]}, + {"method": "GET", "path": "/invoices/{invoice_id}", "name": "get_invoice", + "section": "Invoices", "doc": "Get a specific invoice by ID", "path_params": ["invoice_id"]}, + # Requisitions + {"method": "GET", "path": "/requisitions", "name": "list_requisitions", + "section": "Requisitions", "doc": "List all requisitions", + "query_params": [("limit", "int", "Maximum number of results"), ("offset", "int", "Offset for pagination")]}, + {"method": "GET", "path": "/requisitions/{requisition_id}", "name": "get_requisition", + "section": "Requisitions", "doc": "Get a specific requisition by ID", "path_params": ["requisition_id"]}, + # Suppliers + {"method": "GET", "path": "/suppliers", "name": "list_suppliers", + "section": "Suppliers", "doc": "List all suppliers", + "query_params": [("limit", "int", "Maximum number of results"), ("offset", "int", "Offset for pagination")]}, + {"method": "GET", "path": "/suppliers/{supplier_id}", "name": "get_supplier", + "section": "Suppliers", "doc": "Get a specific supplier by ID", "path_params": ["supplier_id"]}, + # Contracts + {"method": "GET", "path": "/contracts", "name": "list_contracts", + "section": "Contracts", "doc": "List all contracts", + "query_params": [("limit", "int", "Maximum number of results"), ("offset", "int", "Offset for pagination")]}, + {"method": "GET", "path": "/contracts/{contract_id}", "name": "get_contract", + "section": "Contracts", "doc": "Get a specific contract by ID", "path_params": ["contract_id"]}, + # Users + {"method": "GET", "path": "/users", "name": "list_users", + "section": "Users", "doc": "List all users", + "query_params": [("limit", "int", "Maximum number of results"), ("offset", "int", "Offset for pagination")]}, + {"method": "GET", "path": "/users/{user_id}", "name": "get_user", + "section": "Users", "doc": "Get a specific user by ID", "path_params": ["user_id"]}, + # Departments + {"method": "GET", "path": "/departments", "name": "list_departments", + "section": "Departments", "doc": "List all departments", + "query_params": [("limit", "int", "Maximum number of results"), ("offset", "int", "Offset for pagination")]}, + {"method": "GET", "path": "/departments/{department_id}", "name": "get_department", + "section": "Departments", "doc": "Get a specific department by ID", "path_params": ["department_id"]}, + # Expense Reports + {"method": "GET", "path": "/expense_reports", "name": "list_expense_reports", + "section": "Expense Reports", "doc": "List all expense reports", + "query_params": [("limit", "int", "Maximum number of results"), ("offset", "int", "Offset for pagination")]}, + {"method": "GET", "path": "/expense_reports/{report_id}", "name": "get_expense_report", + "section": "Expense Reports", "doc": "Get a specific expense report by ID", "path_params": ["report_id"]}, +] + + +def _gen_method(ep: dict) -> str: + """Generate a single async method from an endpoint spec.""" + name = ep["name"] + method = ep["method"] + path = ep["path"] + doc = ep["doc"] + path_params = ep.get("path_params", []) + query_params = ep.get("query_params", []) + body_params = ep.get("body_params", []) + + sig_parts = ["self"] + for p in path_params: + sig_parts.append(f"{p}: str") + for bp in body_params: + sig_parts.append(f"{bp[0]}: {bp[2]}") + if query_params: + sig_parts.append("*") + for qp in query_params: + sig_parts.append(f"{qp[0]}: {qp[1]} | None = None") + + sig = ",\n ".join(sig_parts) + + doc_args = "" + if path_params or query_params or body_params: + doc_args = "\n Args:\n" + for p in path_params: + doc_args += f" {p}: The {p.replace('_', ' ')}\n" + for bp in body_params: + doc_args += f" {bp[0]}: {bp[3]}\n" + for qp in query_params: + doc_args += f" {qp[0]}: {qp[2]}\n" + + query_block = "" + if query_params: + lines = ["\n query_params: dict[str, Any] = {}"] + for qp in query_params: + lines.append(f" if {qp[0]} is not None:") + lines.append(f" query_params['{qp[0]}'] = str({qp[0]})") + query_block = "\n".join(lines) + "\n" + + if path_params: + fmt_args = ", ".join(f"{p}={p}" for p in path_params) + url_line = f' url = self.base_url + "{path}".format({fmt_args})' + else: + url_line = f' url = self.base_url + "{path}"' + + body_block = "" + if body_params: + lines = ["\n body: dict[str, Any] = {}"] + for bp in body_params: + lines.append(f' if {bp[0]} is not None:') + lines.append(f' body["{bp[1]}"] = {bp[0]}') + body_block = "\n".join(lines) + + req_extra = "" + if query_params: + req_extra += "\n query=query_params," + if body_params: + req_extra += "\n body=body," + + return f''' + async def {name}( + {sig} + ) -> CoupaResponse: + """{doc} + + HTTP {method} {path} +{doc_args} + Returns: + CoupaResponse with operation result + """ + if hasattr(self.http, 'ensure_token'): + await self.http.ensure_token() +{query_block} +{url_line} +{body_block} + + try: + request = HTTPRequest( + method="{method}", + url=url, + headers={{"Content-Type": "application/json", "Accept": "application/json"}},{req_extra} + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CoupaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed {name}" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {{response.status}}" + ) + except Exception as e: + return CoupaResponse(success=False, error=str(e), message="Failed to execute {name}") +''' + + +def generate_datasource() -> str: + """Generate the full Coupa DataSource module code.""" + header = '''# ruff: noqa +""" +Coupa REST API DataSource - Auto-generated API wrapper + +Generated from Coupa REST API documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. + +Note: For OAuth clients, ensure_token() is called before each request + to auto-fetch a client_credentials OAuth token. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.coupa.coupa import CoupaClient, CoupaResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class CoupaDataSource: + """Coupa REST API DataSource + + Provides async wrapper methods for Coupa REST API operations: + - Purchase Orders management + - Invoices management + - Requisitions management + - Suppliers management + - Contracts management + - Users management + - Departments management + - Expense Reports management + + All methods return CoupaResponse objects. + """ + + def __init__(self, client: CoupaClient) -> None: + """Initialize with CoupaClient. + + Args: + client: CoupaClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'CoupaDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> CoupaClient: + """Return the underlying CoupaClient.""" + return self._client +''' + + methods = [] + current_section = None + for ep in ENDPOINTS: + section = ep.get("section", "") + if section and section != current_section: + current_section = section + methods.append(f"\n # {'-' * 71}\n # {section}\n # {'-' * 71}") + methods.append(_gen_method(ep)) + + return header + "\n".join(methods) + "\n" + + +if __name__ == "__main__": + print(generate_datasource()) diff --git a/backend/python/app/sources/external/coupa/coupa.py b/backend/python/app/sources/external/coupa/coupa.py new file mode 100644 index 000000000..9c304ac31 --- /dev/null +++ b/backend/python/app/sources/external/coupa/coupa.py @@ -0,0 +1,763 @@ +# ruff: noqa +""" +Coupa REST API DataSource - Auto-generated API wrapper + +Generated from Coupa REST API documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. + +Note: For OAuth clients, ensure_token() is called before each request + to auto-fetch a client_credentials OAuth token. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.coupa.coupa import CoupaClient, CoupaResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class CoupaDataSource: + """Coupa REST API DataSource + + Provides async wrapper methods for Coupa REST API operations: + - Purchase Orders management + - Invoices management + - Requisitions management + - Suppliers management + - Contracts management + - Users management + - Departments management + - Expense Reports management + + All methods return CoupaResponse objects. + """ + + def __init__(self, client: CoupaClient) -> None: + """Initialize with CoupaClient. + + Args: + client: CoupaClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'CoupaDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> CoupaClient: + """Return the underlying CoupaClient.""" + return self._client + + # ----------------------------------------------------------------------- + # Purchase Orders + # ----------------------------------------------------------------------- + + async def list_purchase_orders( + self, + *, + limit: int | None = None, + offset: int | None = None + ) -> CoupaResponse: + """List all purchase orders + + HTTP GET /purchase_orders + + Args: + limit: Maximum number of results + offset: Offset for pagination + + Returns: + CoupaResponse with operation result + """ + if hasattr(self.http, 'ensure_token'): + await self.http.ensure_token() + + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/purchase_orders" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CoupaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_purchase_orders" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CoupaResponse(success=False, error=str(e), message="Failed to execute list_purchase_orders") + + + async def get_purchase_order( + self, + order_id: str + ) -> CoupaResponse: + """Get a specific purchase order by ID + + HTTP GET /purchase_orders/{order_id} + + Args: + order_id: The order id + + Returns: + CoupaResponse with operation result + """ + if hasattr(self.http, 'ensure_token'): + await self.http.ensure_token() + + url = self.base_url + "/purchase_orders/{order_id}".format(order_id=order_id) + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CoupaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_purchase_order" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CoupaResponse(success=False, error=str(e), message="Failed to execute get_purchase_order") + + + # ----------------------------------------------------------------------- + # Invoices + # ----------------------------------------------------------------------- + + async def list_invoices( + self, + *, + limit: int | None = None, + offset: int | None = None + ) -> CoupaResponse: + """List all invoices + + HTTP GET /invoices + + Args: + limit: Maximum number of results + offset: Offset for pagination + + Returns: + CoupaResponse with operation result + """ + if hasattr(self.http, 'ensure_token'): + await self.http.ensure_token() + + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/invoices" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CoupaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_invoices" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CoupaResponse(success=False, error=str(e), message="Failed to execute list_invoices") + + + async def get_invoice( + self, + invoice_id: str + ) -> CoupaResponse: + """Get a specific invoice by ID + + HTTP GET /invoices/{invoice_id} + + Args: + invoice_id: The invoice id + + Returns: + CoupaResponse with operation result + """ + if hasattr(self.http, 'ensure_token'): + await self.http.ensure_token() + + url = self.base_url + "/invoices/{invoice_id}".format(invoice_id=invoice_id) + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CoupaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_invoice" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CoupaResponse(success=False, error=str(e), message="Failed to execute get_invoice") + + + # ----------------------------------------------------------------------- + # Requisitions + # ----------------------------------------------------------------------- + + async def list_requisitions( + self, + *, + limit: int | None = None, + offset: int | None = None + ) -> CoupaResponse: + """List all requisitions + + HTTP GET /requisitions + + Args: + limit: Maximum number of results + offset: Offset for pagination + + Returns: + CoupaResponse with operation result + """ + if hasattr(self.http, 'ensure_token'): + await self.http.ensure_token() + + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/requisitions" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CoupaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_requisitions" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CoupaResponse(success=False, error=str(e), message="Failed to execute list_requisitions") + + + async def get_requisition( + self, + requisition_id: str + ) -> CoupaResponse: + """Get a specific requisition by ID + + HTTP GET /requisitions/{requisition_id} + + Args: + requisition_id: The requisition id + + Returns: + CoupaResponse with operation result + """ + if hasattr(self.http, 'ensure_token'): + await self.http.ensure_token() + + url = self.base_url + "/requisitions/{requisition_id}".format(requisition_id=requisition_id) + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CoupaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_requisition" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CoupaResponse(success=False, error=str(e), message="Failed to execute get_requisition") + + + # ----------------------------------------------------------------------- + # Suppliers + # ----------------------------------------------------------------------- + + async def list_suppliers( + self, + *, + limit: int | None = None, + offset: int | None = None + ) -> CoupaResponse: + """List all suppliers + + HTTP GET /suppliers + + Args: + limit: Maximum number of results + offset: Offset for pagination + + Returns: + CoupaResponse with operation result + """ + if hasattr(self.http, 'ensure_token'): + await self.http.ensure_token() + + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/suppliers" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CoupaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_suppliers" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CoupaResponse(success=False, error=str(e), message="Failed to execute list_suppliers") + + + async def get_supplier( + self, + supplier_id: str + ) -> CoupaResponse: + """Get a specific supplier by ID + + HTTP GET /suppliers/{supplier_id} + + Args: + supplier_id: The supplier id + + Returns: + CoupaResponse with operation result + """ + if hasattr(self.http, 'ensure_token'): + await self.http.ensure_token() + + url = self.base_url + "/suppliers/{supplier_id}".format(supplier_id=supplier_id) + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CoupaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_supplier" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CoupaResponse(success=False, error=str(e), message="Failed to execute get_supplier") + + + # ----------------------------------------------------------------------- + # Contracts + # ----------------------------------------------------------------------- + + async def list_contracts( + self, + *, + limit: int | None = None, + offset: int | None = None + ) -> CoupaResponse: + """List all contracts + + HTTP GET /contracts + + Args: + limit: Maximum number of results + offset: Offset for pagination + + Returns: + CoupaResponse with operation result + """ + if hasattr(self.http, 'ensure_token'): + await self.http.ensure_token() + + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/contracts" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CoupaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_contracts" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CoupaResponse(success=False, error=str(e), message="Failed to execute list_contracts") + + + async def get_contract( + self, + contract_id: str + ) -> CoupaResponse: + """Get a specific contract by ID + + HTTP GET /contracts/{contract_id} + + Args: + contract_id: The contract id + + Returns: + CoupaResponse with operation result + """ + if hasattr(self.http, 'ensure_token'): + await self.http.ensure_token() + + url = self.base_url + "/contracts/{contract_id}".format(contract_id=contract_id) + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CoupaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_contract" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CoupaResponse(success=False, error=str(e), message="Failed to execute get_contract") + + + # ----------------------------------------------------------------------- + # Users + # ----------------------------------------------------------------------- + + async def list_users( + self, + *, + limit: int | None = None, + offset: int | None = None + ) -> CoupaResponse: + """List all users + + HTTP GET /users + + Args: + limit: Maximum number of results + offset: Offset for pagination + + Returns: + CoupaResponse with operation result + """ + if hasattr(self.http, 'ensure_token'): + await self.http.ensure_token() + + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/users" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CoupaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_users" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CoupaResponse(success=False, error=str(e), message="Failed to execute list_users") + + + async def get_user( + self, + user_id: str + ) -> CoupaResponse: + """Get a specific user by ID + + HTTP GET /users/{user_id} + + Args: + user_id: The user id + + Returns: + CoupaResponse with operation result + """ + if hasattr(self.http, 'ensure_token'): + await self.http.ensure_token() + + url = self.base_url + "/users/{user_id}".format(user_id=user_id) + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CoupaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_user" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CoupaResponse(success=False, error=str(e), message="Failed to execute get_user") + + + # ----------------------------------------------------------------------- + # Departments + # ----------------------------------------------------------------------- + + async def list_departments( + self, + *, + limit: int | None = None, + offset: int | None = None + ) -> CoupaResponse: + """List all departments + + HTTP GET /departments + + Args: + limit: Maximum number of results + offset: Offset for pagination + + Returns: + CoupaResponse with operation result + """ + if hasattr(self.http, 'ensure_token'): + await self.http.ensure_token() + + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/departments" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CoupaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_departments" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CoupaResponse(success=False, error=str(e), message="Failed to execute list_departments") + + + async def get_department( + self, + department_id: str + ) -> CoupaResponse: + """Get a specific department by ID + + HTTP GET /departments/{department_id} + + Args: + department_id: The department id + + Returns: + CoupaResponse with operation result + """ + if hasattr(self.http, 'ensure_token'): + await self.http.ensure_token() + + url = self.base_url + "/departments/{department_id}".format(department_id=department_id) + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CoupaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_department" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CoupaResponse(success=False, error=str(e), message="Failed to execute get_department") + + + # ----------------------------------------------------------------------- + # Expense Reports + # ----------------------------------------------------------------------- + + async def list_expense_reports( + self, + *, + limit: int | None = None, + offset: int | None = None + ) -> CoupaResponse: + """List all expense reports + + HTTP GET /expense_reports + + Args: + limit: Maximum number of results + offset: Offset for pagination + + Returns: + CoupaResponse with operation result + """ + if hasattr(self.http, 'ensure_token'): + await self.http.ensure_token() + + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/expense_reports" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CoupaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_expense_reports" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CoupaResponse(success=False, error=str(e), message="Failed to execute list_expense_reports") + + + async def get_expense_report( + self, + report_id: str + ) -> CoupaResponse: + """Get a specific expense report by ID + + HTTP GET /expense_reports/{report_id} + + Args: + report_id: The report id + + Returns: + CoupaResponse with operation result + """ + if hasattr(self.http, 'ensure_token'): + await self.http.ensure_token() + + url = self.base_url + "/expense_reports/{report_id}".format(report_id=report_id) + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return CoupaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_expense_report" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return CoupaResponse(success=False, error=str(e), message="Failed to execute get_expense_report") + diff --git a/backend/python/app/sources/external/coupa/example.py b/backend/python/app/sources/external/coupa/example.py new file mode 100644 index 000000000..448700c0a --- /dev/null +++ b/backend/python/app/sources/external/coupa/example.py @@ -0,0 +1,170 @@ +# ruff: noqa + +""" +Coupa API Usage Examples + +This example demonstrates how to use the Coupa DataSource to interact with +the Coupa API, covering: +- Authentication (API Key, OAuth2 client_credentials) +- Initializing the Client and DataSource +- Listing Purchase Orders, Invoices, Requisitions +- Getting Suppliers, Contracts, Users, Departments +- Getting Expense Reports + +Prerequisites: +For API Key: +1. Get your Coupa API key from Coupa admin +2. Set COUPA_API_KEY and COUPA_INSTANCE environment variables + +For OAuth2: +1. Get your OAuth2 client_id and client_secret +2. Set COUPA_CLIENT_ID, COUPA_CLIENT_SECRET, and COUPA_INSTANCE environment variables +""" + +import asyncio +import json +import os + +from app.sources.client.coupa.coupa import ( + CoupaApiKeyConfig, + CoupaClient, + CoupaOAuthConfig, + CoupaResponse, +) +from app.sources.external.coupa.coupa import CoupaDataSource + +# --- Configuration --- +# API Key credentials +API_KEY = os.getenv("COUPA_API_KEY") + +# OAuth2 credentials +CLIENT_ID = os.getenv("COUPA_CLIENT_ID") +CLIENT_SECRET = os.getenv("COUPA_CLIENT_SECRET") + +# Instance name (required for both auth methods) +INSTANCE = os.getenv("COUPA_INSTANCE") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: CoupaResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + for key in ("purchase_orders", "invoices", "requisitions", + "suppliers", "contracts", "users", "departments", + "expense_reports", "results", "items"): + if isinstance(data, dict) and key in data: + items = data[key] + print(f" Found {len(items)} {key}.") + if items: + print(f" Sample: {json.dumps(items[0], indent=2)[:400]}...") + return + if isinstance(data, list): + print(f" Found {len(data)} items.") + if data: + print(f" Sample: {json.dumps(data[0], indent=2)[:400]}...") + return + print(f" Data: {json.dumps(data, indent=2)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + # 1. Initialize Client + print_section("Initializing Coupa Client") + + if not INSTANCE: + print(" COUPA_INSTANCE is required.") + print(" Please set COUPA_INSTANCE environment variable.") + return + + config = None + + # Priority 1: API Key + if API_KEY: + print(" Using API Key authentication") + config = CoupaApiKeyConfig(api_key=API_KEY, instance=INSTANCE) + + # Priority 2: OAuth2 + if config is None and CLIENT_ID and CLIENT_SECRET: + print(" Using OAuth2 client_credentials authentication") + config = CoupaOAuthConfig( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + instance=INSTANCE, + ) + + if config is None: + print(" No valid authentication method found.") + print(" Please set one of the following:") + print(" - COUPA_API_KEY (for API Key auth)") + print(" - COUPA_CLIENT_ID and COUPA_CLIENT_SECRET (for OAuth2)") + return + + client = CoupaClient.build_with_config(config) + data_source = CoupaDataSource(client) + print("Client initialized successfully.") + + try: + # 2. List Purchase Orders + print_section("Purchase Orders") + orders_resp = await data_source.list_purchase_orders(limit=10) + print_result("List Purchase Orders", orders_resp) + + # 3. List Invoices + print_section("Invoices") + invoices_resp = await data_source.list_invoices(limit=10) + print_result("List Invoices", invoices_resp) + + # 4. List Requisitions + print_section("Requisitions") + reqs_resp = await data_source.list_requisitions(limit=10) + print_result("List Requisitions", reqs_resp) + + # 5. List Suppliers + print_section("Suppliers") + suppliers_resp = await data_source.list_suppliers(limit=10) + print_result("List Suppliers", suppliers_resp) + + # 6. List Contracts + print_section("Contracts") + contracts_resp = await data_source.list_contracts(limit=10) + print_result("List Contracts", contracts_resp) + + # 7. List Users + print_section("Users") + users_resp = await data_source.list_users(limit=10) + print_result("List Users", users_resp) + + # 8. List Departments + print_section("Departments") + depts_resp = await data_source.list_departments(limit=10) + print_result("List Departments", depts_resp) + + # 9. List Expense Reports + print_section("Expense Reports") + expense_resp = await data_source.list_expense_reports(limit=10) + print_result("List Expense Reports", expense_resp) + + finally: + print("\nClosing client connection...") + inner_client = client.get_client() + if hasattr(inner_client, "close"): + await inner_client.close() + + print("\n" + "=" * 80) + print(" All Coupa API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/coupa/run_generator.py b/backend/python/app/sources/external/coupa/run_generator.py new file mode 100644 index 000000000..41f050c5b --- /dev/null +++ b/backend/python/app/sources/external/coupa/run_generator.py @@ -0,0 +1,24 @@ +"""Runner script to generate the Coupa DataSource wrapper. + +Execute this script to regenerate coupa.py from the endpoint definitions +in code_generator.py. + +Usage: + python -m app.sources.external.coupa.run_generator +""" + +from app.sources.external.coupa.code_generator import generate_datasource + + +def main() -> None: + """Generate the Coupa DataSource file.""" + code = generate_datasource() + output_path = __file__.replace("run_generator.py", "coupa.py") + with open(output_path, "w") as f: + f.write(code) + print(f"Generated Coupa DataSource -> {output_path}") + print(f" Total characters: {len(code)}") + + +if __name__ == "__main__": + main() diff --git a/backend/python/app/sources/external/darwinbox/darwinbox.py b/backend/python/app/sources/external/darwinbox/darwinbox.py new file mode 100644 index 000000000..e2cd6bf31 --- /dev/null +++ b/backend/python/app/sources/external/darwinbox/darwinbox.py @@ -0,0 +1,621 @@ +""" +DarwinBox REST API DataSource - Auto-generated API wrapper + +Generated from DarwinBox REST API documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.darwinbox.darwinbox import ( + DarwinBoxClient, + DarwinBoxResponse, +) +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class DarwinBoxDataSource: + """DarwinBox REST API DataSource + + Provides async wrapper methods for DarwinBox REST API operations: + - Employee management + - Department and designation management + - Location management + - Attendance tracking + - Leave management + - Payroll / salary + - Recruitment (openings, applications) + + The base URL is determined by the domain configured in the client. + All methods return DarwinBoxResponse objects. + """ + + def __init__(self, client: DarwinBoxClient) -> None: + """Initialize with DarwinBoxClient. + + Args: + client: DarwinBoxClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip("/") + except AttributeError as exc: + raise ValueError( + "HTTP client does not have get_base_url method" + ) from exc + + def get_data_source(self) -> "DarwinBoxDataSource": + """Return the data source instance.""" + return self + + def get_client(self) -> DarwinBoxClient: + """Return the underlying DarwinBoxClient.""" + return self._client + + # ----------------------------------------------------------------------- + # Employees + # ----------------------------------------------------------------------- + + async def get_employees( + self, + *, + page: int | None = None, + per_page: int | None = None, + ) -> DarwinBoxResponse: + """Get a list of employees. + + Args: + page: Page number + per_page: Number of results per page + + Returns: + DarwinBoxResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params["page"] = str(page) + if per_page is not None: + query_params["per_page"] = str(per_page) + + url = self.base_url + "/employees" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return DarwinBoxResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_employees" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return DarwinBoxResponse( + success=False, + error=str(e), + message="Failed to execute get_employees", + ) + + async def get_employee(self, employee_id: str) -> DarwinBoxResponse: + """Get an employee by ID. + + Args: + employee_id: The employee ID + + Returns: + DarwinBoxResponse with operation result + """ + url = self.base_url + "/employees/{employee_id}".format( + employee_id=employee_id + ) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return DarwinBoxResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_employee" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return DarwinBoxResponse( + success=False, + error=str(e), + message="Failed to execute get_employee", + ) + + # ----------------------------------------------------------------------- + # Departments + # ----------------------------------------------------------------------- + + async def get_departments(self) -> DarwinBoxResponse: + """Get a list of departments. + + Returns: + DarwinBoxResponse with operation result + """ + url = self.base_url + "/departments" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return DarwinBoxResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_departments" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return DarwinBoxResponse( + success=False, + error=str(e), + message="Failed to execute get_departments", + ) + + async def get_department( + self, department_id: str + ) -> DarwinBoxResponse: + """Get a department by ID. + + Args: + department_id: The department ID + + Returns: + DarwinBoxResponse with operation result + """ + url = self.base_url + "/departments/{department_id}".format( + department_id=department_id + ) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return DarwinBoxResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_department" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return DarwinBoxResponse( + success=False, + error=str(e), + message="Failed to execute get_department", + ) + + # ----------------------------------------------------------------------- + # Designations + # ----------------------------------------------------------------------- + + async def get_designations(self) -> DarwinBoxResponse: + """Get a list of designations. + + Returns: + DarwinBoxResponse with operation result + """ + url = self.base_url + "/designations" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return DarwinBoxResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_designations" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return DarwinBoxResponse( + success=False, + error=str(e), + message="Failed to execute get_designations", + ) + + # ----------------------------------------------------------------------- + # Locations + # ----------------------------------------------------------------------- + + async def get_locations(self) -> DarwinBoxResponse: + """Get a list of locations. + + Returns: + DarwinBoxResponse with operation result + """ + url = self.base_url + "/locations" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return DarwinBoxResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_locations" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return DarwinBoxResponse( + success=False, + error=str(e), + message="Failed to execute get_locations", + ) + + # ----------------------------------------------------------------------- + # Attendance + # ----------------------------------------------------------------------- + + async def get_attendance( + self, + *, + page: int | None = None, + per_page: int | None = None, + ) -> DarwinBoxResponse: + """Get attendance records. + + Args: + page: Page number + per_page: Number of results per page + + Returns: + DarwinBoxResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params["page"] = str(page) + if per_page is not None: + query_params["per_page"] = str(per_page) + + url = self.base_url + "/attendance" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return DarwinBoxResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_attendance" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return DarwinBoxResponse( + success=False, + error=str(e), + message="Failed to execute get_attendance", + ) + + async def get_employee_attendance( + self, employee_id: str + ) -> DarwinBoxResponse: + """Get attendance records for a specific employee. + + Args: + employee_id: The employee ID + + Returns: + DarwinBoxResponse with operation result + """ + url = self.base_url + "/attendance/{employee_id}".format( + employee_id=employee_id + ) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return DarwinBoxResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_employee_attendance" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return DarwinBoxResponse( + success=False, + error=str(e), + message="Failed to execute get_employee_attendance", + ) + + # ----------------------------------------------------------------------- + # Leave + # ----------------------------------------------------------------------- + + async def get_leave_balance( + self, employee_id: str + ) -> DarwinBoxResponse: + """Get leave balance for an employee. + + Args: + employee_id: The employee ID + + Returns: + DarwinBoxResponse with operation result + """ + url = self.base_url + "/leave/balance/{employee_id}".format( + employee_id=employee_id + ) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return DarwinBoxResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_leave_balance" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return DarwinBoxResponse( + success=False, + error=str(e), + message="Failed to execute get_leave_balance", + ) + + async def get_leave_requests( + self, + *, + page: int | None = None, + per_page: int | None = None, + ) -> DarwinBoxResponse: + """Get leave requests. + + Args: + page: Page number + per_page: Number of results per page + + Returns: + DarwinBoxResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params["page"] = str(page) + if per_page is not None: + query_params["per_page"] = str(per_page) + + url = self.base_url + "/leave/requests" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return DarwinBoxResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_leave_requests" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return DarwinBoxResponse( + success=False, + error=str(e), + message="Failed to execute get_leave_requests", + ) + + # ----------------------------------------------------------------------- + # Payroll + # ----------------------------------------------------------------------- + + async def get_employee_salary( + self, employee_id: str + ) -> DarwinBoxResponse: + """Get salary details for an employee. + + Args: + employee_id: The employee ID + + Returns: + DarwinBoxResponse with operation result + """ + url = self.base_url + "/payroll/salary/{employee_id}".format( + employee_id=employee_id + ) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return DarwinBoxResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_employee_salary" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return DarwinBoxResponse( + success=False, + error=str(e), + message="Failed to execute get_employee_salary", + ) + + # ----------------------------------------------------------------------- + # Recruitment + # ----------------------------------------------------------------------- + + async def get_recruitment_openings( + self, + *, + page: int | None = None, + per_page: int | None = None, + ) -> DarwinBoxResponse: + """Get recruitment openings. + + Args: + page: Page number + per_page: Number of results per page + + Returns: + DarwinBoxResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params["page"] = str(page) + if per_page is not None: + query_params["per_page"] = str(per_page) + + url = self.base_url + "/recruitment/openings" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return DarwinBoxResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_recruitment_openings" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return DarwinBoxResponse( + success=False, + error=str(e), + message="Failed to execute get_recruitment_openings", + ) + + async def get_recruitment_applications( + self, + *, + page: int | None = None, + per_page: int | None = None, + ) -> DarwinBoxResponse: + """Get recruitment applications. + + Args: + page: Page number + per_page: Number of results per page + + Returns: + DarwinBoxResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params["page"] = str(page) + if per_page is not None: + query_params["per_page"] = str(per_page) + + url = self.base_url + "/recruitment/applications" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return DarwinBoxResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_recruitment_applications" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return DarwinBoxResponse( + success=False, + error=str(e), + message="Failed to execute get_recruitment_applications", + ) diff --git a/backend/python/app/sources/external/darwinbox/example.py b/backend/python/app/sources/external/darwinbox/example.py new file mode 100644 index 000000000..0b4d88e38 --- /dev/null +++ b/backend/python/app/sources/external/darwinbox/example.py @@ -0,0 +1,172 @@ +# ruff: noqa + +""" +DarwinBox API Usage Examples + +This example demonstrates how to use the DarwinBox DataSource to interact with +the DarwinBox API, covering: +- Authentication (Basic Auth, OAuth2) +- Initializing the Client and DataSource +- Fetching Employees +- Listing Departments, Designations, Locations +- Attendance and Leave management +- Payroll and Recruitment + +Prerequisites: +For Basic Auth: +1. Set DARWINBOX_DOMAIN to your DarwinBox domain (e.g. "yourcompany") +2. Set DARWINBOX_API_KEY and DARWINBOX_API_SECRET + +For OAuth2: +1. Set DARWINBOX_DOMAIN, DARWINBOX_CLIENT_ID, DARWINBOX_CLIENT_SECRET +2. Complete OAuth flow to get access_token +3. Set DARWINBOX_ACCESS_TOKEN +""" + +import asyncio +import json +import os + +from app.sources.client.darwinbox.darwinbox import ( + DarwinBoxBasicAuthConfig, + DarwinBoxClient, + DarwinBoxOAuthConfig, + DarwinBoxResponse, +) +from app.sources.external.darwinbox.darwinbox import DarwinBoxDataSource + +# --- Configuration --- +DOMAIN = os.getenv("DARWINBOX_DOMAIN", "") +API_KEY = os.getenv("DARWINBOX_API_KEY", "") +API_SECRET = os.getenv("DARWINBOX_API_SECRET", "") +ACCESS_TOKEN = os.getenv("DARWINBOX_ACCESS_TOKEN", "") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: DarwinBoxResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + if isinstance(data, list): + print(f" Found {len(data)} items.") + if data: + print(f" Sample: {json.dumps(data[0], indent=2)[:400]}...") + elif isinstance(data, dict): + print(f" Data: {json.dumps(data, indent=2)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + # 1. Initialize Client + print_section("Initializing DarwinBox Client") + + config = None + + if not DOMAIN: + print(" DARWINBOX_DOMAIN is required.") + return + + # Priority 1: OAuth2 + if ACCESS_TOKEN: + print(" Using OAuth2 authentication") + config = DarwinBoxOAuthConfig( + access_token=ACCESS_TOKEN, + domain=DOMAIN, + ) + + # Priority 2: Basic Auth + elif API_KEY and API_SECRET: + print(" Using Basic Auth authentication") + config = DarwinBoxBasicAuthConfig( + api_key=API_KEY, + api_secret=API_SECRET, + domain=DOMAIN, + ) + + if config is None: + print(" No valid authentication found.") + print(" Please set one of:") + print(" - DARWINBOX_ACCESS_TOKEN (for OAuth2)") + print(" - DARWINBOX_API_KEY and DARWINBOX_API_SECRET (for Basic Auth)") + return + + client = DarwinBoxClient.build_with_config(config) + data_source = DarwinBoxDataSource(client) + print(f"Client initialized for domain: {DOMAIN}") + + try: + # 2. Get Employees + print_section("Employees") + employees_resp = await data_source.get_employees(per_page=5) + print_result("Get Employees", employees_resp) + + employee_id = None + if employees_resp.success and isinstance(employees_resp.data, list) and employees_resp.data: + employee_id = str(employees_resp.data[0].get("id", "")) + print(f" Using Employee ID: {employee_id}") + + # 3. Get Specific Employee + if employee_id: + print_section("Employee Details") + emp_resp = await data_source.get_employee(employee_id) + print_result("Get Employee", emp_resp) + + # 4. Get Departments + print_section("Departments") + depts_resp = await data_source.get_departments() + print_result("Get Departments", depts_resp) + + # 5. Get Designations + print_section("Designations") + desig_resp = await data_source.get_designations() + print_result("Get Designations", desig_resp) + + # 6. Get Locations + print_section("Locations") + loc_resp = await data_source.get_locations() + print_result("Get Locations", loc_resp) + + # 7. Get Attendance + print_section("Attendance") + att_resp = await data_source.get_attendance(per_page=5) + print_result("Get Attendance", att_resp) + + # 8. Get Leave Requests + print_section("Leave Requests") + leave_resp = await data_source.get_leave_requests(per_page=5) + print_result("Get Leave Requests", leave_resp) + + # 9. Get Recruitment Openings + print_section("Recruitment Openings") + openings_resp = await data_source.get_recruitment_openings(per_page=5) + print_result("Get Recruitment Openings", openings_resp) + + # 10. Get Recruitment Applications + print_section("Recruitment Applications") + apps_resp = await data_source.get_recruitment_applications(per_page=5) + print_result("Get Recruitment Applications", apps_resp) + + finally: + # Cleanup + print("\nClosing client connection...") + inner_client = client.get_client() + if hasattr(inner_client, "close"): + await inner_client.close() + + print("\n" + "=" * 80) + print(" All DarwinBox API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/datadog/datadog.py b/backend/python/app/sources/external/datadog/datadog.py new file mode 100644 index 000000000..f25dc6c79 --- /dev/null +++ b/backend/python/app/sources/external/datadog/datadog.py @@ -0,0 +1,305 @@ +# ruff: noqa +from __future__ import annotations + +from typing import Any, Dict, Optional, Union + +from datadog_api_client import ApiClient, Configuration # type: ignore[reportMissingImports] +from datadog_api_client.v1.api.dashboards_api import DashboardsApi # type: ignore[reportMissingImports] +from datadog_api_client.v1.api.monitors_api import MonitorsApi # type: ignore[reportMissingImports] +from datadog_api_client.v1.api.hosts_api import HostsApi # type: ignore[reportMissingImports] +from datadog_api_client.v1.api.metrics_api import MetricsApi as MetricsApiV1 # type: ignore[reportMissingImports] +from datadog_api_client.v1.api.synthetics_api import SyntheticsApi # type: ignore[reportMissingImports] +from datadog_api_client.v1.api.downtimes_api import DowntimesApi # type: ignore[reportMissingImports] +from datadog_api_client.v1.model.dashboard import Dashboard # type: ignore[reportMissingImports] +from datadog_api_client.v1.model.monitor import Monitor # type: ignore[reportMissingImports] +from datadog_api_client.v1.model.monitor_update_request import MonitorUpdateRequest # type: ignore[reportMissingImports] +from datadog_api_client.v2.api.users_api import UsersApi # type: ignore[reportMissingImports] +from datadog_api_client.v2.api.incidents_api import IncidentsApi # type: ignore[reportMissingImports] +from datadog_api_client.v2.api.logs_api import LogsApi # type: ignore[reportMissingImports] +from datadog_api_client.v2.api.metrics_api import MetricsApi as MetricsApiV2 # type: ignore[reportMissingImports] +from datadog_api_client.v2.api.service_definition_api import ServiceDefinitionApi # type: ignore[reportMissingImports] +from datadog_api_client.v2.model.logs_list_request import LogsListRequest # type: ignore[reportMissingImports] +from datadog_api_client.v2.model.logs_query_filter import LogsQueryFilter # type: ignore[reportMissingImports] +from datadog_api_client.v2.model.logs_list_request_page import LogsListRequestPage # type: ignore[reportMissingImports] +from datadog_api_client.v2.model.logs_sort import LogsSort # type: ignore[reportMissingImports] + +from app.sources.client.datadog.datadog import DatadogResponse + + +class DatadogDataSource: + """ + Typed wrapper over the official datadog-api-client SDK for common + Datadog business operations. + + Accepts either a ``Configuration`` instance *or* any object with + ``.get_sdk() -> Configuration``. + """ + + def __init__(self, client_or_config: Union[Configuration, object]) -> None: # type: ignore[reportUnknownParameterType] + super().__init__() + if hasattr(client_or_config, "get_sdk"): # type: ignore[reportUnknownArgumentType] + self._config: Configuration = getattr(client_or_config, "get_sdk")() # type: ignore[reportUnknownMemberType] + else: + self._config = client_or_config # type: ignore[assignment] + + # ---- helpers ---- + + @staticmethod + def _to_dict_safe(obj: Any) -> Any: + """Convert SDK response objects to dicts when possible.""" + if hasattr(obj, "to_dict"): + return obj.to_dict() # type: ignore[reportUnknownMemberType] + if isinstance(obj, list): + out: list[Any] = [] + for item in obj: # type: ignore[reportUnknownVariableType] + out.append(item.to_dict() if hasattr(item, "to_dict") else item) # type: ignore[reportUnknownMemberType] + return out + return obj + + @staticmethod + def _params(**kwargs: Any) -> Dict[str, Any]: + """Filter out None values to avoid overriding SDK defaults.""" + return {k: v for k, v in kwargs.items() if v is not None} + def list_dashboards(self) -> DatadogResponse: + """List all dashboards. [dashboards]""" + try: + with ApiClient(self._config) as api_client: # type: ignore[reportUnknownMemberType] + api = DashboardsApi(api_client) # type: ignore[reportUnknownMemberType] + result: Any = api.list_dashboards() # type: ignore[reportUnknownMemberType] + return DatadogResponse(success=True, data=self._to_dict_safe(result)) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return DatadogResponse(success=False, error=str(e)) + def get_dashboard(self, dashboard_id: str) -> DatadogResponse: + """Get a single dashboard by ID. [dashboards]""" + try: + with ApiClient(self._config) as api_client: # type: ignore[reportUnknownMemberType] + api = DashboardsApi(api_client) # type: ignore[reportUnknownMemberType] + result: Any = api.get_dashboard(dashboard_id=dashboard_id) # type: ignore[reportUnknownMemberType] + return DatadogResponse(success=True, data=self._to_dict_safe(result)) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return DatadogResponse(success=False, error=str(e)) + def create_dashboard(self, body: Dict[str, Any]) -> DatadogResponse: + """Create a dashboard. Pass the dashboard definition as a dict. [dashboards]""" + try: + with ApiClient(self._config) as api_client: # type: ignore[reportUnknownMemberType] + api = DashboardsApi(api_client) # type: ignore[reportUnknownMemberType] + dashboard = Dashboard(**body) # type: ignore[reportUnknownMemberType] + result: Any = api.create_dashboard(body=dashboard) # type: ignore[reportUnknownMemberType] + return DatadogResponse(success=True, data=self._to_dict_safe(result)) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return DatadogResponse(success=False, error=str(e)) + def list_monitors(self, group_states: Optional[str] = None, name: Optional[str] = None, tags: Optional[str] = None, monitor_tags: Optional[str] = None, page: Optional[int] = None, page_size: Optional[int] = None) -> DatadogResponse: + """List monitors with optional filters. [monitors]""" + try: + with ApiClient(self._config) as api_client: # type: ignore[reportUnknownMemberType] + api = MonitorsApi(api_client) # type: ignore[reportUnknownMemberType] + kwargs = self._params(group_states=group_states, name=name, tags=tags, monitor_tags=monitor_tags, page=page, page_size=page_size) + result: Any = api.list_monitors(**kwargs) # type: ignore[reportUnknownMemberType] + return DatadogResponse(success=True, data=self._to_dict_safe(result)) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return DatadogResponse(success=False, error=str(e)) + def get_monitor(self, monitor_id: int) -> DatadogResponse: + """Get a single monitor by ID. [monitors]""" + try: + with ApiClient(self._config) as api_client: # type: ignore[reportUnknownMemberType] + api = MonitorsApi(api_client) # type: ignore[reportUnknownMemberType] + result: Any = api.get_monitor(monitor_id=monitor_id) # type: ignore[reportUnknownMemberType] + return DatadogResponse(success=True, data=self._to_dict_safe(result)) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return DatadogResponse(success=False, error=str(e)) + def create_monitor(self, body: Dict[str, Any]) -> DatadogResponse: + """Create a monitor. Pass the monitor definition as a dict. [monitors]""" + try: + with ApiClient(self._config) as api_client: # type: ignore[reportUnknownMemberType] + api = MonitorsApi(api_client) # type: ignore[reportUnknownMemberType] + monitor = Monitor(**body) # type: ignore[reportUnknownMemberType] + result: Any = api.create_monitor(body=monitor) # type: ignore[reportUnknownMemberType] + return DatadogResponse(success=True, data=self._to_dict_safe(result)) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return DatadogResponse(success=False, error=str(e)) + def update_monitor(self, monitor_id: int, body: Dict[str, Any]) -> DatadogResponse: + """Update an existing monitor. [monitors]""" + try: + with ApiClient(self._config) as api_client: # type: ignore[reportUnknownMemberType] + api = MonitorsApi(api_client) # type: ignore[reportUnknownMemberType] + update_req = MonitorUpdateRequest(**body) # type: ignore[reportUnknownMemberType] + result: Any = api.update_monitor(monitor_id=monitor_id, body=update_req) # type: ignore[reportUnknownMemberType] + return DatadogResponse(success=True, data=self._to_dict_safe(result)) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return DatadogResponse(success=False, error=str(e)) + def delete_monitor(self, monitor_id: int) -> DatadogResponse: + """Delete a monitor by ID. [monitors]""" + try: + with ApiClient(self._config) as api_client: # type: ignore[reportUnknownMemberType] + api = MonitorsApi(api_client) # type: ignore[reportUnknownMemberType] + result: Any = api.delete_monitor(monitor_id=monitor_id) # type: ignore[reportUnknownMemberType] + return DatadogResponse(success=True, data=self._to_dict_safe(result)) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return DatadogResponse(success=False, error=str(e)) + def list_users(self, page_size: Optional[int] = None, page_number: Optional[int] = None, sort: Optional[str] = None, sort_dir: Optional[str] = None, filter_str: Optional[str] = None, filter_status: Optional[str] = None) -> DatadogResponse: + """List all users in the organization. [users]""" + try: + with ApiClient(self._config) as api_client: # type: ignore[reportUnknownMemberType] + api = UsersApi(api_client) # type: ignore[reportUnknownMemberType] + kwargs: Dict[str, Any] = {} + if page_size is not None: + kwargs['page_size'] = page_size + if page_number is not None: + kwargs['page_number'] = page_number + if sort is not None: + kwargs['sort'] = sort + if sort_dir is not None: + kwargs['sort_dir'] = sort_dir + if filter_str is not None: + kwargs['filter'] = filter_str + if filter_status is not None: + kwargs['filter_status'] = filter_status + result: Any = api.list_users(**kwargs) # type: ignore[reportUnknownMemberType] + return DatadogResponse(success=True, data=self._to_dict_safe(result)) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return DatadogResponse(success=False, error=str(e)) + def get_user(self, user_id: str) -> DatadogResponse: + """Get a single user by ID. [users]""" + try: + with ApiClient(self._config) as api_client: # type: ignore[reportUnknownMemberType] + api = UsersApi(api_client) # type: ignore[reportUnknownMemberType] + result: Any = api.get_user(user_id=user_id) # type: ignore[reportUnknownMemberType] + return DatadogResponse(success=True, data=self._to_dict_safe(result)) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return DatadogResponse(success=False, error=str(e)) + def list_hosts(self, filter_str: Optional[str] = None, sort_field: Optional[str] = None, sort_dir: Optional[str] = None, start: Optional[int] = None, count: Optional[int] = None, from_ts: Optional[int] = None) -> DatadogResponse: + """List all hosts for the organization. [hosts]""" + try: + with ApiClient(self._config) as api_client: # type: ignore[reportUnknownMemberType] + api = HostsApi(api_client) # type: ignore[reportUnknownMemberType] + kwargs: Dict[str, Any] = {} + if filter_str is not None: + kwargs['filter'] = filter_str + if sort_field is not None: + kwargs['sort_field'] = sort_field + if sort_dir is not None: + kwargs['sort_dir'] = sort_dir + if start is not None: + kwargs['start'] = start + if count is not None: + kwargs['count'] = count + if from_ts is not None: + kwargs['_from'] = from_ts + result: Any = api.list_hosts(**kwargs) # type: ignore[reportUnknownMemberType] + return DatadogResponse(success=True, data=self._to_dict_safe(result)) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return DatadogResponse(success=False, error=str(e)) + def query_timeseries(self, from_ts: int, to_ts: int, query: str) -> DatadogResponse: + """Query timeseries data using a metrics query string. [metrics]""" + try: + with ApiClient(self._config) as api_client: # type: ignore[reportUnknownMemberType] + api = MetricsApiV1(api_client) # type: ignore[reportUnknownMemberType] + result: Any = api.query_metrics(_from=from_ts, to=to_ts, query=query) # type: ignore[reportUnknownMemberType] + return DatadogResponse(success=True, data=self._to_dict_safe(result)) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return DatadogResponse(success=False, error=str(e)) + def list_active_metrics(self, filter_configured: Optional[bool] = None, filter_tags_configured: Optional[str] = None, filter_metric_type: Optional[str] = None, filter_include_percentiles: Optional[bool] = None, filter_queried: Optional[bool] = None, filter_tags: Optional[str] = None, window_seconds: Optional[int] = None, page_size: Optional[int] = None, page_cursor: Optional[str] = None) -> DatadogResponse: + """List active metric tag configurations with optional filters. [metrics]""" + try: + with ApiClient(self._config) as api_client: # type: ignore[reportUnknownMemberType] + api = MetricsApiV2(api_client) # type: ignore[reportUnknownMemberType] + kwargs = self._params( + filter_configured=filter_configured, + filter_tags_configured=filter_tags_configured, + filter_metric_type=filter_metric_type, + filter_include_percentiles=filter_include_percentiles, + filter_queried=filter_queried, + filter_tags=filter_tags, + window_seconds=window_seconds, + page_size=page_size, + page_cursor=page_cursor, + ) + result: Any = api.list_tag_configurations(**kwargs) # type: ignore[reportUnknownMemberType] + return DatadogResponse(success=True, data=self._to_dict_safe(result)) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return DatadogResponse(success=False, error=str(e)) + def list_incidents(self, page_size: Optional[int] = None, page_offset: Optional[int] = None) -> DatadogResponse: + """List all incidents. [incidents]""" + try: + with ApiClient(self._config) as api_client: # type: ignore[reportUnknownMemberType] + api = IncidentsApi(api_client) # type: ignore[reportUnknownMemberType] + kwargs = self._params(page_size=page_size, page_offset=page_offset) + result: Any = api.list_incidents(**kwargs) # type: ignore[reportUnknownMemberType] + return DatadogResponse(success=True, data=self._to_dict_safe(result)) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return DatadogResponse(success=False, error=str(e)) + def get_incident(self, incident_id: str) -> DatadogResponse: + """Get a single incident by ID. [incidents]""" + try: + with ApiClient(self._config) as api_client: # type: ignore[reportUnknownMemberType] + api = IncidentsApi(api_client) # type: ignore[reportUnknownMemberType] + result: Any = api.get_incident(incident_id=incident_id) # type: ignore[reportUnknownMemberType] + return DatadogResponse(success=True, data=self._to_dict_safe(result)) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return DatadogResponse(success=False, error=str(e)) + def search_logs(self, filter_query: Optional[str] = None, filter_from: Optional[str] = None, filter_to: Optional[str] = None, sort: Optional[str] = None, page_cursor: Optional[str] = None, page_limit: Optional[int] = None) -> DatadogResponse: + """Search and filter logs. [logs]""" + try: + with ApiClient(self._config) as api_client: # type: ignore[reportUnknownMemberType] + api = LogsApi(api_client) # type: ignore[reportUnknownMemberType] + filter_obj = LogsQueryFilter() # type: ignore[reportUnknownMemberType] + if filter_query is not None: + filter_obj.query = filter_query # type: ignore[reportUnknownMemberType] + if filter_from is not None: + filter_obj._from = filter_from # type: ignore[reportUnknownMemberType] + if filter_to is not None: + filter_obj.to = filter_to # type: ignore[reportUnknownMemberType] + body_kwargs: Dict[str, Any] = {'filter': filter_obj} + if sort is not None: + body_kwargs['sort'] = LogsSort(sort) # type: ignore[reportUnknownMemberType] + if page_cursor is not None or page_limit is not None: + page_obj = LogsListRequestPage() # type: ignore[reportUnknownMemberType] + if page_cursor is not None: + page_obj.cursor = page_cursor # type: ignore[reportUnknownMemberType] + if page_limit is not None: + page_obj.limit = page_limit # type: ignore[reportUnknownMemberType] + body_kwargs['page'] = page_obj + request_body = LogsListRequest(**body_kwargs) # type: ignore[reportUnknownMemberType] + result: Any = api.list_logs(body=request_body) # type: ignore[reportUnknownMemberType] + return DatadogResponse(success=True, data=self._to_dict_safe(result)) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return DatadogResponse(success=False, error=str(e)) + def list_synthetics_tests(self, page_size: Optional[int] = None, page_number: Optional[int] = None) -> DatadogResponse: + """List all Synthetics tests. [synthetics]""" + try: + with ApiClient(self._config) as api_client: # type: ignore[reportUnknownMemberType] + api = SyntheticsApi(api_client) # type: ignore[reportUnknownMemberType] + kwargs = self._params(page_size=page_size, page_number=page_number) + result: Any = api.list_tests(**kwargs) # type: ignore[reportUnknownMemberType] + return DatadogResponse(success=True, data=self._to_dict_safe(result)) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return DatadogResponse(success=False, error=str(e)) + def get_synthetics_test(self, public_id: str) -> DatadogResponse: + """Get a single Synthetics test by public ID. [synthetics]""" + try: + with ApiClient(self._config) as api_client: # type: ignore[reportUnknownMemberType] + api = SyntheticsApi(api_client) # type: ignore[reportUnknownMemberType] + result: Any = api.get_test(public_id=public_id) # type: ignore[reportUnknownMemberType] + return DatadogResponse(success=True, data=self._to_dict_safe(result)) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return DatadogResponse(success=False, error=str(e)) + def list_downtimes(self, current_only: Optional[bool] = None, with_creator: Optional[bool] = None) -> DatadogResponse: + """List all scheduled downtimes. [downtimes]""" + try: + with ApiClient(self._config) as api_client: # type: ignore[reportUnknownMemberType] + api = DowntimesApi(api_client) # type: ignore[reportUnknownMemberType] + kwargs = self._params(current_only=current_only, with_creator=with_creator) + result: Any = api.list_downtimes(**kwargs) # type: ignore[reportUnknownMemberType] + return DatadogResponse(success=True, data=self._to_dict_safe(result)) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return DatadogResponse(success=False, error=str(e)) + def list_service_definitions(self, page_size: Optional[int] = None, page_number: Optional[int] = None, schema_version: Optional[str] = None) -> DatadogResponse: + """List all service definitions. [service definitions]""" + try: + with ApiClient(self._config) as api_client: # type: ignore[reportUnknownMemberType] + api = ServiceDefinitionApi(api_client) # type: ignore[reportUnknownMemberType] + kwargs = self._params(page_size=page_size, page_number=page_number, schema_version=schema_version) + result: Any = api.list_service_definitions(**kwargs) # type: ignore[reportUnknownMemberType] + return DatadogResponse(success=True, data=self._to_dict_safe(result)) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return DatadogResponse(success=False, error=str(e)) diff --git a/backend/python/app/sources/external/datadog/example.py b/backend/python/app/sources/external/datadog/example.py new file mode 100644 index 000000000..28f9db08c --- /dev/null +++ b/backend/python/app/sources/external/datadog/example.py @@ -0,0 +1,129 @@ +# ruff: noqa +""" +Datadog API Usage Examples (SDK-based) + +This example demonstrates how to use the Datadog DataSource (backed by the +official datadog-api-client SDK) covering: +- Authentication (API Key + Application Key) +- Initializing the Client and DataSource +- Listing Dashboards +- Listing Monitors +- Listing Users +- Listing Hosts +- Listing Active Metrics + +Prerequisites: +1. Create API and Application keys at https://app.datadoghq.com/organization-settings/api-keys +2. Set DD_API_KEY and DD_APP_KEY environment variables +3. Optionally set DD_SITE for non-US1 sites (e.g., datadoghq.eu, us3.datadoghq.com) +""" + +import json +import os + +from app.sources.client.datadog.datadog import ( + DatadogApiKeyConfig, + DatadogClient, + DatadogResponse, +) +from app.sources.external.datadog.datadog import DatadogDataSource + +# --- Configuration --- +API_KEY = os.getenv("DD_API_KEY") +APP_KEY = os.getenv("DD_APP_KEY") +SITE = os.getenv("DD_SITE", "datadoghq.com") + + +def print_section(title: str) -> None: + print(f"\n{'-' * 80}") + print(f"| {title}") + print(f"{'-' * 80}") + + +def print_result(name: str, response: DatadogResponse, show_data: bool = True) -> None: + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + # Handle dict responses with common list keys + for key in ( + "dashboards", + "monitors", + "data", + "host_list", + "tests", + "series", + ): + if isinstance(data, dict) and key in data: + items = data[key] + if isinstance(items, list): + print(f" Found {len(items)} {key}.") + if items: + print( + f" Sample: {json.dumps(items[0], indent=2, default=str)[:400]}..." + ) + return + # Generic response + print(f" Data: {json.dumps(data, indent=2, default=str)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +def main() -> None: + # 1. Initialize Client + print_section("Initializing Datadog Client (SDK)") + + if not API_KEY or not APP_KEY: + print(" No valid authentication found.") + print(" Please set the following environment variables:") + print(" - DD_API_KEY (Datadog API key)") + print(" - DD_APP_KEY (Datadog Application key)") + print(" - DD_SITE (optional, default: datadoghq.com)") + return + + print(f" Using API Key authentication (site: {SITE})") + config = DatadogApiKeyConfig( + api_key=API_KEY, + app_key=APP_KEY, + site=SITE, + ) + + client = DatadogClient.build_with_config(config) + data_source = DatadogDataSource(client) + print(" Client initialized successfully.") + + # 2. List Dashboards + print_section("Dashboards") + dashboards_resp = data_source.list_dashboards() + print_result("List Dashboards", dashboards_resp) + + # 3. List Monitors + print_section("Monitors") + monitors_resp = data_source.list_monitors(page_size=5) + print_result("List Monitors", monitors_resp) + + # 4. List Users + print_section("Users") + users_resp = data_source.list_users(page_size=10) + print_result("List Users", users_resp) + + # 5. List Hosts + print_section("Hosts") + hosts_resp = data_source.list_hosts(count=10) + print_result("List Hosts", hosts_resp) + + # 6. List Active Metrics + print_section("Active Metrics") + metrics_resp = data_source.list_active_metrics(page_size=10) + print_result("List Active Metrics", metrics_resp) + + print("\n" + "=" * 80) + print(" All Datadog API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + main() diff --git a/backend/python/app/sources/external/datastax/code_generator.py b/backend/python/app/sources/external/datastax/code_generator.py new file mode 100644 index 000000000..498ea096d --- /dev/null +++ b/backend/python/app/sources/external/datastax/code_generator.py @@ -0,0 +1,339 @@ +# ruff: noqa +""" +DataStax DataSource Code Generator + +Defines DataStax Astra DB SDK method specifications and generates the DataSource +wrapper class (datastax.py) from them. + +Methods wrap the official astrapy Python package (Data API). +""" + +from __future__ import annotations + +# Each spec: +# name: method name +# section: section heading +# doc: docstring line +# body: list of Python lines forming the method body (using self._sdk, self._database) +# params: list of (param_name, param_type, default_or_None, doc_line) +METHODS = [ + # ---- Databases ---- + { + "name": "list_databases", + "section": "Databases", + "doc": "List all databases accessible via the admin API.", + "body": [ + "admin = self._sdk.get_admin()", + "dbs = list(admin.list_databases())", + "result = [str(db) for db in dbs]", + ], + "params": [], + }, + # ---- Collections ---- + { + "name": "list_collections", + "section": "Collections", + "doc": "List all collection names in the database.", + "body": [ + "result = self._database.list_collection_names()", + ], + "params": [], + }, + { + "name": "get_collection_info", + "section": "Collections", + "doc": "Get information about a collection by listing and filtering.", + "body": [ + "names = self._database.list_collection_names()", + "found = collection_name in names", + 'result = {"name": collection_name, "exists": found}', + ], + "params": [("collection_name", "str", None, "The collection name")], + }, + # ---- Documents ---- + { + "name": "find_documents", + "section": "Documents", + "doc": "Find documents in a collection with optional filter.", + "body": [ + "collection = self._database.get_collection(collection_name)", + "filter_dict = filter or {}", + "cursor = collection.find(filter_dict, limit=limit or 20)", + "result = list(cursor)", + ], + "params": [ + ("collection_name", "str", None, "The collection name"), + ("filter", "dict[str, object] | None", "None", "Filter criteria"), + ("limit", "int | None", "None", "Maximum documents to return"), + ], + }, + { + "name": "find_one", + "section": "Documents", + "doc": "Find a single document by filter.", + "body": [ + "collection = self._database.get_collection(collection_name)", + "filter_dict = filter or {}", + "result = collection.find_one(filter_dict)", + ], + "params": [ + ("collection_name", "str", None, "The collection name"), + ("filter", "dict[str, object] | None", "None", "Filter criteria"), + ], + }, + { + "name": "find_by_id", + "section": "Documents", + "doc": "Find a single document by its ``_id``.", + "body": [ + "collection = self._database.get_collection(collection_name)", + 'result = collection.find_one({"_id": document_id})', + ], + "params": [ + ("collection_name", "str", None, "The collection name"), + ("document_id", "str", None, "The document _id"), + ], + }, + { + "name": "insert_one", + "section": "Documents", + "doc": "Insert a single document into a collection.", + "body": [ + "collection = self._database.get_collection(collection_name)", + "insert_result = collection.insert_one(document)", + "result = insert_result", + ], + "params": [ + ("collection_name", "str", None, "The collection name"), + ("document", "dict[str, object]", None, "The document to insert"), + ], + }, + { + "name": "insert_many", + "section": "Documents", + "doc": "Insert multiple documents into a collection.", + "body": [ + "collection = self._database.get_collection(collection_name)", + "insert_result = collection.insert_many(documents)", + "result = insert_result", + ], + "params": [ + ("collection_name", "str", None, "The collection name"), + ("documents", "list[dict[str, object]]", None, "List of documents to insert"), + ], + }, + { + "name": "update_one", + "section": "Documents", + "doc": "Update a single document matching the filter.", + "body": [ + "collection = self._database.get_collection(collection_name)", + "update_result = collection.update_one(filter, update)", + "result = update_result", + ], + "params": [ + ("collection_name", "str", None, "The collection name"), + ("filter", "dict[str, object]", None, "Filter criteria"), + ("update", "dict[str, object]", None, "Update operations"), + ], + }, + { + "name": "delete_one", + "section": "Documents", + "doc": "Delete a single document matching the filter.", + "body": [ + "collection = self._database.get_collection(collection_name)", + "delete_result = collection.delete_one(filter)", + "result = delete_result", + ], + "params": [ + ("collection_name", "str", None, "The collection name"), + ("filter", "dict[str, object]", None, "Filter criteria"), + ], + }, + { + "name": "count_documents", + "section": "Documents", + "doc": "Count documents in a collection matching an optional filter.", + "body": [ + "collection = self._database.get_collection(collection_name)", + "filter_dict = filter or {}", + "result = collection.count_documents(filter_dict, upper_bound=upper_bound or 1000)", + ], + "params": [ + ("collection_name", "str", None, "The collection name"), + ("filter", "dict[str, object] | None", "None", "Filter criteria"), + ("upper_bound", "int | None", "None", "Upper bound for count estimation"), + ], + }, + # ---- Collection Management ---- + { + "name": "create_collection", + "section": "Collection Management", + "doc": "Create a new collection in the database.", + "body": [ + "collection = self._database.create_collection(collection_name)", + 'result = {"name": collection_name, "created": True}', + ], + "params": [ + ("collection_name", "str", None, "The collection name"), + ], + }, + { + "name": "drop_collection", + "section": "Collection Management", + "doc": "Drop (delete) a collection from the database.", + "body": [ + "self._database.drop_collection(collection_name)", + 'result = {"name": collection_name, "dropped": True}', + ], + "params": [ + ("collection_name", "str", None, "The collection name"), + ], + }, +] + + +def _gen_method(spec: dict) -> str: + """Generate a single method from a spec.""" + name = spec["name"] + doc = spec["doc"] + body_lines = spec["body"] + params = spec.get("params", []) + + # Build signature + sig_parts = ["self"] + has_kw_only = False + for p_name, p_type, p_default, _ in params: + if p_default is not None and not has_kw_only: + sig_parts.append("*") + has_kw_only = True + if p_default is None: + sig_parts.append(f"{p_name}: {p_type}") + else: + sig_parts.append(f"{p_name}: {p_type} = {p_default}") + + sig = ",\n ".join(sig_parts) + + # Build docstring args section + doc_args = "" + if params: + doc_args = "\n\n Args:\n" + for p_name, _, _, p_doc in params: + doc_args += f" {p_name}: {p_doc}\n" + + # Build body + body = "\n".join(f" {line}" for line in body_lines) + + return f''' + def {name}( + {sig}, + ) -> DataStaxResponse: + """{doc}{doc_args} + Returns: + DataStaxResponse with operation result + """ + try: +{body} + return DataStaxResponse(success=True, data=result) + except Exception as e: + return DataStaxResponse( + success=False, error=str(e), message="Failed to execute {name}" + ) +''' + + +def generate_datasource() -> str: + """Generate the full DataStax DataSource module code.""" + header = '''# ruff: noqa +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownParameterType=false +""" +DataStax Astra DB SDK DataSource - Auto-generated SDK wrapper + +Generated from DataStax SDK method specifications. +Wraps the official astrapy Python package. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Union, cast + +from astrapy import DataAPIClient + +from app.sources.client.datastax.datastax import DataStaxClient, DataStaxResponse + + +class DataStaxDataSource: + """DataStax Astra DB SDK DataSource + + Provides typed wrapper methods for DataStax Astra DB operations: + - Database listing + - Collection management + - Document CRUD operations + + All methods return DataStaxResponse objects. + """ + + def __init__(self, client_or_sdk: Union[DataStaxClient, DataAPIClient, object]) -> None: + """Initialize with DataStaxClient, raw SDK, or any wrapper with ``get_sdk()``. + + The ``api_endpoint`` must be provided either via the DataStaxClient wrapper + or passed when constructing with a raw SDK. + + Args: + client_or_sdk: DataStaxClient, DataAPIClient instance, or wrapper + """ + if isinstance(client_or_sdk, DataStaxClient): + self._sdk: DataAPIClient = client_or_sdk.get_sdk() + self._api_endpoint: str = client_or_sdk.get_api_endpoint() + elif isinstance(client_or_sdk, DataAPIClient): + self._sdk = client_or_sdk + self._api_endpoint = "" + elif hasattr(client_or_sdk, "get_sdk"): + self._sdk = cast(DataAPIClient, getattr(client_or_sdk, "get_sdk")()) + if hasattr(client_or_sdk, "get_api_endpoint"): + self._api_endpoint = str(getattr(client_or_sdk, "get_api_endpoint")()) + else: + self._api_endpoint = "" + else: + self._sdk = cast(DataAPIClient, client_or_sdk) + self._api_endpoint = "" + + # Lazily connect to the database + self._database = self._sdk.get_database(api_endpoint=self._api_endpoint) if self._api_endpoint else None + + def set_api_endpoint(self, api_endpoint: str) -> None: + """Set the API endpoint and connect to the database. + + Args: + api_endpoint: The database API endpoint URL + """ + self._api_endpoint = api_endpoint + self._database = self._sdk.get_database(api_endpoint=api_endpoint) + + def _ensure_database(self) -> None: + """Ensure a database connection is established.""" + if self._database is None: + raise ValueError( + "No database connection. Provide api_endpoint via DataStaxClient " + "or call set_api_endpoint()." + ) +''' + + methods = [] + current_section = None + for spec in METHODS: + section = spec.get("section", "") + if section and section != current_section: + current_section = section + methods.append( + f"\n # {'-' * 71}\n # {section}\n # {'-' * 71}" + ) + methods.append(_gen_method(spec)) + + return header + "\n".join(methods) + "\n" + + +if __name__ == "__main__": + print(generate_datasource()) diff --git a/backend/python/app/sources/external/datastax/datastax.py b/backend/python/app/sources/external/datastax/datastax.py new file mode 100644 index 000000000..5cfc62975 --- /dev/null +++ b/backend/python/app/sources/external/datastax/datastax.py @@ -0,0 +1,395 @@ +# ruff: noqa +""" +DataStax Astra DB SDK DataSource - Auto-generated SDK wrapper + +Generated from DataStax SDK method specifications. +Wraps the official astrapy Python package. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any, Union, cast + +from astrapy import DataAPIClient # type: ignore[reportMissingImports] + +from app.sources.client.datastax.datastax import DataStaxClient, DataStaxResponse + + +class DataStaxDataSource: + """DataStax Astra DB SDK DataSource + + Provides typed wrapper methods for DataStax Astra DB operations: + - Database listing + - Collection management + - Document CRUD operations + + All methods return DataStaxResponse objects. + """ + + def __init__(self, client_or_sdk: Union[DataStaxClient, DataAPIClient, object]) -> None: # type: ignore[reportUnknownParameterType] + """Initialize with DataStaxClient, raw SDK, or any wrapper with ``get_sdk()``. + + The ``api_endpoint`` must be provided either via the DataStaxClient wrapper + or passed when constructing with a raw SDK. + + Args: + client_or_sdk: DataStaxClient, DataAPIClient instance, or wrapper + """ + super().__init__() + if isinstance(client_or_sdk, DataStaxClient): + self._sdk: DataAPIClient = client_or_sdk.get_sdk() # type: ignore[reportUnknownMemberType] + self._api_endpoint: str = client_or_sdk.get_api_endpoint() + elif isinstance(client_or_sdk, DataAPIClient): # type: ignore[reportUnknownMemberType] + self._sdk = client_or_sdk # type: ignore[reportUnknownMemberType] + self._api_endpoint = "" + elif hasattr(client_or_sdk, "get_sdk"): # type: ignore[reportUnknownArgumentType] + self._sdk = cast(DataAPIClient, getattr(client_or_sdk, "get_sdk")()) # type: ignore[reportUnknownArgumentType] + if hasattr(client_or_sdk, "get_api_endpoint"): # type: ignore[reportUnknownArgumentType] + self._api_endpoint = str(getattr(client_or_sdk, "get_api_endpoint")()) # type: ignore[reportUnknownArgumentType] + else: + self._api_endpoint = "" + else: + self._sdk = cast(DataAPIClient, client_or_sdk) + self._api_endpoint = "" + + # Lazily connect to the database + self._database: Any = self._sdk.get_database(api_endpoint=self._api_endpoint) if self._api_endpoint else None # type: ignore[reportUnknownMemberType] + + def set_api_endpoint(self, api_endpoint: str) -> None: + """Set the API endpoint and connect to the database. + + Args: + api_endpoint: The database API endpoint URL + """ + self._api_endpoint = api_endpoint + self._database = self._sdk.get_database(api_endpoint=api_endpoint) # type: ignore[reportUnknownMemberType] + + def _ensure_database(self) -> None: + """Ensure a database connection is established.""" + if self._database is None: + raise ValueError( + "No database connection. Provide api_endpoint via DataStaxClient " + "or call set_api_endpoint()." + ) + + # ----------------------------------------------------------------------- + # Databases + # ----------------------------------------------------------------------- + + def list_databases( + self, + ) -> DataStaxResponse: + """List all databases accessible via the admin API. + Returns: + DataStaxResponse with operation result + """ + try: + admin = self._sdk.get_admin() # type: ignore[reportUnknownMemberType] + dbs = list(admin.list_databases()) # type: ignore[reportUnknownMemberType, reportUnknownArgumentType] + result = [str(db) for db in dbs] # type: ignore[reportUnknownVariableType, reportUnknownArgumentType] + return DataStaxResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return DataStaxResponse( + success=False, error=str(e), message="Failed to execute list_databases" + ) + + + # ----------------------------------------------------------------------- + # Collections + # ----------------------------------------------------------------------- + + def list_collections( + self, + ) -> DataStaxResponse: + """List all collection names in the database. + Returns: + DataStaxResponse with operation result + """ + try: + result: Any = self._database.list_collection_names() + return DataStaxResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return DataStaxResponse( + success=False, error=str(e), message="Failed to execute list_collections" + ) + + + def get_collection_info( + self, + collection_name: str, + ) -> DataStaxResponse: + """Get information about a collection by listing and filtering. + + Args: + collection_name: The collection name + + Returns: + DataStaxResponse with operation result + """ + try: + names = self._database.list_collection_names() + found = collection_name in names + result = {"name": collection_name, "exists": found} + return DataStaxResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return DataStaxResponse( + success=False, error=str(e), message="Failed to execute get_collection_info" + ) + + + # ----------------------------------------------------------------------- + # Documents + # ----------------------------------------------------------------------- + + def find_documents( + self, + collection_name: str, + *, + filter: dict[str, object] | None = None, + limit: int | None = None, + ) -> DataStaxResponse: + """Find documents in a collection with optional filter. + + Args: + collection_name: The collection name + filter: Filter criteria + limit: Maximum documents to return + + Returns: + DataStaxResponse with operation result + """ + try: + collection = self._database.get_collection(collection_name) + filter_dict = filter or {} + cursor = collection.find(filter_dict, limit=limit or 20) + result: Any = list(cursor) + return DataStaxResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return DataStaxResponse( + success=False, error=str(e), message="Failed to execute find_documents" + ) + + + def find_one( + self, + collection_name: str, + *, + filter: dict[str, object] | None = None, + ) -> DataStaxResponse: + """Find a single document by filter. + + Args: + collection_name: The collection name + filter: Filter criteria + + Returns: + DataStaxResponse with operation result + """ + try: + collection = self._database.get_collection(collection_name) + filter_dict = filter or {} + result: Any = collection.find_one(filter_dict) + return DataStaxResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return DataStaxResponse( + success=False, error=str(e), message="Failed to execute find_one" + ) + + + def find_by_id( + self, + collection_name: str, + document_id: str, + ) -> DataStaxResponse: + """Find a single document by its ``_id``. + + Args: + collection_name: The collection name + document_id: The document _id + + Returns: + DataStaxResponse with operation result + """ + try: + collection = self._database.get_collection(collection_name) + result: Any = collection.find_one({"_id": document_id}) + return DataStaxResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return DataStaxResponse( + success=False, error=str(e), message="Failed to execute find_by_id" + ) + + + def insert_one( + self, + collection_name: str, + document: dict[str, object], + ) -> DataStaxResponse: + """Insert a single document into a collection. + + Args: + collection_name: The collection name + document: The document to insert + + Returns: + DataStaxResponse with operation result + """ + try: + collection = self._database.get_collection(collection_name) + result: Any = collection.insert_one(document) + return DataStaxResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return DataStaxResponse( + success=False, error=str(e), message="Failed to execute insert_one" + ) + + + def insert_many( + self, + collection_name: str, + documents: list[dict[str, object]], + ) -> DataStaxResponse: + """Insert multiple documents into a collection. + + Args: + collection_name: The collection name + documents: List of documents to insert + + Returns: + DataStaxResponse with operation result + """ + try: + collection = self._database.get_collection(collection_name) + result: Any = collection.insert_many(documents) + return DataStaxResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return DataStaxResponse( + success=False, error=str(e), message="Failed to execute insert_many" + ) + + + def update_one( + self, + collection_name: str, + filter: dict[str, object], + update: dict[str, object], + ) -> DataStaxResponse: + """Update a single document matching the filter. + + Args: + collection_name: The collection name + filter: Filter criteria + update: Update operations + + Returns: + DataStaxResponse with operation result + """ + try: + collection = self._database.get_collection(collection_name) + result: Any = collection.update_one(filter, update) + return DataStaxResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return DataStaxResponse( + success=False, error=str(e), message="Failed to execute update_one" + ) + + + def delete_one( + self, + collection_name: str, + filter: dict[str, object], + ) -> DataStaxResponse: + """Delete a single document matching the filter. + + Args: + collection_name: The collection name + filter: Filter criteria + + Returns: + DataStaxResponse with operation result + """ + try: + collection = self._database.get_collection(collection_name) + result: Any = collection.delete_one(filter) + return DataStaxResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return DataStaxResponse( + success=False, error=str(e), message="Failed to execute delete_one" + ) + + + def count_documents( + self, + collection_name: str, + *, + filter: dict[str, object] | None = None, + upper_bound: int | None = None, + ) -> DataStaxResponse: + """Count documents in a collection matching an optional filter. + + Args: + collection_name: The collection name + filter: Filter criteria + upper_bound: Upper bound for count estimation + + Returns: + DataStaxResponse with operation result + """ + try: + collection = self._database.get_collection(collection_name) + filter_dict = filter or {} + result: Any = collection.count_documents(filter_dict, upper_bound=upper_bound or 1000) + return DataStaxResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return DataStaxResponse( + success=False, error=str(e), message="Failed to execute count_documents" + ) + + + # ----------------------------------------------------------------------- + # Collection Management + # ----------------------------------------------------------------------- + + def create_collection( + self, + collection_name: str, + ) -> DataStaxResponse: + """Create a new collection in the database. + + Args: + collection_name: The collection name + + Returns: + DataStaxResponse with operation result + """ + try: + self._database.create_collection(collection_name) + result = {"name": collection_name, "created": True} + return DataStaxResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return DataStaxResponse( + success=False, error=str(e), message="Failed to execute create_collection" + ) + + + def drop_collection( + self, + collection_name: str, + ) -> DataStaxResponse: + """Drop (delete) a collection from the database. + + Args: + collection_name: The collection name + + Returns: + DataStaxResponse with operation result + """ + try: + self._database.drop_collection(collection_name) + result = {"name": collection_name, "dropped": True} + return DataStaxResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return DataStaxResponse( + success=False, error=str(e), message="Failed to execute drop_collection" + ) diff --git a/backend/python/app/sources/external/datastax/example.py b/backend/python/app/sources/external/datastax/example.py new file mode 100644 index 000000000..bb554c80a --- /dev/null +++ b/backend/python/app/sources/external/datastax/example.py @@ -0,0 +1,114 @@ +# ruff: noqa + +""" +DataStax Astra DB API Usage Examples + +This example demonstrates how to use the DataStax DataSource to interact +with DataStax Astra DB via the official astrapy SDK, covering: +- Authentication (Application Token) +- Initializing the Client and DataSource +- Listing collections +- Finding, inserting, and counting documents + +Prerequisites: +1. Create an Astra DB database at https://astra.datastax.com +2. Generate an Application Token +3. Set the following environment variables: + - DATASTAX_TOKEN: Application token (e.g. AstraCS:...) + - DATASTAX_API_ENDPOINT: Database API endpoint URL +""" + +import json +import os + +from app.sources.client.datastax.datastax import ( + DataStaxClient, + DataStaxResponse, + DataStaxTokenConfig, +) +from app.sources.external.datastax.datastax import DataStaxDataSource + +# --- Configuration --- +TOKEN = os.getenv("DATASTAX_TOKEN") +API_ENDPOINT = os.getenv("DATASTAX_API_ENDPOINT") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: DataStaxResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + print(f" Data: {json.dumps(data, indent=2, default=str)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +def main() -> None: + # 1. Initialize Client + print_section("Initializing DataStax Client") + + if not TOKEN or not API_ENDPOINT: + print(" Missing required environment variables.") + print(" Please set:") + print(" - DATASTAX_TOKEN (Application Token, e.g. AstraCS:...)") + print(" - DATASTAX_API_ENDPOINT (Database API endpoint URL)") + return + + print(" Using Application Token authentication") + config = DataStaxTokenConfig( + token=TOKEN, + api_endpoint=API_ENDPOINT, + ) + + client = DataStaxClient.build_with_config(config) + data_source = DataStaxDataSource(client) + print(" Client initialized successfully.") + + # 2. List Collections + print_section("Collections") + collections_resp = data_source.list_collections() + print_result("List Collections", collections_resp) + + # Extract first collection for further exploration + collection_name = None + if collections_resp.success and collections_resp.data: + data = collections_resp.data + if isinstance(data, list) and data: + collection_name = str(data[0]) + + if collection_name: + print(f" Using collection: {collection_name}") + + # 3. Find Documents + print_section(f"Documents in {collection_name}") + docs_resp = data_source.find_documents( + collection_name=collection_name, + limit=5, + ) + print_result("Find Documents", docs_resp) + + # 4. Count Documents + print_section(f"Count Documents in {collection_name}") + count_resp = data_source.count_documents( + collection_name=collection_name, + ) + print_result("Count Documents", count_resp) + else: + print(" No collections found. Skipping document operations.") + + print("\n" + "=" * 80) + print(" All DataStax API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + main() diff --git a/backend/python/app/sources/external/datastax/run_generator.py b/backend/python/app/sources/external/datastax/run_generator.py new file mode 100644 index 000000000..d51b0bcfa --- /dev/null +++ b/backend/python/app/sources/external/datastax/run_generator.py @@ -0,0 +1,25 @@ +# ruff: noqa: T201 +"""Runner script to generate the DataStax DataSource wrapper. + +Execute this script to regenerate datastax.py from the method definitions +in code_generator.py. + +Usage: + python -m app.sources.external.datastax.run_generator +""" + +from app.sources.external.datastax.code_generator import generate_datasource + + +def main() -> None: + """Generate the DataStax DataSource file.""" + code = generate_datasource() + output_path = __file__.replace("run_generator.py", "datastax.py") + with open(output_path, "w") as f: + f.write(code) + print(f"Generated DataStax DataSource -> {output_path}") + print(f" Total characters: {len(code)}") + + +if __name__ == "__main__": + main() diff --git a/backend/python/app/sources/external/docusign/docusign.py b/backend/python/app/sources/external/docusign/docusign.py new file mode 100644 index 000000000..54784f4aa --- /dev/null +++ b/backend/python/app/sources/external/docusign/docusign.py @@ -0,0 +1,1266 @@ +# ruff: noqa +""" +DocuSign Unified DataSource - Auto-generated API wrapper + +Covers all DocuSign APIs: +- eSignature (SDK-based via docusign-esign) +- Admin, Rooms, Click, Monitor, WebForms (HTTP-based) + +All eSign methods are synchronous (SDK). All HTTP methods are async. +""" + +from __future__ import annotations + +from typing import Any, cast + +import docusign_esign # type: ignore[reportMissingImports] +from docusign_esign import ApiClient # type: ignore[reportMissingImports] + +from app.sources.client.docusign.docusign import DocuSignClient, DocuSignResponse +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class DocuSignDataSource: + """DocuSign Unified DataSource + + Provides wrapper methods for all DocuSign API operations: + - eSignature: Envelopes, Templates, Users, Folders, Brands (SDK-based, sync) + - Admin: Organizations, Users, Groups, Permissions (HTTP-based, async) + - Rooms: Rooms, Documents, Templates, Roles (HTTP-based, async) + - Click: Clickwraps, Agreements, Service Info (HTTP-based, async) + - Monitor: Audit stream events (HTTP-based, async) + - WebForms: Forms, Instances (HTTP-based, async) + + All methods return DocuSignResponse objects. + """ + + # Base URLs for non-eSign APIs + ADMIN_BASE_URL = "https://api-d.docusign.net/management" + ROOMS_BASE_URL = "https://demo.rooms.docusign.com/restapi" + CLICK_BASE_URL = "https://demo.docusign.net/clickapi" + MONITOR_BASE_URL = "https://lens-d.docusign.net" + WEBFORMS_BASE_URL = "https://apps-d.docusign.com/api/webforms/v1.1" + + def __init__(self, client: DocuSignClient) -> None: + """Initialize with DocuSignClient. + + Args: + client: DocuSignClient instance with configured authentication + """ + super().__init__() + self._client = client + # eSign SDK + self._sdk: ApiClient = cast(ApiClient, client.get_client().get_sdk()) + self._account_id: str = client.get_client().get_account_id() + # Lazy HTTP clients for each API + self._admin_http: HTTPClient | None = None + self._rooms_http: HTTPClient | None = None + self._click_http: HTTPClient | None = None + self._monitor_http: HTTPClient | None = None + self._webforms_http: HTTPClient | None = None + + # Lazy SDK API instances + self._envelopes_api: Any = None + self._templates_api: Any = None + self._users_api: Any = None + self._folders_api: Any = None + self._accounts_api: Any = None + + # ---- lazy HTTP client accessors ---- + + def _get_admin_http(self) -> HTTPClient: + if self._admin_http is None: + self._admin_http = self._client.get_client().get_http_client(self.ADMIN_BASE_URL) + return self._admin_http + + def _get_rooms_http(self) -> HTTPClient: + if self._rooms_http is None: + self._rooms_http = self._client.get_client().get_http_client(self.ROOMS_BASE_URL) + return self._rooms_http + + def _get_click_http(self) -> HTTPClient: + if self._click_http is None: + self._click_http = self._client.get_client().get_http_client(self.CLICK_BASE_URL) + return self._click_http + + def _get_monitor_http(self) -> HTTPClient: + if self._monitor_http is None: + self._monitor_http = self._client.get_client().get_http_client(self.MONITOR_BASE_URL) + return self._monitor_http + + def _get_webforms_http(self) -> HTTPClient: + if self._webforms_http is None: + self._webforms_http = self._client.get_client().get_http_client(self.WEBFORMS_BASE_URL) + return self._webforms_http + + # ---- lazy SDK API accessors ---- + + @property + def envelopes_api(self) -> Any: + if self._envelopes_api is None: + self._envelopes_api = docusign_esign.EnvelopesApi(self._sdk) # type: ignore[reportUnknownMemberType] + return self._envelopes_api # type: ignore[reportUnknownVariableType, reportUnknownMemberType] + + @property + def templates_api(self) -> Any: + if self._templates_api is None: + self._templates_api = docusign_esign.TemplatesApi(self._sdk) # type: ignore[reportUnknownMemberType] + return self._templates_api # type: ignore[reportUnknownVariableType, reportUnknownMemberType] + + @property + def users_api(self) -> Any: + if self._users_api is None: + self._users_api = docusign_esign.UsersApi(self._sdk) # type: ignore[reportUnknownMemberType] + return self._users_api # type: ignore[reportUnknownVariableType, reportUnknownMemberType] + + @property + def folders_api(self) -> Any: + if self._folders_api is None: + self._folders_api = docusign_esign.FoldersApi(self._sdk) # type: ignore[reportUnknownMemberType] + return self._folders_api # type: ignore[reportUnknownVariableType, reportUnknownMemberType] + + @property + def accounts_api(self) -> Any: + if self._accounts_api is None: + self._accounts_api = docusign_esign.AccountsApi(self._sdk) # type: ignore[reportUnknownMemberType] + return self._accounts_api # type: ignore[reportUnknownVariableType, reportUnknownMemberType] + + # ---- helpers ---- + + def get_data_source(self) -> 'DocuSignDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> DocuSignClient: + """Return the underlying DocuSignClient.""" + return self._client + + @staticmethod + def _params(**kwargs: object) -> dict[str, object]: + """Filter out Nones to avoid overriding SDK defaults.""" + out: dict[str, object] = {} + for k, v in kwargs.items(): + if v is None: + continue + if isinstance(v, (list, dict)) and len(v) == 0: # type: ignore[reportUnknownArgumentType] + continue + out[k] = v + return out + + # ---- eSign SDK methods (synchronous) ---- + + def list_envelopes(self, from_date: str, to_date: str | None = None, status: str | None = None, search_text: str | None = None, count: str | None = None, start_position: str | None = None, order: str | None = None, order_by: str | None = None, folder_ids: str | None = None) -> DocuSignResponse: + """List envelopes for the account. from_date is required by the API. [eSign]""" + try: + params = self._params(from_date=from_date, to_date=to_date, status=status, search_text=search_text, count=count, start_position=start_position, order=order, order_by=order_by, folder_ids=folder_ids) + result: Any = self.envelopes_api.list_status_changes(account_id=self._account_id, **params) + return DocuSignResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return DocuSignResponse(success=False, error=str(e), message="SDK call failed") + + def get_envelope(self, envelope_id: str) -> DocuSignResponse: + """Get details for a specific envelope. [eSign]""" + try: + result: Any = self.envelopes_api.get_envelope(account_id=self._account_id, envelope_id=envelope_id) + return DocuSignResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return DocuSignResponse(success=False, error=str(e), message="SDK call failed") + + def create_envelope(self, envelope_definition: dict[str, object]) -> DocuSignResponse: + """Create and optionally send a new envelope from an envelope definition dict. [eSign]""" + try: + body: Any = docusign_esign.EnvelopeDefinition(**envelope_definition) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + result: Any = self.envelopes_api.create_envelope(account_id=self._account_id, envelope_definition=body) + return DocuSignResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return DocuSignResponse(success=False, error=str(e), message="SDK call failed") + + def update_envelope(self, envelope_id: str, envelope: dict[str, object]) -> DocuSignResponse: + """Update an existing envelope (e.g. change status to sent or voided). [eSign]""" + try: + body: Any = docusign_esign.Envelope(**envelope) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + result: Any = self.envelopes_api.update(account_id=self._account_id, envelope_id=envelope_id, envelope=body) + return DocuSignResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return DocuSignResponse(success=False, error=str(e), message="SDK call failed") + + def list_envelope_documents(self, envelope_id: str) -> DocuSignResponse: + """List documents in an envelope. [eSign]""" + try: + result: Any = self.envelopes_api.list_documents(account_id=self._account_id, envelope_id=envelope_id) + return DocuSignResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return DocuSignResponse(success=False, error=str(e), message="SDK call failed") + + def get_envelope_document(self, envelope_id: str, document_id: str) -> DocuSignResponse: + """Download a specific document from an envelope. [eSign]""" + try: + result: Any = self.envelopes_api.get_document(account_id=self._account_id, envelope_id=envelope_id, document_id=document_id) + return DocuSignResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return DocuSignResponse(success=False, error=str(e), message="SDK call failed") + + def list_envelope_recipients(self, envelope_id: str) -> DocuSignResponse: + """List recipients for an envelope. [eSign]""" + try: + result: Any = self.envelopes_api.list_recipients(account_id=self._account_id, envelope_id=envelope_id) + return DocuSignResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return DocuSignResponse(success=False, error=str(e), message="SDK call failed") + + def get_envelope_audit_events(self, envelope_id: str) -> DocuSignResponse: + """Get audit trail events for an envelope. [eSign]""" + try: + result: Any = self.envelopes_api.list_audit_events(account_id=self._account_id, envelope_id=envelope_id) + return DocuSignResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return DocuSignResponse(success=False, error=str(e), message="SDK call failed") + + def list_templates(self, count: str | None = None, start_position: str | None = None, search_text: str | None = None, folder: str | None = None, order: str | None = None, order_by: str | None = None) -> DocuSignResponse: + """List templates for the account. [eSign]""" + try: + params = self._params(count=count, start_position=start_position, search_text=search_text, folder=folder, order=order, order_by=order_by) + result: Any = self.templates_api.list_templates(account_id=self._account_id, **params) + return DocuSignResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return DocuSignResponse(success=False, error=str(e), message="SDK call failed") + + def get_template(self, template_id: str) -> DocuSignResponse: + """Get details for a specific template. [eSign]""" + try: + result: Any = self.templates_api.get(account_id=self._account_id, template_id=template_id) + return DocuSignResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return DocuSignResponse(success=False, error=str(e), message="SDK call failed") + + def list_users(self, count: str | None = None, start_position: str | None = None, status: str | None = None, email: str | None = None) -> DocuSignResponse: + """List users in the account. [eSign]""" + try: + params = self._params(count=count, start_position=start_position, status=status, email=email) + result: Any = self.users_api.list(account_id=self._account_id, **params) + return DocuSignResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return DocuSignResponse(success=False, error=str(e), message="SDK call failed") + + def get_user(self, user_id: str) -> DocuSignResponse: + """Get details for a specific user. [eSign]""" + try: + result: Any = self.users_api.get_information(account_id=self._account_id, user_id=user_id) + return DocuSignResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return DocuSignResponse(success=False, error=str(e), message="SDK call failed") + + def list_folders(self) -> DocuSignResponse: + """List folders in the account. [eSign]""" + try: + result: Any = self.folders_api.list(account_id=self._account_id) + return DocuSignResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return DocuSignResponse(success=False, error=str(e), message="SDK call failed") + + def list_folder_items(self, folder_id: str, from_date: str | None = None, to_date: str | None = None, status: str | None = None, search_text: str | None = None, count: str | None = None, start_position: str | None = None) -> DocuSignResponse: + """List items (envelopes) in a specific folder. [eSign]""" + try: + params = self._params(from_date=from_date, to_date=to_date, status=status, search_text=search_text, count=count, start_position=start_position) + result: Any = self.folders_api.list_items(account_id=self._account_id, folder_id=folder_id, **params) + return DocuSignResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return DocuSignResponse(success=False, error=str(e), message="SDK call failed") + + def list_brands(self) -> DocuSignResponse: + """List brands for the account. [eSign]""" + try: + result: Any = self.accounts_api.list_brands(account_id=self._account_id) + return DocuSignResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return DocuSignResponse(success=False, error=str(e), message="SDK call failed") + + def list_custom_fields(self) -> DocuSignResponse: + """List custom fields for the account. [eSign]""" + try: + result: Any = self.accounts_api.list_custom_fields(account_id=self._account_id) + return DocuSignResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return DocuSignResponse(success=False, error=str(e), message="SDK call failed") + + # ---- HTTP-based methods (async) ---- + + async def admin_get_organizations( + self + ) -> DocuSignResponse: + """Get all organizations [Admin] + + Returns: + DocuSignResponse with operation result + """ + base_url = self.ADMIN_BASE_URL + url = base_url + "/v2/organizations" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self._get_admin_http().execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return DocuSignResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed admin_get_organizations" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return DocuSignResponse(success=False, error=str(e), message="Failed to execute admin_get_organizations") + + async def admin_get_users( + self, + org_id: str, + account_id: str | None = None, + email: str | None = None, + start: int | None = None, + take: int | None = None + ) -> DocuSignResponse: + """Get users for an organization [Admin] + + Args: + org_id: Organization ID + account_id: Filter by account ID + email: Filter by email address + start: Start index for pagination + take: Number of results to return + + Returns: + DocuSignResponse with operation result + """ + query_params: dict[str, object] = {} + if account_id is not None: + query_params['account_id'] = account_id + if email is not None: + query_params['email'] = email + if start is not None: + query_params['start'] = start + if take is not None: + query_params['take'] = take + + base_url = self.ADMIN_BASE_URL + url = base_url + f"/v2.1/organizations/{org_id}/users" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, # type: ignore[reportArgumentType] + ) + response = await self._get_admin_http().execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return DocuSignResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed admin_get_users" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return DocuSignResponse(success=False, error=str(e), message="Failed to execute admin_get_users") + + async def admin_get_user_profile( + self, + org_id: str, + email: str | None = None + ) -> DocuSignResponse: + """Get user profile by email [Admin] + + Args: + org_id: Organization ID + email: Email address to look up + + Returns: + DocuSignResponse with operation result + """ + query_params: dict[str, object] = {} + if email is not None: + query_params['email'] = email + + base_url = self.ADMIN_BASE_URL + url = base_url + f"/v2.1/organizations/{org_id}/users/profile" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, # type: ignore[reportArgumentType] + ) + response = await self._get_admin_http().execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return DocuSignResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed admin_get_user_profile" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return DocuSignResponse(success=False, error=str(e), message="Failed to execute admin_get_user_profile") + + async def admin_get_ds_groups( + self, + org_id: str, + account_id: str + ) -> DocuSignResponse: + """Get DocuSign groups for an account [Admin] + + Args: + org_id: Organization ID + account_id: Account ID + + Returns: + DocuSignResponse with operation result + """ + base_url = self.ADMIN_BASE_URL + url = base_url + f"/v2.1/organizations/{org_id}/accounts/{account_id}/dsGroups" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self._get_admin_http().execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return DocuSignResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed admin_get_ds_groups" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return DocuSignResponse(success=False, error=str(e), message="Failed to execute admin_get_ds_groups") + + async def admin_get_permission_profiles( + self, + org_id: str, + account_id: str + ) -> DocuSignResponse: + """Get permission profiles for an account [Admin] + + Args: + org_id: Organization ID + account_id: Account ID + + Returns: + DocuSignResponse with operation result + """ + base_url = self.ADMIN_BASE_URL + url = base_url + f"/v2.1/organizations/{org_id}/accounts/{account_id}/products/permission_profiles" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self._get_admin_http().execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return DocuSignResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed admin_get_permission_profiles" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return DocuSignResponse(success=False, error=str(e), message="Failed to execute admin_get_permission_profiles") + + async def rooms_get_rooms( + self, + account_id: str, + count: int | None = None, + startPosition: int | None = None, + roomStatus: str | None = None + ) -> DocuSignResponse: + """Get rooms for the account [Rooms] + + Args: + account_id: Account ID + count: Number of results to return + startPosition: Start position for pagination + roomStatus: Filter by room status + + Returns: + DocuSignResponse with operation result + """ + query_params: dict[str, object] = {} + if count is not None: + query_params['count'] = count + if startPosition is not None: + query_params['startPosition'] = startPosition + if roomStatus is not None: + query_params['roomStatus'] = roomStatus + + base_url = self.ROOMS_BASE_URL + url = base_url + f"/v2/accounts/{account_id}/rooms" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, # type: ignore[reportArgumentType] + ) + response = await self._get_rooms_http().execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return DocuSignResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed rooms_get_rooms" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return DocuSignResponse(success=False, error=str(e), message="Failed to execute rooms_get_rooms") + + async def rooms_get_room( + self, + account_id: str, + room_id: str + ) -> DocuSignResponse: + """Get a specific room [Rooms] + + Args: + account_id: Account ID + room_id: Room ID + + Returns: + DocuSignResponse with operation result + """ + base_url = self.ROOMS_BASE_URL + url = base_url + f"/v2/accounts/{account_id}/rooms/{room_id}" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self._get_rooms_http().execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return DocuSignResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed rooms_get_room" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return DocuSignResponse(success=False, error=str(e), message="Failed to execute rooms_get_room") + + async def rooms_create_room( + self, + account_id: str, + name: str, + roleId: int, + transactionSideId: str | None = None + ) -> DocuSignResponse: + """Create a new room [Rooms] + + Args: + account_id: Account ID + name: Room name + roleId: Role ID for the room creator + transactionSideId: Transaction side ID + + Returns: + DocuSignResponse with operation result + """ + base_url = self.ROOMS_BASE_URL + url = base_url + f"/v2/accounts/{account_id}/rooms" + + body: dict[str, object] = {} + body['name'] = name + body['roleId'] = roleId + if transactionSideId is not None: + body['transactionSideId'] = transactionSideId + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self._get_rooms_http().execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return DocuSignResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed rooms_create_room" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return DocuSignResponse(success=False, error=str(e), message="Failed to execute rooms_create_room") + + async def rooms_delete_room( + self, + account_id: str, + room_id: str + ) -> DocuSignResponse: + """Delete a room [Rooms] + + Args: + account_id: Account ID + room_id: Room ID + + Returns: + DocuSignResponse with operation result + """ + base_url = self.ROOMS_BASE_URL + url = base_url + f"/v2/accounts/{account_id}/rooms/{room_id}" + + try: + request = HTTPRequest( + method="DELETE", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self._get_rooms_http().execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return DocuSignResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed rooms_delete_room" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return DocuSignResponse(success=False, error=str(e), message="Failed to execute rooms_delete_room") + + async def rooms_get_room_documents( + self, + account_id: str, + room_id: str + ) -> DocuSignResponse: + """Get documents in a room [Rooms] + + Args: + account_id: Account ID + room_id: Room ID + + Returns: + DocuSignResponse with operation result + """ + base_url = self.ROOMS_BASE_URL + url = base_url + f"/v2/accounts/{account_id}/rooms/{room_id}/documents" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self._get_rooms_http().execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return DocuSignResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed rooms_get_room_documents" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return DocuSignResponse(success=False, error=str(e), message="Failed to execute rooms_get_room_documents") + + async def rooms_get_room_templates( + self, + account_id: str, + count: int | None = None, + startPosition: int | None = None + ) -> DocuSignResponse: + """Get room templates [Rooms] + + Args: + account_id: Account ID + count: Number of results to return + startPosition: Start position for pagination + + Returns: + DocuSignResponse with operation result + """ + query_params: dict[str, object] = {} + if count is not None: + query_params['count'] = count + if startPosition is not None: + query_params['startPosition'] = startPosition + + base_url = self.ROOMS_BASE_URL + url = base_url + f"/v2/accounts/{account_id}/room_templates" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, # type: ignore[reportArgumentType] + ) + response = await self._get_rooms_http().execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return DocuSignResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed rooms_get_room_templates" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return DocuSignResponse(success=False, error=str(e), message="Failed to execute rooms_get_room_templates") + + async def rooms_get_roles( + self, + account_id: str + ) -> DocuSignResponse: + """Get roles for the account [Rooms] + + Args: + account_id: Account ID + + Returns: + DocuSignResponse with operation result + """ + base_url = self.ROOMS_BASE_URL + url = base_url + f"/v2/accounts/{account_id}/roles" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self._get_rooms_http().execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return DocuSignResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed rooms_get_roles" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return DocuSignResponse(success=False, error=str(e), message="Failed to execute rooms_get_roles") + + async def rooms_get_offices( + self, + account_id: str + ) -> DocuSignResponse: + """Get offices for the account [Rooms] + + Args: + account_id: Account ID + + Returns: + DocuSignResponse with operation result + """ + base_url = self.ROOMS_BASE_URL + url = base_url + f"/v2/accounts/{account_id}/offices" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self._get_rooms_http().execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return DocuSignResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed rooms_get_offices" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return DocuSignResponse(success=False, error=str(e), message="Failed to execute rooms_get_offices") + + async def rooms_get_regions( + self, + account_id: str + ) -> DocuSignResponse: + """Get regions for the account [Rooms] + + Args: + account_id: Account ID + + Returns: + DocuSignResponse with operation result + """ + base_url = self.ROOMS_BASE_URL + url = base_url + f"/v2/accounts/{account_id}/regions" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self._get_rooms_http().execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return DocuSignResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed rooms_get_regions" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return DocuSignResponse(success=False, error=str(e), message="Failed to execute rooms_get_regions") + + async def rooms_get_form_libraries( + self, + account_id: str + ) -> DocuSignResponse: + """Get form libraries for the account [Rooms] + + Args: + account_id: Account ID + + Returns: + DocuSignResponse with operation result + """ + base_url = self.ROOMS_BASE_URL + url = base_url + f"/v2/accounts/{account_id}/form_libraries" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self._get_rooms_http().execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return DocuSignResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed rooms_get_form_libraries" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return DocuSignResponse(success=False, error=str(e), message="Failed to execute rooms_get_form_libraries") + + async def click_get_clickwraps( + self, + account_id: str + ) -> DocuSignResponse: + """Get all clickwraps for the account [Click] + + Args: + account_id: Account ID + + Returns: + DocuSignResponse with operation result + """ + base_url = self.CLICK_BASE_URL + url = base_url + f"/v1/accounts/{account_id}/clickwraps" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self._get_click_http().execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return DocuSignResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed click_get_clickwraps" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return DocuSignResponse(success=False, error=str(e), message="Failed to execute click_get_clickwraps") + + async def click_get_clickwrap( + self, + account_id: str, + clickwrap_id: str + ) -> DocuSignResponse: + """Get a specific clickwrap [Click] + + Args: + account_id: Account ID + clickwrap_id: Clickwrap ID + + Returns: + DocuSignResponse with operation result + """ + base_url = self.CLICK_BASE_URL + url = base_url + f"/v1/accounts/{account_id}/clickwraps/{clickwrap_id}" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self._get_click_http().execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return DocuSignResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed click_get_clickwrap" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return DocuSignResponse(success=False, error=str(e), message="Failed to execute click_get_clickwrap") + + async def click_create_clickwrap( + self, + account_id: str, + clickwrapName: str, + documents: list[dict[str, object]], + requireReacceptance: bool | None = None + ) -> DocuSignResponse: + """Create a new clickwrap [Click] + + Args: + account_id: Account ID + clickwrapName: Clickwrap name + documents: Documents for the clickwrap + requireReacceptance: Whether re-acceptance is required + + Returns: + DocuSignResponse with operation result + """ + base_url = self.CLICK_BASE_URL + url = base_url + f"/v1/accounts/{account_id}/clickwraps" + + body: dict[str, object] = {} + body['clickwrapName'] = clickwrapName + body['documents'] = documents + if requireReacceptance is not None: + body['requireReacceptance'] = requireReacceptance + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self._get_click_http().execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return DocuSignResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed click_create_clickwrap" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return DocuSignResponse(success=False, error=str(e), message="Failed to execute click_create_clickwrap") + + async def click_delete_clickwrap( + self, + account_id: str, + clickwrap_id: str + ) -> DocuSignResponse: + """Delete a clickwrap [Click] + + Args: + account_id: Account ID + clickwrap_id: Clickwrap ID + + Returns: + DocuSignResponse with operation result + """ + base_url = self.CLICK_BASE_URL + url = base_url + f"/v1/accounts/{account_id}/clickwraps/{clickwrap_id}" + + try: + request = HTTPRequest( + method="DELETE", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self._get_click_http().execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return DocuSignResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed click_delete_clickwrap" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return DocuSignResponse(success=False, error=str(e), message="Failed to execute click_delete_clickwrap") + + async def click_get_clickwrap_agreements( + self, + account_id: str, + clickwrap_id: str + ) -> DocuSignResponse: + """Get clickwrap agreements (user acceptances) [Click] + + Args: + account_id: Account ID + clickwrap_id: Clickwrap ID + + Returns: + DocuSignResponse with operation result + """ + base_url = self.CLICK_BASE_URL + url = base_url + f"/v1/accounts/{account_id}/clickwraps/{clickwrap_id}/users" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self._get_click_http().execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return DocuSignResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed click_get_clickwrap_agreements" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return DocuSignResponse(success=False, error=str(e), message="Failed to execute click_get_clickwrap_agreements") + + async def click_get_service_info( + self, + account_id: str + ) -> DocuSignResponse: + """Get Click service information for the account [Click] + + Args: + account_id: Account ID + + Returns: + DocuSignResponse with operation result + """ + base_url = self.CLICK_BASE_URL + url = base_url + f"/v1/accounts/{account_id}/service_information" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self._get_click_http().execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return DocuSignResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed click_get_service_info" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return DocuSignResponse(success=False, error=str(e), message="Failed to execute click_get_service_info") + + async def monitor_get_stream( + self, + cursor: str | None = None, + limit: int | None = None + ) -> DocuSignResponse: + """Get monitor audit stream events [Monitor] + + Args: + cursor: Cursor for pagination + limit: Number of events to return + + Returns: + DocuSignResponse with operation result + """ + query_params: dict[str, object] = {} + if cursor is not None: + query_params['cursor'] = cursor + if limit is not None: + query_params['limit'] = limit + + base_url = self.MONITOR_BASE_URL + url = base_url + "/api/v2.0/datasets/monitor/stream" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, # type: ignore[reportArgumentType] + ) + response = await self._get_monitor_http().execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return DocuSignResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed monitor_get_stream" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return DocuSignResponse(success=False, error=str(e), message="Failed to execute monitor_get_stream") + + async def webforms_list_forms( + self, + account_id: str, + search: str | None = None, + state: str | None = None, + status: str | None = None + ) -> DocuSignResponse: + """List web forms for the account [Webforms] + + Args: + account_id: Account ID + search: Search filter + state: Filter by form state + status: Filter by form status + + Returns: + DocuSignResponse with operation result + """ + query_params: dict[str, object] = {} + if search is not None: + query_params['search'] = search + if state is not None: + query_params['state'] = state + if status is not None: + query_params['status'] = status + + base_url = self.WEBFORMS_BASE_URL + url = base_url + f"/accounts/{account_id}/forms" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, # type: ignore[reportArgumentType] + ) + response = await self._get_webforms_http().execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return DocuSignResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed webforms_list_forms" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return DocuSignResponse(success=False, error=str(e), message="Failed to execute webforms_list_forms") + + async def webforms_get_form( + self, + account_id: str, + form_id: str + ) -> DocuSignResponse: + """Get a specific web form [Webforms] + + Args: + account_id: Account ID + form_id: Form ID + + Returns: + DocuSignResponse with operation result + """ + base_url = self.WEBFORMS_BASE_URL + url = base_url + f"/accounts/{account_id}/forms/{form_id}" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self._get_webforms_http().execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return DocuSignResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed webforms_get_form" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return DocuSignResponse(success=False, error=str(e), message="Failed to execute webforms_get_form") + + async def webforms_list_instances( + self, + account_id: str, + form_id: str + ) -> DocuSignResponse: + """List instances of a web form [Webforms] + + Args: + account_id: Account ID + form_id: Form ID + + Returns: + DocuSignResponse with operation result + """ + base_url = self.WEBFORMS_BASE_URL + url = base_url + f"/accounts/{account_id}/forms/{form_id}/instances" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self._get_webforms_http().execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return DocuSignResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed webforms_list_instances" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return DocuSignResponse(success=False, error=str(e), message="Failed to execute webforms_list_instances") + + async def webforms_get_instance( + self, + account_id: str, + form_id: str, + instance_id: str + ) -> DocuSignResponse: + """Get a specific web form instance [Webforms] + + Args: + account_id: Account ID + form_id: Form ID + instance_id: Instance ID + + Returns: + DocuSignResponse with operation result + """ + base_url = self.WEBFORMS_BASE_URL + url = base_url + f"/accounts/{account_id}/forms/{form_id}/instances/{instance_id}" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self._get_webforms_http().execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return DocuSignResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed webforms_get_instance" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return DocuSignResponse(success=False, error=str(e), message="Failed to execute webforms_get_instance") + + async def webforms_create_instance( + self, + account_id: str, + form_id: str, + clientUserId: str | None = None, + tags: list[str] | None = None, + returnUrl: str | None = None + ) -> DocuSignResponse: + """Create a new web form instance [Webforms] + + Args: + account_id: Account ID + form_id: Form ID + clientUserId: Client user ID + tags: Tags for the instance + returnUrl: Return URL after form completion + + Returns: + DocuSignResponse with operation result + """ + base_url = self.WEBFORMS_BASE_URL + url = base_url + f"/accounts/{account_id}/forms/{form_id}/instances" + + body: dict[str, object] = {} + if clientUserId is not None: + body['clientUserId'] = clientUserId + if tags is not None: + body['tags'] = tags + if returnUrl is not None: + body['returnUrl'] = returnUrl + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self._get_webforms_http().execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return DocuSignResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed webforms_create_instance" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return DocuSignResponse(success=False, error=str(e), message="Failed to execute webforms_create_instance") diff --git a/backend/python/app/sources/external/docusign/example.py b/backend/python/app/sources/external/docusign/example.py new file mode 100644 index 000000000..8131d5c66 --- /dev/null +++ b/backend/python/app/sources/external/docusign/example.py @@ -0,0 +1,187 @@ +# ruff: noqa + +""" +DocuSign Unified API Usage Examples + +This example demonstrates how to use the DocuSign DataSource covering: +- eSignature (SDK): Envelopes, Templates, Users, Folders +- Admin (HTTP): Organizations, Users +- Rooms (HTTP): Rooms, Roles +- Click (HTTP): Clickwraps, Service Info +- Monitor (HTTP): Audit stream +- WebForms (HTTP): Forms + +Prerequisites: +1. Set DOCUSIGN_ACCESS_TOKEN environment variable +2. Set DOCUSIGN_ACCOUNT_ID environment variable +3. Optionally set DOCUSIGN_BASE_PATH (default: https://demo.docusign.net/restapi) +""" + +import asyncio +import json +import os + +from app.sources.client.docusign.docusign import ( + DocuSignClient, + DocuSignOAuthConfig, + DocuSignResponse, +) +from app.sources.external.docusign.docusign import DocuSignDataSource + +# --- Configuration --- +ACCESS_TOKEN = os.getenv("DOCUSIGN_ACCESS_TOKEN") +ACCOUNT_ID = os.getenv("DOCUSIGN_ACCOUNT_ID") +BASE_PATH = os.getenv("DOCUSIGN_BASE_PATH", "https://demo.docusign.net/restapi") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: DocuSignResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + # The SDK returns model objects; convert to dict for display + if hasattr(data, "to_dict"): + data = data.to_dict() + if isinstance(data, dict): + for key in ("envelopes", "envelope_templates", "users", "folders", + "brands", "envelope_documents", "signers", "audit_events", + "organizations", "rooms", "clickwraps", "forms"): + if key in data: + items = data[key] + if isinstance(items, list): + print(f" Found {len(items)} {key}.") + if items: + print(f" Sample: {json.dumps(items[0], indent=2, default=str)[:400]}...") + return + print(f" Data: {json.dumps(data, indent=2, default=str)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + # 1. Initialize Client + print_section("Initializing DocuSign Client") + + if not ACCOUNT_ID: + print(" DOCUSIGN_ACCOUNT_ID is required. Please set it and try again.") + return + + if not ACCESS_TOKEN: + print(" DOCUSIGN_ACCESS_TOKEN is required. Please set it and try again.") + return + + config = DocuSignOAuthConfig( + access_token=ACCESS_TOKEN, + account_id=ACCOUNT_ID, + base_path=BASE_PATH, + ) + + client = DocuSignClient.build_with_config(config) + ds = DocuSignDataSource(client) + print(" Client initialized successfully (unified: SDK + HTTP).") + + # ===== eSign SDK methods (synchronous) ===== + + # 2. List Envelopes + print_section("eSign: Envelopes") + envelopes_resp = ds.list_envelopes( + from_date="2024-01-01T00:00:00Z", + count="10", + ) + print_result("List Envelopes", envelopes_resp) + + # 3. List Templates + print_section("eSign: Templates") + templates_resp = ds.list_templates(count="10") + print_result("List Templates", templates_resp) + + # 4. List Users + print_section("eSign: Users") + users_resp = ds.list_users(count="10") + print_result("List Users", users_resp) + + # 5. List Folders + print_section("eSign: Folders") + folders_resp = ds.list_folders() + print_result("List Folders", folders_resp) + + # ===== Admin HTTP methods (async) ===== + + print_section("Admin: Organizations") + orgs_resp = await ds.admin_get_organizations() + print_result("Get Organizations", orgs_resp) + + # If we got organizations, try to list users + if orgs_resp.success and orgs_resp.data: + orgs = orgs_resp.data if isinstance(orgs_resp.data, dict) else {} + org_list = orgs.get("organizations", []) + if org_list: + org_id = str(org_list[0].get("id", "")) + if org_id: + print_section("Admin: Organization Users") + admin_users_resp = await ds.admin_get_users( + org_id=org_id, + take=5, + ) + print_result("Get Admin Users", admin_users_resp) + + # ===== Rooms HTTP methods (async) ===== + + print_section("Rooms: List Rooms") + rooms_resp = await ds.rooms_get_rooms( + account_id=ACCOUNT_ID, + count=5, + ) + print_result("Get Rooms", rooms_resp) + + print_section("Rooms: Roles") + roles_resp = await ds.rooms_get_roles(account_id=ACCOUNT_ID) + print_result("Get Roles", roles_resp) + + # ===== Click HTTP methods (async) ===== + + print_section("Click: Clickwraps") + clickwraps_resp = await ds.click_get_clickwraps(account_id=ACCOUNT_ID) + print_result("Get Clickwraps", clickwraps_resp) + + print_section("Click: Service Info") + svc_resp = await ds.click_get_service_info(account_id=ACCOUNT_ID) + print_result("Get Service Info", svc_resp) + + # ===== Monitor HTTP methods (async) ===== + + print_section("Monitor: Audit Stream") + stream_resp = await ds.monitor_get_stream(limit=5) + print_result("Get Stream", stream_resp) + + # ===== WebForms HTTP methods (async) ===== + + print_section("WebForms: List Forms") + forms_resp = await ds.webforms_list_forms(account_id=ACCOUNT_ID) + print_result("List Forms", forms_resp) + + # ===== Cleanup ===== + + # Close HTTP clients + inner = client.get_client() + if hasattr(inner, "_http_clients"): + for http_client in inner._http_clients.values(): + if hasattr(http_client, "close"): + await http_client.close() + + print("\n" + "=" * 80) + print(" All DocuSign API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/dokuwiki/dokuwiki.py b/backend/python/app/sources/external/dokuwiki/dokuwiki.py new file mode 100644 index 000000000..e445bbc78 --- /dev/null +++ b/backend/python/app/sources/external/dokuwiki/dokuwiki.py @@ -0,0 +1,387 @@ +""" +DokuWiki XML-RPC DataSource - Auto-generated API wrapper + +Generated from DokuWiki XML-RPC API documentation. +Uses xmlrpc.client.ServerProxy for XML-RPC interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.dokuwiki.dokuwiki import DokuWikiClient, DokuWikiResponse + +# Type alias for XML-RPC results which return _Marshallable +_XmlRpcResult = Any + + +class DokuWikiDataSource: + """DokuWiki XML-RPC DataSource + + Provides wrapper methods for DokuWiki XML-RPC API operations: + - System info (version, time) + - Page CRUD and listing + - Page info and versions + - Search + - Attachments + - Backlinks + - Recent changes + - ACL checks + + Uses xmlrpc.client.ServerProxy under the hood. + All methods return DokuWikiResponse objects. + + Note: XML-RPC calls are synchronous (blocking). The methods here + are not async but follow the same response pattern for consistency. + """ + + def __init__(self, client: DokuWikiClient) -> None: + """Initialize with DokuWikiClient. + + Args: + client: DokuWikiClient instance with configured authentication + """ + self._client = client + self.server = client.get_sdk() + + def get_data_source(self) -> "DokuWikiDataSource": + """Return the data source instance.""" + return self + + def get_client(self) -> DokuWikiClient: + """Return the underlying DokuWikiClient.""" + return self._client + + # ----------------------------------------------------------------------- + # System + # ----------------------------------------------------------------------- + + def get_version(self) -> DokuWikiResponse: + """Get the DokuWiki version. + + Returns: + DokuWikiResponse with version string + """ + try: + result: _XmlRpcResult = self.server.dokuwiki.getVersion() + return DokuWikiResponse( + success=True, + data=result, + message="Successfully executed get_version", + ) + except Exception as e: + return DokuWikiResponse( + success=False, + error=str(e), + message="Failed to execute get_version", + ) + + def get_time(self) -> DokuWikiResponse: + """Get the server time. + + Returns: + DokuWikiResponse with server time (Unix timestamp) + """ + try: + result: _XmlRpcResult = self.server.dokuwiki.getTime() + return DokuWikiResponse( + success=True, + data=result, + message="Successfully executed get_time", + ) + except Exception as e: + return DokuWikiResponse( + success=False, + error=str(e), + message="Failed to execute get_time", + ) + + # ----------------------------------------------------------------------- + # Pages + # ----------------------------------------------------------------------- + + def get_page(self, pagename: str) -> DokuWikiResponse: + """Get the content of a wiki page. + + Args: + pagename: Full page name (e.g. "namespace:pagename") + + Returns: + DokuWikiResponse with page content (string) + """ + try: + result: _XmlRpcResult = self.server.wiki.getPage(pagename) + return DokuWikiResponse( + success=True, + data=result, + message="Successfully executed get_page", + ) + except Exception as e: + return DokuWikiResponse( + success=False, + error=str(e), + message="Failed to execute get_page", + ) + + def put_page( + self, + pagename: str, + content: str, + *, + attrs: dict[str, Any] | None = None, + ) -> DokuWikiResponse: + """Create or update a wiki page. + + Args: + pagename: Full page name (e.g. "namespace:pagename") + content: Page content in wiki markup + attrs: Optional attributes dict (e.g. {"sum": "edit summary", "minor": True}) + + Returns: + DokuWikiResponse with operation result + """ + try: + result: _XmlRpcResult = self.server.wiki.putPage( + pagename, content, attrs or {} + ) + return DokuWikiResponse( + success=True, + data=result, + message="Successfully executed put_page", + ) + except Exception as e: + return DokuWikiResponse( + success=False, + error=str(e), + message="Failed to execute put_page", + ) + + def list_pages(self, namespace: str) -> DokuWikiResponse: + """List pages in a namespace. + + Args: + namespace: Namespace to list pages from (e.g. "wiki") + + Returns: + DokuWikiResponse with list of page info dicts + """ + try: + result: _XmlRpcResult = self.server.wiki.listPages(namespace) + return DokuWikiResponse( + success=True, + data=result, + message="Successfully executed list_pages", + ) + except Exception as e: + return DokuWikiResponse( + success=False, + error=str(e), + message="Failed to execute list_pages", + ) + + def get_all_pages(self) -> DokuWikiResponse: + """Get a list of all pages. + + Returns: + DokuWikiResponse with list of all page info dicts + """ + try: + result: _XmlRpcResult = self.server.wiki.getAllPages() + return DokuWikiResponse( + success=True, + data=result, + message="Successfully executed get_all_pages", + ) + except Exception as e: + return DokuWikiResponse( + success=False, + error=str(e), + message="Failed to execute get_all_pages", + ) + + def get_page_info(self, pagename: str) -> DokuWikiResponse: + """Get metadata about a page. + + Args: + pagename: Full page name + + Returns: + DokuWikiResponse with page info dict (name, lastModified, author, version) + """ + try: + result: _XmlRpcResult = self.server.wiki.getPageInfo(pagename) + return DokuWikiResponse( + success=True, + data=result, + message="Successfully executed get_page_info", + ) + except Exception as e: + return DokuWikiResponse( + success=False, + error=str(e), + message="Failed to execute get_page_info", + ) + + def get_page_versions( + self, + pagename: str, + offset: int = 0, + ) -> DokuWikiResponse: + """Get version history of a page. + + Args: + pagename: Full page name + offset: Offset for pagination (default: 0) + + Returns: + DokuWikiResponse with list of version info dicts + """ + try: + result: _XmlRpcResult = self.server.wiki.getPageVersions(pagename, offset) + return DokuWikiResponse( + success=True, + data=result, + message="Successfully executed get_page_versions", + ) + except Exception as e: + return DokuWikiResponse( + success=False, + error=str(e), + message="Failed to execute get_page_versions", + ) + + # ----------------------------------------------------------------------- + # Search + # ----------------------------------------------------------------------- + + def search(self, query: str) -> DokuWikiResponse: + """Search wiki pages. + + Args: + query: Search query string + + Returns: + DokuWikiResponse with list of search result dicts + """ + try: + result: _XmlRpcResult = self.server.wiki.search(query) + return DokuWikiResponse( + success=True, + data=result, + message="Successfully executed search", + ) + except Exception as e: + return DokuWikiResponse( + success=False, + error=str(e), + message="Failed to execute search", + ) + + # ----------------------------------------------------------------------- + # Attachments + # ----------------------------------------------------------------------- + + def get_attachments(self, namespace: str) -> DokuWikiResponse: + """Get attachments in a namespace. + + Args: + namespace: Namespace to list attachments from + + Returns: + DokuWikiResponse with list of attachment info dicts + """ + try: + result: _XmlRpcResult = self.server.wiki.getAttachments(namespace) + return DokuWikiResponse( + success=True, + data=result, + message="Successfully executed get_attachments", + ) + except Exception as e: + return DokuWikiResponse( + success=False, + error=str(e), + message="Failed to execute get_attachments", + ) + + # ----------------------------------------------------------------------- + # Backlinks + # ----------------------------------------------------------------------- + + def get_backlinks(self, pagename: str) -> DokuWikiResponse: + """Get pages that link to the specified page. + + Args: + pagename: Full page name to find backlinks for + + Returns: + DokuWikiResponse with list of page names + """ + try: + result: _XmlRpcResult = self.server.wiki.getBackLinks(pagename) + return DokuWikiResponse( + success=True, + data=result, + message="Successfully executed get_backlinks", + ) + except Exception as e: + return DokuWikiResponse( + success=False, + error=str(e), + message="Failed to execute get_backlinks", + ) + + # ----------------------------------------------------------------------- + # Recent Changes + # ----------------------------------------------------------------------- + + def get_recent_changes(self, timestamp: int) -> DokuWikiResponse: + """Get recent changes since a given timestamp. + + Args: + timestamp: Unix timestamp to get changes since + + Returns: + DokuWikiResponse with list of change info dicts + """ + try: + result: _XmlRpcResult = self.server.wiki.getRecentChanges(timestamp) + return DokuWikiResponse( + success=True, + data=result, + message="Successfully executed get_recent_changes", + ) + except Exception as e: + return DokuWikiResponse( + success=False, + error=str(e), + message="Failed to execute get_recent_changes", + ) + + # ----------------------------------------------------------------------- + # ACL + # ----------------------------------------------------------------------- + + def acl_check(self, pagename: str) -> DokuWikiResponse: + """Check ACL permissions for a page. + + Args: + pagename: Full page name to check + + Returns: + DokuWikiResponse with permission level (int) + """ + try: + result: _XmlRpcResult = self.server.wiki.aclCheck(pagename) + return DokuWikiResponse( + success=True, + data=result, + message="Successfully executed acl_check", + ) + except Exception as e: + return DokuWikiResponse( + success=False, + error=str(e), + message="Failed to execute acl_check", + ) diff --git a/backend/python/app/sources/external/dokuwiki/example.py b/backend/python/app/sources/external/dokuwiki/example.py new file mode 100644 index 000000000..764a71cb8 --- /dev/null +++ b/backend/python/app/sources/external/dokuwiki/example.py @@ -0,0 +1,170 @@ +# ruff: noqa + +""" +DokuWiki XML-RPC API Usage Examples + +This example demonstrates how to use the DokuWiki DataSource to interact with +the DokuWiki XML-RPC API, covering: +- Authentication (Basic Auth via XML-RPC transport) +- Initializing the Client and DataSource +- Getting version and server time +- Page operations (get, put, list, info, versions) +- Search +- Attachments and backlinks +- Recent changes +- ACL checks + +Prerequisites: +1. Set DOKUWIKI_INSTANCE_URL to your DokuWiki instance (e.g. "wiki.example.com") +2. Set DOKUWIKI_USERNAME and DOKUWIKI_PASSWORD +3. Ensure XML-RPC is enabled in DokuWiki configuration + (Configuration Manager > Authentication > Remote Access) +""" + +import json +import os + +from app.sources.client.dokuwiki.dokuwiki import ( + DokuWikiBasicAuthConfig, + DokuWikiClient, + DokuWikiResponse, +) +from app.sources.external.dokuwiki.dokuwiki import DokuWikiDataSource + +# --- Configuration --- +INSTANCE_URL = os.getenv("DOKUWIKI_INSTANCE_URL", "") +USERNAME = os.getenv("DOKUWIKI_USERNAME", "") +PASSWORD = os.getenv("DOKUWIKI_PASSWORD", "") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: DokuWikiResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data is not None: + data = response.data + if isinstance(data, list): + print(f" Found {len(data)} items.") + if data: + item = data[0] + if isinstance(item, dict): + print(f" Sample: {json.dumps(item, indent=2, default=str)[:400]}...") + else: + print(f" Sample: {str(item)[:400]}...") + elif isinstance(data, dict): + print(f" Data: {json.dumps(data, indent=2, default=str)[:500]}...") + elif isinstance(data, str): + print(f" Content: {data[:400]}...") + else: + print(f" Value: {data}") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +def main() -> None: + # 1. Initialize Client + print_section("Initializing DokuWiki Client") + + if not INSTANCE_URL or not USERNAME or not PASSWORD: + print(" No valid authentication found.") + print(" Please set:") + print(" - DOKUWIKI_INSTANCE_URL (e.g. wiki.example.com)") + print(" - DOKUWIKI_USERNAME") + print(" - DOKUWIKI_PASSWORD") + return + + config = DokuWikiBasicAuthConfig( + instance_url=INSTANCE_URL, + username=USERNAME, + password=PASSWORD, + ) + client = DokuWikiClient.build_with_config(config) + data_source = DokuWikiDataSource(client) + print(f"Client initialized for instance: {INSTANCE_URL}") + + # 2. Get Version + print_section("DokuWiki Version") + version_resp = data_source.get_version() + print_result("Get Version", version_resp) + + # 3. Get Server Time + print_section("Server Time") + time_resp = data_source.get_time() + print_result("Get Time", time_resp) + + # 4. Get All Pages + print_section("All Pages") + all_pages_resp = data_source.get_all_pages() + print_result("Get All Pages", all_pages_resp) + + # Get first page name for further operations + pagename = None + if all_pages_resp.success and isinstance(all_pages_resp.data, list) and all_pages_resp.data: + first_page = all_pages_resp.data[0] + if isinstance(first_page, dict): + pagename = str(first_page.get("id", "")) + print(f" Using page: {pagename}") + + # 5. Get Page Content + if pagename: + print_section(f"Page Content: {pagename}") + page_resp = data_source.get_page(pagename) + print_result("Get Page", page_resp) + + # 6. Get Page Info + print_section(f"Page Info: {pagename}") + info_resp = data_source.get_page_info(pagename) + print_result("Get Page Info", info_resp) + + # 7. Get Page Versions + print_section(f"Page Versions: {pagename}") + versions_resp = data_source.get_page_versions(pagename) + print_result("Get Page Versions", versions_resp) + + # 8. Get Backlinks + print_section(f"Backlinks: {pagename}") + backlinks_resp = data_source.get_backlinks(pagename) + print_result("Get Backlinks", backlinks_resp) + + # 9. ACL Check + print_section(f"ACL Check: {pagename}") + acl_resp = data_source.acl_check(pagename) + print_result("ACL Check", acl_resp) + + # 10. Search + print_section("Search") + search_resp = data_source.search("wiki") + print_result("Search 'wiki'", search_resp) + + # 11. List Pages in namespace + print_section("List Pages in Root Namespace") + list_resp = data_source.list_pages("") + print_result("List Pages", list_resp) + + # 12. Get Recent Changes (last 24 hours) + import time + print_section("Recent Changes (last 24h)") + yesterday = int(time.time()) - 86400 + changes_resp = data_source.get_recent_changes(yesterday) + print_result("Get Recent Changes", changes_resp) + + # 13. Get Attachments in root namespace + print_section("Attachments") + attachments_resp = data_source.get_attachments("") + print_result("Get Attachments", attachments_resp) + + print("\n" + "=" * 80) + print(" All DokuWiki API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + main() diff --git a/backend/python/app/sources/external/egnyte/egnyte.py b/backend/python/app/sources/external/egnyte/egnyte.py new file mode 100644 index 000000000..a1330018f --- /dev/null +++ b/backend/python/app/sources/external/egnyte/egnyte.py @@ -0,0 +1,1199 @@ +""" +Egnyte REST API DataSource - Auto-generated API wrapper + +Generated from Egnyte Public API v1 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.egnyte.egnyte import EgnyteClient, EgnyteResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class EgnyteDataSource: + """Egnyte REST API DataSource + + Provides async wrapper methods for Egnyte Public API v1 operations: + - File system operations (metadata, content, folders) + - Links management + - User and group management + - Audit operations (files, logins, permissions) + - Search + - Permissions management + + The base URL is determined by the EgnyteClient's configured domain. + + All methods return EgnyteResponse objects. + """ + + def __init__(self, client: EgnyteClient) -> None: + """Initialize with EgnyteClient. + + Args: + client: EgnyteClient instance with configured authentication and domain + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'EgnyteDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> EgnyteClient: + """Return the underlying EgnyteClient.""" + return self._client + + async def get_file_or_folder_metadata( + self, + path: str, + *, + list_content: bool | None = None, + allowed_link_types: bool | None = None, + count: int | None = None, + offset: int | None = None, + sort_by: str | None = None, + sort_direction: str | None = None + ) -> EgnyteResponse: + """Get file or folder metadata at the given path + + Args: + path: File or folder path (e.g. 'Shared/Documents') + list_content: If true and path is a folder, list contents + allowed_link_types: Include allowed link types info + count: Number of items to return (for folder listing) + offset: Offset for pagination (for folder listing) + sort_by: Sort field (name, last_modified, size) + sort_direction: Sort direction (asc, desc) + + Returns: + EgnyteResponse with operation result + """ + query_params: dict[str, Any] = {} + if list_content is not None: + query_params['list_content'] = str(list_content).lower() + if allowed_link_types is not None: + query_params['allowed_link_types'] = str(allowed_link_types).lower() + if count is not None: + query_params['count'] = str(count) + if offset is not None: + query_params['offset'] = str(offset) + if sort_by is not None: + query_params['sort_by'] = sort_by + if sort_direction is not None: + query_params['sort_direction'] = sort_direction + + url = self.base_url + "/fs/{path}".format(path=path) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return EgnyteResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_file_or_folder_metadata" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return EgnyteResponse(success=False, error=str(e), message="Failed to execute get_file_or_folder_metadata") + + async def create_folder( + self, + path: str, + action: str + ) -> EgnyteResponse: + """Create a folder at the given path + + Args: + path: Folder path to create + action: Action type (must be 'add_folder') + + Returns: + EgnyteResponse with operation result + """ + url = self.base_url + "/fs/{path}".format(path=path) + + body: dict[str, Any] = {} + body['action'] = action + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return EgnyteResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed create_folder" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return EgnyteResponse(success=False, error=str(e), message="Failed to execute create_folder") + + async def delete_file_or_folder( + self, + path: str + ) -> EgnyteResponse: + """Delete a file or folder at the given path + + Args: + path: File or folder path to delete + + Returns: + EgnyteResponse with operation result + """ + url = self.base_url + "/fs/{path}".format(path=path) + + try: + request = HTTPRequest( + method="DELETE", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return EgnyteResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed delete_file_or_folder" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return EgnyteResponse(success=False, error=str(e), message="Failed to execute delete_file_or_folder") + + async def move_file_or_folder( + self, + path: str, + action: str, + destination: str + ) -> EgnyteResponse: + """Move or copy a file or folder + + Args: + path: Source file or folder path + action: Action type ('move' or 'copy') + destination: Destination path + + Returns: + EgnyteResponse with operation result + """ + url = self.base_url + "/fs/{path}".format(path=path) + + body: dict[str, Any] = {} + body['action'] = action + body['destination'] = destination + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return EgnyteResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed move_file_or_folder" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return EgnyteResponse(success=False, error=str(e), message="Failed to execute move_file_or_folder") + + async def download_file( + self, + path: str, + *, + entry_id: str | None = None + ) -> EgnyteResponse: + """Download file content at the given path + + Args: + path: File path to download + entry_id: Specific version entry ID + + Returns: + EgnyteResponse with operation result + """ + query_params: dict[str, Any] = {} + if entry_id is not None: + query_params['entry_id'] = entry_id + + url = self.base_url + "/fs-content/{path}".format(path=path) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return EgnyteResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed download_file" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return EgnyteResponse(success=False, error=str(e), message="Failed to execute download_file") + + async def upload_file( + self, + path: str + ) -> EgnyteResponse: + """Upload file content to the given path + + Args: + path: File path for upload + + Returns: + EgnyteResponse with operation result + """ + url = self.base_url + "/fs-content/{path}".format(path=path) + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return EgnyteResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed upload_file" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return EgnyteResponse(success=False, error=str(e), message="Failed to execute upload_file") + + async def list_links( + self, + *, + path: str | None = None, + type_: str | None = None, + accessibility: str | None = None, + count: int | None = None, + offset: int | None = None + ) -> EgnyteResponse: + """List shared links + + Args: + path: Filter by path + type_: Link type (file or folder) + accessibility: Accessibility (anyone, password, domain, recipients) + count: Number of links to return + offset: Offset for pagination + + Returns: + EgnyteResponse with operation result + """ + query_params: dict[str, Any] = {} + if path is not None: + query_params['path'] = path + if type_ is not None: + query_params['type'] = type_ + if accessibility is not None: + query_params['accessibility'] = accessibility + if count is not None: + query_params['count'] = str(count) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/links" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return EgnyteResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_links" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return EgnyteResponse(success=False, error=str(e), message="Failed to execute list_links") + + async def create_link( + self, + path: str, + type_: str, + accessibility: str, + *, + send_email: bool | None = None, + recipients: list[str] | None = None, + message: str | None = None, + copy_me: bool | None = None, + notify: bool | None = None, + link_to_current: bool | None = None, + expiry_date: str | None = None, + expiry_clicks: int | None = None, + add_file_name: bool | None = None + ) -> EgnyteResponse: + """Create a shared link + + Args: + path: Path to the file or folder + type_: Link type (file or folder) + accessibility: Accessibility (anyone, password, domain, recipients) + send_email: Send email notification + recipients: List of recipient email addresses + message: Email message body + copy_me: Send copy to creator + notify: Notify on access + link_to_current: Link to current version only + expiry_date: Expiry date (YYYY-MM-DD) + expiry_clicks: Number of clicks before expiry + add_file_name: Add file name to link + + Returns: + EgnyteResponse with operation result + """ + url = self.base_url + "/links" + + body: dict[str, Any] = {} + body['path'] = path + body['type'] = type_ + body['accessibility'] = accessibility + if send_email is not None: + body['send_email'] = send_email + if recipients is not None: + body['recipients'] = recipients + if message is not None: + body['message'] = message + if copy_me is not None: + body['copy_me'] = copy_me + if notify is not None: + body['notify'] = notify + if link_to_current is not None: + body['link_to_current'] = link_to_current + if expiry_date is not None: + body['expiry_date'] = expiry_date + if expiry_clicks is not None: + body['expiry_clicks'] = expiry_clicks + if add_file_name is not None: + body['add_file_name'] = add_file_name + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return EgnyteResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed create_link" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return EgnyteResponse(success=False, error=str(e), message="Failed to execute create_link") + + async def get_link( + self, + link_id: str + ) -> EgnyteResponse: + """Get a specific shared link + + Args: + link_id: The link ID + + Returns: + EgnyteResponse with operation result + """ + url = self.base_url + "/links/{link_id}".format(link_id=link_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return EgnyteResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_link" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return EgnyteResponse(success=False, error=str(e), message="Failed to execute get_link") + + async def delete_link( + self, + link_id: str + ) -> EgnyteResponse: + """Delete a shared link + + Args: + link_id: The link ID + + Returns: + EgnyteResponse with operation result + """ + url = self.base_url + "/links/{link_id}".format(link_id=link_id) + + try: + request = HTTPRequest( + method="DELETE", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return EgnyteResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed delete_link" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return EgnyteResponse(success=False, error=str(e), message="Failed to execute delete_link") + + async def get_current_user( + self + ) -> EgnyteResponse: + """Get current authenticated user info + + Returns: + EgnyteResponse with operation result + """ + url = self.base_url + "/userinfo" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return EgnyteResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_current_user" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return EgnyteResponse(success=False, error=str(e), message="Failed to execute get_current_user") + + async def list_users( + self, + *, + startIndex: int | None = None, + count: int | None = None + ) -> EgnyteResponse: + """List users in the domain + + Args: + startIndex: Start index for pagination (1-based) + count: Number of users to return (max 100) + + Returns: + EgnyteResponse with operation result + """ + query_params: dict[str, Any] = {} + if startIndex is not None: + query_params['startIndex'] = str(startIndex) + if count is not None: + query_params['count'] = str(count) + + url = self.base_url + "/users" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return EgnyteResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_users" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return EgnyteResponse(success=False, error=str(e), message="Failed to execute list_users") + + async def get_user( + self, + user_id: str + ) -> EgnyteResponse: + """Get a specific user by ID + + Args: + user_id: The user ID + + Returns: + EgnyteResponse with operation result + """ + url = self.base_url + "/users/{user_id}".format(user_id=user_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return EgnyteResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_user" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return EgnyteResponse(success=False, error=str(e), message="Failed to execute get_user") + + async def create_user( + self, + userName: str, + externalId: str, + email: str, + name: dict[str, str], + *, + active: bool | None = None, + sendInvite: bool | None = None, + authType: str | None = None, + userType: str | None = None, + role: str | None = None + ) -> EgnyteResponse: + """Create a new user + + Args: + userName: Username (email) + externalId: External ID + email: User email address + name: User name object with familyName and givenName + active: Whether user is active + sendInvite: Send invite email + authType: Authentication type + userType: User type (power, standard, etc.) + role: User role + + Returns: + EgnyteResponse with operation result + """ + url = self.base_url + "/users" + + body: dict[str, Any] = {} + body['userName'] = userName + body['externalId'] = externalId + body['email'] = email + body['name'] = name + if active is not None: + body['active'] = active + if sendInvite is not None: + body['sendInvite'] = sendInvite + if authType is not None: + body['authType'] = authType + if userType is not None: + body['userType'] = userType + if role is not None: + body['role'] = role + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return EgnyteResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed create_user" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return EgnyteResponse(success=False, error=str(e), message="Failed to execute create_user") + + async def update_user( + self, + user_id: str, + *, + userName: str | None = None, + email: str | None = None, + name: dict[str, str] | None = None, + active: bool | None = None, + userType: str | None = None, + role: str | None = None + ) -> EgnyteResponse: + """Update an existing user + + Args: + user_id: The user ID + userName: Username (email) + email: User email address + name: User name object + active: Whether user is active + userType: User type + role: User role + + Returns: + EgnyteResponse with operation result + """ + url = self.base_url + "/users/{user_id}".format(user_id=user_id) + + body: dict[str, Any] = {} + if userName is not None: + body['userName'] = userName + if email is not None: + body['email'] = email + if name is not None: + body['name'] = name + if active is not None: + body['active'] = active + if userType is not None: + body['userType'] = userType + if role is not None: + body['role'] = role + + try: + request = HTTPRequest( + method="PATCH", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return EgnyteResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed update_user" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return EgnyteResponse(success=False, error=str(e), message="Failed to execute update_user") + + async def delete_user( + self, + user_id: str + ) -> EgnyteResponse: + """Delete a user + + Args: + user_id: The user ID + + Returns: + EgnyteResponse with operation result + """ + url = self.base_url + "/users/{user_id}".format(user_id=user_id) + + try: + request = HTTPRequest( + method="DELETE", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return EgnyteResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed delete_user" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return EgnyteResponse(success=False, error=str(e), message="Failed to execute delete_user") + + async def list_groups( + self + ) -> EgnyteResponse: + """List all groups + + Returns: + EgnyteResponse with operation result + """ + url = self.base_url + "/groups" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return EgnyteResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_groups" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return EgnyteResponse(success=False, error=str(e), message="Failed to execute list_groups") + + async def get_group( + self, + group_id: str + ) -> EgnyteResponse: + """Get a specific group by ID + + Args: + group_id: The group ID + + Returns: + EgnyteResponse with operation result + """ + url = self.base_url + "/groups/{group_id}".format(group_id=group_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return EgnyteResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_group" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return EgnyteResponse(success=False, error=str(e), message="Failed to execute get_group") + + async def create_group( + self, + displayName: str, + *, + members: list[dict[str, str]] | None = None + ) -> EgnyteResponse: + """Create a new group + + Args: + displayName: Group display name + members: List of member objects with 'value' (user ID) + + Returns: + EgnyteResponse with operation result + """ + url = self.base_url + "/groups" + + body: dict[str, Any] = {} + body['displayName'] = displayName + if members is not None: + body['members'] = members + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return EgnyteResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed create_group" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return EgnyteResponse(success=False, error=str(e), message="Failed to execute create_group") + + async def update_group( + self, + group_id: str, + *, + displayName: str | None = None, + members: list[dict[str, str]] | None = None + ) -> EgnyteResponse: + """Update a group + + Args: + group_id: The group ID + displayName: Group display name + members: List of member objects + + Returns: + EgnyteResponse with operation result + """ + url = self.base_url + "/groups/{group_id}".format(group_id=group_id) + + body: dict[str, Any] = {} + if displayName is not None: + body['displayName'] = displayName + if members is not None: + body['members'] = members + + try: + request = HTTPRequest( + method="PATCH", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return EgnyteResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed update_group" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return EgnyteResponse(success=False, error=str(e), message="Failed to execute update_group") + + async def delete_group( + self, + group_id: str + ) -> EgnyteResponse: + """Delete a group + + Args: + group_id: The group ID + + Returns: + EgnyteResponse with operation result + """ + url = self.base_url + "/groups/{group_id}".format(group_id=group_id) + + try: + request = HTTPRequest( + method="DELETE", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return EgnyteResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed delete_group" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return EgnyteResponse(success=False, error=str(e), message="Failed to execute delete_group") + + async def audit_files( + self, + startdate: str, + enddate: str, + *, + count: int | None = None, + offset: int | None = None, + folder: str | None = None, + file: str | None = None, + users: str | None = None, + transaction_type: str | None = None + ) -> EgnyteResponse: + """Audit file activity (access, uploads, downloads, etc.) + + Args: + startdate: Start date (YYYY-MM-DD) + enddate: End date (YYYY-MM-DD) + count: Number of records to return + offset: Offset for pagination + folder: Filter by folder path + file: Filter by file path + users: Filter by username + transaction_type: Transaction type filter + + Returns: + EgnyteResponse with operation result + """ + query_params: dict[str, Any] = {} + query_params['startdate'] = startdate + query_params['enddate'] = enddate + if count is not None: + query_params['count'] = str(count) + if offset is not None: + query_params['offset'] = str(offset) + if folder is not None: + query_params['folder'] = folder + if file is not None: + query_params['file'] = file + if users is not None: + query_params['users'] = users + if transaction_type is not None: + query_params['transaction_type'] = transaction_type + + url = self.base_url + "/audit/files" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return EgnyteResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed audit_files" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return EgnyteResponse(success=False, error=str(e), message="Failed to execute audit_files") + + async def audit_logins( + self, + startdate: str, + enddate: str, + *, + count: int | None = None, + offset: int | None = None, + users: str | None = None, + events: str | None = None, + access_points: str | None = None + ) -> EgnyteResponse: + """Audit login activity + + Args: + startdate: Start date (YYYY-MM-DD) + enddate: End date (YYYY-MM-DD) + count: Number of records to return + offset: Offset for pagination + users: Filter by username + events: Filter by event type + access_points: Filter by access point + + Returns: + EgnyteResponse with operation result + """ + query_params: dict[str, Any] = {} + query_params['startdate'] = startdate + query_params['enddate'] = enddate + if count is not None: + query_params['count'] = str(count) + if offset is not None: + query_params['offset'] = str(offset) + if users is not None: + query_params['users'] = users + if events is not None: + query_params['events'] = events + if access_points is not None: + query_params['access_points'] = access_points + + url = self.base_url + "/audit/logins" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return EgnyteResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed audit_logins" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return EgnyteResponse(success=False, error=str(e), message="Failed to execute audit_logins") + + async def audit_permissions( + self, + startdate: str, + enddate: str, + *, + count: int | None = None, + offset: int | None = None, + folder: str | None = None, + users: str | None = None + ) -> EgnyteResponse: + """Audit permissions changes + + Args: + startdate: Start date (YYYY-MM-DD) + enddate: End date (YYYY-MM-DD) + count: Number of records to return + offset: Offset for pagination + folder: Filter by folder path + users: Filter by username + + Returns: + EgnyteResponse with operation result + """ + query_params: dict[str, Any] = {} + query_params['startdate'] = startdate + query_params['enddate'] = enddate + if count is not None: + query_params['count'] = str(count) + if offset is not None: + query_params['offset'] = str(offset) + if folder is not None: + query_params['folder'] = folder + if users is not None: + query_params['users'] = users + + url = self.base_url + "/audit/permissions" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return EgnyteResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed audit_permissions" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return EgnyteResponse(success=False, error=str(e), message="Failed to execute audit_permissions") + + async def search( + self, + query: str, + *, + offset: int | None = None, + count: int | None = None, + folder: str | None = None, + modified_before: str | None = None, + modified_after: str | None = None, + type_: str | None = None + ) -> EgnyteResponse: + """Search for files and folders + + Args: + query: Search query string + offset: Offset for pagination + count: Number of results to return + folder: Restrict search to folder path + modified_before: Filter modified before (ISO 8601) + modified_after: Filter modified after (ISO 8601) + type_: Filter by type (file, folder) + + Returns: + EgnyteResponse with operation result + """ + query_params: dict[str, Any] = {} + query_params['query'] = query + if offset is not None: + query_params['offset'] = str(offset) + if count is not None: + query_params['count'] = str(count) + if folder is not None: + query_params['folder'] = folder + if modified_before is not None: + query_params['modified_before'] = modified_before + if modified_after is not None: + query_params['modified_after'] = modified_after + if type_ is not None: + query_params['type'] = type_ + + url = self.base_url + "/search" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return EgnyteResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed search" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return EgnyteResponse(success=False, error=str(e), message="Failed to execute search") + + async def get_folder_permissions( + self, + path: str + ) -> EgnyteResponse: + """Get permissions for a folder + + Args: + path: Folder path + + Returns: + EgnyteResponse with operation result + """ + url = self.base_url + "/perms/{path}".format(path=path) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return EgnyteResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_folder_permissions" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return EgnyteResponse(success=False, error=str(e), message="Failed to execute get_folder_permissions") + + async def set_folder_permissions( + self, + path: str, + *, + userPerms: dict[str, str] | None = None, + groupPerms: dict[str, str] | None = None, + inheritsPermissions: bool | None = None + ) -> EgnyteResponse: + """Set permissions for a folder + + Args: + path: Folder path + userPerms: User permissions mapping + groupPerms: Group permissions mapping + inheritsPermissions: Whether folder inherits parent permissions + + Returns: + EgnyteResponse with operation result + """ + url = self.base_url + "/perms/{path}".format(path=path) + + body: dict[str, Any] = {} + if userPerms is not None: + body['userPerms'] = userPerms + if groupPerms is not None: + body['groupPerms'] = groupPerms + if inheritsPermissions is not None: + body['inheritsPermissions'] = inheritsPermissions + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return EgnyteResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed set_folder_permissions" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return EgnyteResponse(success=False, error=str(e), message="Failed to execute set_folder_permissions") diff --git a/backend/python/app/sources/external/egnyte/example.py b/backend/python/app/sources/external/egnyte/example.py new file mode 100644 index 000000000..56117248f --- /dev/null +++ b/backend/python/app/sources/external/egnyte/example.py @@ -0,0 +1,185 @@ +# ruff: noqa + +""" +Egnyte API Usage Examples + +This example demonstrates how to use the Egnyte DataSource to interact with +the Egnyte API, covering: +- Authentication (OAuth2, Access Token) +- Initializing the Client and DataSource +- Fetching Current User Info +- Listing Files and Folders +- Managing Users and Groups +- Searching Files +- Auditing File Activity + +Prerequisites: +For OAuth2: +1. Create an Egnyte API key at https://developers.egnyte.com +2. Set EGNYTE_CLIENT_ID, EGNYTE_CLIENT_SECRET, and EGNYTE_DOMAIN +3. The OAuth flow will automatically open a browser for authorization + +For Access Token: +1. Generate a token in Egnyte developer portal +2. Set EGNYTE_ACCESS_TOKEN and EGNYTE_DOMAIN environment variables + +API Reference: https://developers.egnyte.com/docs +""" + +import asyncio +import json +import os + +from app.sources.client.egnyte.egnyte import ( + EgnyteClient, + EgnyteOAuthConfig, + EgnyteResponse, + EgnyteTokenConfig, +) +from app.sources.external.egnyte.egnyte import EgnyteDataSource +from app.sources.external.utils.oauth import perform_oauth_flow + +# --- Configuration --- +# OAuth2 credentials (highest priority) +CLIENT_ID = os.getenv("EGNYTE_CLIENT_ID") +CLIENT_SECRET = os.getenv("EGNYTE_CLIENT_SECRET") + +# Access Token (second priority) +ACCESS_TOKEN = os.getenv("EGNYTE_ACCESS_TOKEN") + +# Domain (required for all auth types) +DOMAIN = os.getenv("EGNYTE_DOMAIN") # e.g. 'mycompany' for mycompany.egnyte.com + +# OAuth redirect URI +REDIRECT_URI = os.getenv("EGNYTE_REDIRECT_URI", "http://localhost:8080/callback") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: EgnyteResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + print(f" Data: {json.dumps(data, indent=2, default=str)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + if not DOMAIN: + print(" Please set EGNYTE_DOMAIN environment variable") + print(" e.g. 'mycompany' for mycompany.egnyte.com") + return + + # 1. Initialize Client + print_section("Initializing Egnyte Client") + + config = None + + # Priority 1: OAuth2 + if CLIENT_ID and CLIENT_SECRET: + print(" Using OAuth2 authentication") + try: + print("Starting OAuth flow...") + token_response = perform_oauth_flow( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + auth_endpoint=f"https://{DOMAIN}.egnyte.com/puboauth/token", + token_endpoint=f"https://{DOMAIN}.egnyte.com/puboauth/token", + redirect_uri=REDIRECT_URI, + scopes=[], + scope_delimiter=" ", + auth_method="body", + ) + + access_token = token_response.get("access_token") + if not access_token: + raise Exception("No access_token found in OAuth response") + + config = EgnyteOAuthConfig( + access_token=access_token, + domain=DOMAIN, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + ) + print(" OAuth authentication successful") + except Exception as e: + print(f" OAuth flow failed: {e}") + print(" Falling back to other authentication methods...") + + # Priority 2: Access Token + if config is None and ACCESS_TOKEN: + print(" Using Access Token authentication") + config = EgnyteTokenConfig( + token=ACCESS_TOKEN, + domain=DOMAIN, + ) + + if config is None: + print(" No valid authentication method found.") + print(" Please set one of the following:") + print(" - EGNYTE_CLIENT_ID and EGNYTE_CLIENT_SECRET (for OAuth2)") + print(" - EGNYTE_ACCESS_TOKEN (for Access Token)") + return + + client = EgnyteClient.build_with_config(config) + data_source = EgnyteDataSource(client) + print(f"Client initialized successfully for domain: {DOMAIN}") + + try: + # 2. Get Current User Info + print_section("Current User Info") + user_resp = await data_source.get_current_user() + print_result("Get Current User", user_resp) + + # 3. List Root Folder + print_section("Root Folder Contents") + root_resp = await data_source.get_file_or_folder_metadata( + path="Shared", + list_content=True, + count=10, + ) + print_result("Root Folder", root_resp) + + # 4. List Users + print_section("Users") + users_resp = await data_source.list_users(count=5) + print_result("List Users", users_resp) + + # 5. List Groups + print_section("Groups") + groups_resp = await data_source.list_groups() + print_result("List Groups", groups_resp) + + # 6. List Links + print_section("Shared Links") + links_resp = await data_source.list_links(count=5) + print_result("List Links", links_resp) + + # 7. Search + print_section("Search Files") + search_resp = await data_source.search(query="report") + print_result("Search 'report'", search_resp) + + finally: + # Cleanup: Close the HTTP client session + print("\nClosing client connection...") + inner_client = client.get_client() + if hasattr(inner_client, "close"): + await inner_client.close() + + print("\n" + "=" * 80) + print(" All Egnyte API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/elasticsearch_db/elasticsearch_db_.py b/backend/python/app/sources/external/elasticsearch_db/elasticsearch_db_.py new file mode 100644 index 000000000..49d9e8971 --- /dev/null +++ b/backend/python/app/sources/external/elasticsearch_db/elasticsearch_db_.py @@ -0,0 +1,104 @@ +# ruff: noqa +from __future__ import annotations + +from elasticsearch import Elasticsearch # type: ignore[import-untyped] +from typing import Dict, List, Optional, Union, cast + +from app.sources.client.elasticsearch_db.elasticsearch_db import ElasticsearchResponse + +class ElasticsearchDataSource: + """ + Strict, typed wrapper over elasticsearch-py for common Elasticsearch operations. + + Accepts either an elasticsearch-py `Elasticsearch` instance *or* any object with `.get_sdk() -> Elasticsearch`. + """ + + def __init__(self, client_or_sdk: Union[Elasticsearch, object]) -> None: + super().__init__() + # Support a raw SDK or a wrapper that exposes `.get_sdk()` + if hasattr(client_or_sdk, "get_sdk"): + sdk_obj = getattr(client_or_sdk, "get_sdk")() + self._sdk: Elasticsearch = cast(Elasticsearch, sdk_obj) + else: + self._sdk = cast(Elasticsearch, client_or_sdk) + + # ---- helpers ---- + @staticmethod + def _params(**kwargs: object) -> Dict[str, object]: + # Filter out Nones to avoid overriding SDK defaults + out: Dict[str, object] = {} + for k, v in kwargs.items(): + if v is None: + continue + if isinstance(v, (list, dict)) and len(v) == 0: + continue + out[k] = v + return out + def info(self) -> ElasticsearchResponse: + """Get cluster info.""" + result = self._sdk.info() + return ElasticsearchResponse(success=True, data=result) + def get_cluster_health(self) -> ElasticsearchResponse: + """Get cluster health status.""" + result = self._sdk.cluster.health() + return ElasticsearchResponse(success=True, data=result) + def get_cluster_stats(self) -> ElasticsearchResponse: + """Get cluster statistics.""" + result = self._sdk.cluster.stats() + return ElasticsearchResponse(success=True, data=result) + def list_indices(self) -> ElasticsearchResponse: + """List all indices and their aliases.""" + result = self._sdk.indices.get_alias(index='*') + return ElasticsearchResponse(success=True, data=result) + def create_index(self, index: str, body: Optional[Dict[str, object]] = None) -> ElasticsearchResponse: + """Create an index with optional settings/mappings.""" + params = self._params(index=index, body=body) + result = self._sdk.indices.create(**params) + return ElasticsearchResponse(success=True, data=result) + def delete_index(self, index: str) -> ElasticsearchResponse: + """Delete an index.""" + result = self._sdk.indices.delete(index=index) + return ElasticsearchResponse(success=True, data=result) + def get_mapping(self, index: str) -> ElasticsearchResponse: + """Get mapping for an index.""" + result = self._sdk.indices.get_mapping(index=index) + return ElasticsearchResponse(success=True, data=result) + def search(self, index: str, body: Optional[Dict[str, object]] = None, size: Optional[int] = None, from_: Optional[int] = None, sort: Optional[str] = None) -> ElasticsearchResponse: + """Search documents in an index.""" + params = self._params(index=index, body=body, size=size, sort=sort) + if from_ is not None: + params['from_'] = from_ + result = self._sdk.search(**params) + return ElasticsearchResponse(success=True, data=result) + def index_document(self, index: str, body: Dict[str, object], doc_id: Optional[str] = None) -> ElasticsearchResponse: + """Index (create/update) a document.""" + params = self._params(index=index, body=body, id=doc_id) + result = self._sdk.index(**params) + return ElasticsearchResponse(success=True, data=result) + def get_document(self, index: str, doc_id: str) -> ElasticsearchResponse: + """Get a document by ID.""" + result = self._sdk.get(index=index, id=doc_id) + return ElasticsearchResponse(success=True, data=result) + def delete_document(self, index: str, doc_id: str) -> ElasticsearchResponse: + """Delete a document by ID.""" + result = self._sdk.delete(index=index, id=doc_id) + return ElasticsearchResponse(success=True, data=result) + def count(self, index: str, body: Optional[Dict[str, object]] = None) -> ElasticsearchResponse: + """Count documents in an index.""" + params = self._params(index=index, body=body) + result = self._sdk.count(**params) + return ElasticsearchResponse(success=True, data=result) + def bulk(self, body: List[Dict[str, object]], index: Optional[str] = None) -> ElasticsearchResponse: + """Perform bulk operations.""" + params = self._params(body=body, index=index) + result = self._sdk.bulk(**params) + return ElasticsearchResponse(success=True, data=result) + def scroll(self, scroll_id: str, scroll: str = '5m') -> ElasticsearchResponse: + """Continue a scroll search.""" + result = self._sdk.scroll(scroll_id=scroll_id, scroll=scroll) + return ElasticsearchResponse(success=True, data=result) + def clear_scroll(self, scroll_id: str) -> ElasticsearchResponse: + """Clear a scroll context.""" + result = self._sdk.clear_scroll(scroll_id=scroll_id) + return ElasticsearchResponse(success=True, data=result) + diff --git a/backend/python/app/sources/external/elasticsearch_db/example.py b/backend/python/app/sources/external/elasticsearch_db/example.py new file mode 100644 index 000000000..0a18ef25f --- /dev/null +++ b/backend/python/app/sources/external/elasticsearch_db/example.py @@ -0,0 +1,150 @@ +# ruff: noqa +from __future__ import annotations + +import os + +from dotenv import load_dotenv + +from app.sources.client.elasticsearch_db.elasticsearch_db import ( + ElasticsearchClient, + ElasticsearchApiKeyConfig, + ElasticsearchBasicAuthConfig, + ElasticsearchResponse, +) +from app.sources.external.elasticsearch_db.elasticsearch_db_ import ElasticsearchDataSource + + +def _print_status(title: str, res: ElasticsearchResponse) -> None: + print(f"\n== {title} ==") + if not res.success: + print("error:", res.error or res.message) + else: + print("ok") + + +def main() -> None: + # Load .env if present + load_dotenv() + + # Minimal envs + hosts_str = os.getenv("ELASTICSEARCH_HOSTS", "https://localhost:9200") + hosts = [h.strip() for h in hosts_str.split(",")] + auth_type = os.getenv("ELASTICSEARCH_AUTH_TYPE", "BASIC_AUTH") # API_KEY or BASIC_AUTH + + if auth_type == "API_KEY": + api_key_id = os.getenv("ELASTICSEARCH_API_KEY_ID", "") + api_key_secret = os.getenv("ELASTICSEARCH_API_KEY_SECRET", "") + if not api_key_id or not api_key_secret: + raise RuntimeError("ELASTICSEARCH_API_KEY_ID and ELASTICSEARCH_API_KEY_SECRET are required for API_KEY auth") + client = ElasticsearchClient.build_with_config( + ElasticsearchApiKeyConfig( + hosts=hosts, + api_key_id=api_key_id, + api_key_secret=api_key_secret, + verify_certs=False, + ) + ) + else: + username = os.getenv("ELASTICSEARCH_USERNAME", "elastic") + password = os.getenv("ELASTICSEARCH_PASSWORD", "") + if not password: + raise RuntimeError("ELASTICSEARCH_PASSWORD is required for BASIC_AUTH") + client = ElasticsearchClient.build_with_config( + ElasticsearchBasicAuthConfig( + hosts=hosts, + username=username, + password=password, + verify_certs=False, + ) + ) + + ds = ElasticsearchDataSource(client) + + # 1) Cluster info + info_res: ElasticsearchResponse = ds.info() + _print_status("Cluster Info", info_res) + if info_res.success and info_res.data: + print("cluster_name:", info_res.data.get("cluster_name")) + + # 2) Cluster health + health_res: ElasticsearchResponse = ds.get_cluster_health() + _print_status("Cluster Health", health_res) + if health_res.success and health_res.data: + print("status:", health_res.data.get("status")) + + # 3) List indices + indices_res: ElasticsearchResponse = ds.list_indices() + _print_status("List Indices", indices_res) + if indices_res.success and indices_res.data: + index_names = list(indices_res.data.keys())[:10] + print("indices:", index_names) + + # 4) Create a test index + test_index = "pipeshub-test-index" + try: + create_res: ElasticsearchResponse = ds.create_index( + test_index, + body={"settings": {"number_of_shards": 1, "number_of_replicas": 0}}, + ) + _print_status(f"Create Index ({test_index})", create_res) + except Exception as e: + print(f"Create index failed (may already exist): {e}") + + # 5) Index a document + try: + doc_res: ElasticsearchResponse = ds.index_document( + test_index, + body={"title": "Test document", "content": "Hello from PipesHub"}, + doc_id="doc-1", + ) + _print_status("Index Document", doc_res) + except Exception as e: + print(f"Index document failed: {e}") + + # 6) Get the document + try: + get_res: ElasticsearchResponse = ds.get_document(test_index, "doc-1") + _print_status("Get Document", get_res) + if get_res.success and get_res.data: + print("source:", get_res.data.get("_source")) + except Exception as e: + print(f"Get document failed: {e}") + + # 7) Search + try: + search_res: ElasticsearchResponse = ds.search( + test_index, body={"query": {"match_all": {}}} + ) + _print_status("Search", search_res) + if search_res.success and search_res.data: + hits = search_res.data.get("hits", {}).get("hits", []) + print(f"hits: {len(hits)}") + except Exception as e: + print(f"Search failed: {e}") + + # 8) Count + try: + count_res: ElasticsearchResponse = ds.count(test_index) + _print_status("Count", count_res) + if count_res.success and count_res.data: + print("count:", count_res.data.get("count")) + except Exception as e: + print(f"Count failed: {e}") + + # 9) Delete the document + try: + del_doc_res: ElasticsearchResponse = ds.delete_document(test_index, "doc-1") + _print_status("Delete Document", del_doc_res) + except Exception as e: + print(f"Delete document failed: {e}") + + # 10) Delete the test index + try: + del_idx_res: ElasticsearchResponse = ds.delete_index(test_index) + _print_status(f"Delete Index ({test_index})", del_idx_res) + except Exception as e: + print(f"Delete index failed: {e}") + + +if __name__ == "__main__": + main() diff --git a/backend/python/app/sources/external/esalesmanager/code_generator.py b/backend/python/app/sources/external/esalesmanager/code_generator.py new file mode 100644 index 000000000..f9e86a802 --- /dev/null +++ b/backend/python/app/sources/external/esalesmanager/code_generator.py @@ -0,0 +1,212 @@ +# ruff: noqa +""" +eSalesManager DataSource Code Generator + +Defines eSalesManager API endpoint specifications and generates the DataSource +wrapper class (esalesmanager.py) from them. + +Endpoints: + /customers, /customers/{id}, /contacts, /contacts/{id}, + /activities, /activities/{id}, /deals, /deals/{id}, + /products, /products/{id}, /tasks, /tasks/{id}, + /reports, /users, /users/{id} +""" + +from __future__ import annotations + +ENDPOINTS = [ + # Customers + {"method": "GET", "path": "/customers", "name": "list_customers", "section": "Customers", + "doc": "List all customers", "paginated": True}, + {"method": "GET", "path": "/customers/{customer_id}", "name": "get_customer", "section": "Customers", + "doc": "Get a specific customer by ID", "path_params": ["customer_id"]}, + # Contacts + {"method": "GET", "path": "/contacts", "name": "list_contacts", "section": "Contacts", + "doc": "List all contacts", "paginated": True}, + {"method": "GET", "path": "/contacts/{contact_id}", "name": "get_contact", "section": "Contacts", + "doc": "Get a specific contact by ID", "path_params": ["contact_id"]}, + # Activities + {"method": "GET", "path": "/activities", "name": "list_activities", "section": "Activities", + "doc": "List all activities", "paginated": True}, + {"method": "GET", "path": "/activities/{activity_id}", "name": "get_activity", "section": "Activities", + "doc": "Get a specific activity by ID", "path_params": ["activity_id"]}, + # Deals + {"method": "GET", "path": "/deals", "name": "list_deals", "section": "Deals", + "doc": "List all deals", "paginated": True}, + {"method": "GET", "path": "/deals/{deal_id}", "name": "get_deal", "section": "Deals", + "doc": "Get a specific deal by ID", "path_params": ["deal_id"]}, + # Products + {"method": "GET", "path": "/products", "name": "list_products", "section": "Products", + "doc": "List all products", "paginated": True}, + {"method": "GET", "path": "/products/{product_id}", "name": "get_product", "section": "Products", + "doc": "Get a specific product by ID", "path_params": ["product_id"]}, + # Tasks + {"method": "GET", "path": "/tasks", "name": "list_tasks", "section": "Tasks", + "doc": "List all tasks", "paginated": True}, + {"method": "GET", "path": "/tasks/{task_id}", "name": "get_task", "section": "Tasks", + "doc": "Get a specific task by ID", "path_params": ["task_id"]}, + # Reports + {"method": "GET", "path": "/reports", "name": "list_reports", "section": "Reports", + "doc": "List all reports", "paginated": True}, + # Users + {"method": "GET", "path": "/users", "name": "list_users", "section": "Users", + "doc": "List all users", "paginated": True}, + {"method": "GET", "path": "/users/{user_id}", "name": "get_user", "section": "Users", + "doc": "Get a specific user by ID", "path_params": ["user_id"]}, +] + + +def _gen_method(ep: dict) -> str: + """Generate a single async method from an endpoint spec.""" + name = ep["name"] + method = ep["method"] + path = ep["path"] + doc = ep["doc"] + path_params = ep.get("path_params", []) + paginated = ep.get("paginated", False) + + sig_parts = ["self"] + for p in path_params: + sig_parts.append(f"{p}: str") + if paginated: + sig_parts.append("*") + sig_parts.append("page: int | None = None") + sig_parts.append("per_page: int | None = None") + + sig = ",\n ".join(sig_parts) + + doc_args = "" + if path_params or paginated: + doc_args = "\n Args:\n" + for p in path_params: + doc_args += f" {p}: The {p.replace('_', ' ')}\n" + if paginated: + doc_args += " page: Page number for pagination\n" + doc_args += " per_page: Number of items per page\n" + + query_block = "" + if paginated: + query_block = """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) +""" + + if path_params: + fmt_args = ", ".join(f"{p}={p}" for p in path_params) + url_line = f' url = self.base_url + "{path}".format({fmt_args})' + else: + url_line = f' url = self.base_url + "{path}"' + + req_extra = "" + if paginated: + req_extra = "\n query=query_params," + + return f''' + async def {name}( + {sig} + ) -> ESalesManagerResponse: + """{doc} + + HTTP {method} {path} +{doc_args} + Returns: + ESalesManagerResponse with operation result + """ +{query_block} +{url_line} + + try: + request = HTTPRequest( + method="{method}", + url=url, + headers={{"Content-Type": "application/json"}},{req_extra} + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return ESalesManagerResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed {name}" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {{response.status}}" + ) + except Exception as e: + return ESalesManagerResponse(success=False, error=str(e), message="Failed to execute {name}") +''' + + +def generate_datasource() -> str: + """Generate the full eSalesManager DataSource module code.""" + header = '''# ruff: noqa +""" +eSalesManager REST API DataSource - Auto-generated API wrapper + +Generated from eSalesManager REST API v1 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.esalesmanager.esalesmanager import ESalesManagerClient, ESalesManagerResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class ESalesManagerDataSource: + """eSalesManager REST API DataSource + + Provides async wrapper methods for eSalesManager REST API operations: + - Customers management + - Contacts management + - Activities management + - Deals management + - Products management + - Tasks management + - Reports + - Users management + + All methods return ESalesManagerResponse objects. + """ + + def __init__(self, client: ESalesManagerClient) -> None: + """Initialize with ESalesManagerClient. + + Args: + client: ESalesManagerClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'ESalesManagerDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> ESalesManagerClient: + """Return the underlying ESalesManagerClient.""" + return self._client +''' + + methods = [] + current_section = None + for ep in ENDPOINTS: + section = ep.get("section", "") + if section and section != current_section: + current_section = section + methods.append(f"\n # {'-' * 71}\n # {section}\n # {'-' * 71}") + methods.append(_gen_method(ep)) + + return header + "\n".join(methods) + "\n" + + +if __name__ == "__main__": + print(generate_datasource()) diff --git a/backend/python/app/sources/external/esalesmanager/esalesmanager.py b/backend/python/app/sources/external/esalesmanager/esalesmanager.py new file mode 100644 index 000000000..f62d6b843 --- /dev/null +++ b/backend/python/app/sources/external/esalesmanager/esalesmanager.py @@ -0,0 +1,648 @@ +# ruff: noqa +""" +eSalesManager REST API DataSource - Auto-generated API wrapper + +Generated from eSalesManager REST API v1 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.esalesmanager.esalesmanager import ESalesManagerClient, ESalesManagerResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class ESalesManagerDataSource: + """eSalesManager REST API DataSource + + Provides async wrapper methods for eSalesManager REST API operations: + - Customers management + - Contacts management + - Activities management + - Deals management + - Products management + - Tasks management + - Reports + - Users management + + All methods return ESalesManagerResponse objects. + """ + + def __init__(self, client: ESalesManagerClient) -> None: + """Initialize with ESalesManagerClient. + + Args: + client: ESalesManagerClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'ESalesManagerDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> ESalesManagerClient: + """Return the underlying ESalesManagerClient.""" + return self._client + + # ----------------------------------------------------------------------- + # Customers + # ----------------------------------------------------------------------- + + async def list_customers( + self, + *, + page: int | None = None, + per_page: int | None = None + ) -> ESalesManagerResponse: + """List all customers + + HTTP GET /customers + + Args: + page: Page number for pagination + per_page: Number of items per page + + Returns: + ESalesManagerResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/customers" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return ESalesManagerResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_customers" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return ESalesManagerResponse(success=False, error=str(e), message="Failed to execute list_customers") + + async def get_customer( + self, + customer_id: str + ) -> ESalesManagerResponse: + """Get a specific customer by ID + + HTTP GET /customers/{customer_id} + + Args: + customer_id: The customer ID + + Returns: + ESalesManagerResponse with operation result + """ + url = self.base_url + "/customers/{customer_id}".format(customer_id=customer_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return ESalesManagerResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_customer" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return ESalesManagerResponse(success=False, error=str(e), message="Failed to execute get_customer") + + # ----------------------------------------------------------------------- + # Contacts + # ----------------------------------------------------------------------- + + async def list_contacts( + self, + *, + page: int | None = None, + per_page: int | None = None + ) -> ESalesManagerResponse: + """List all contacts + + HTTP GET /contacts + + Args: + page: Page number for pagination + per_page: Number of items per page + + Returns: + ESalesManagerResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/contacts" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return ESalesManagerResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_contacts" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return ESalesManagerResponse(success=False, error=str(e), message="Failed to execute list_contacts") + + async def get_contact( + self, + contact_id: str + ) -> ESalesManagerResponse: + """Get a specific contact by ID + + HTTP GET /contacts/{contact_id} + + Args: + contact_id: The contact ID + + Returns: + ESalesManagerResponse with operation result + """ + url = self.base_url + "/contacts/{contact_id}".format(contact_id=contact_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return ESalesManagerResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_contact" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return ESalesManagerResponse(success=False, error=str(e), message="Failed to execute get_contact") + + # ----------------------------------------------------------------------- + # Activities + # ----------------------------------------------------------------------- + + async def list_activities( + self, + *, + page: int | None = None, + per_page: int | None = None + ) -> ESalesManagerResponse: + """List all activities + + HTTP GET /activities + + Args: + page: Page number for pagination + per_page: Number of items per page + + Returns: + ESalesManagerResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/activities" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return ESalesManagerResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_activities" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return ESalesManagerResponse(success=False, error=str(e), message="Failed to execute list_activities") + + async def get_activity( + self, + activity_id: str + ) -> ESalesManagerResponse: + """Get a specific activity by ID + + HTTP GET /activities/{activity_id} + + Args: + activity_id: The activity ID + + Returns: + ESalesManagerResponse with operation result + """ + url = self.base_url + "/activities/{activity_id}".format(activity_id=activity_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return ESalesManagerResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_activity" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return ESalesManagerResponse(success=False, error=str(e), message="Failed to execute get_activity") + + # ----------------------------------------------------------------------- + # Deals + # ----------------------------------------------------------------------- + + async def list_deals( + self, + *, + page: int | None = None, + per_page: int | None = None + ) -> ESalesManagerResponse: + """List all deals + + HTTP GET /deals + + Args: + page: Page number for pagination + per_page: Number of items per page + + Returns: + ESalesManagerResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/deals" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return ESalesManagerResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_deals" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return ESalesManagerResponse(success=False, error=str(e), message="Failed to execute list_deals") + + async def get_deal( + self, + deal_id: str + ) -> ESalesManagerResponse: + """Get a specific deal by ID + + HTTP GET /deals/{deal_id} + + Args: + deal_id: The deal ID + + Returns: + ESalesManagerResponse with operation result + """ + url = self.base_url + "/deals/{deal_id}".format(deal_id=deal_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return ESalesManagerResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_deal" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return ESalesManagerResponse(success=False, error=str(e), message="Failed to execute get_deal") + + # ----------------------------------------------------------------------- + # Products + # ----------------------------------------------------------------------- + + async def list_products( + self, + *, + page: int | None = None, + per_page: int | None = None + ) -> ESalesManagerResponse: + """List all products + + HTTP GET /products + + Args: + page: Page number for pagination + per_page: Number of items per page + + Returns: + ESalesManagerResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/products" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return ESalesManagerResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_products" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return ESalesManagerResponse(success=False, error=str(e), message="Failed to execute list_products") + + async def get_product( + self, + product_id: str + ) -> ESalesManagerResponse: + """Get a specific product by ID + + HTTP GET /products/{product_id} + + Args: + product_id: The product ID + + Returns: + ESalesManagerResponse with operation result + """ + url = self.base_url + "/products/{product_id}".format(product_id=product_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return ESalesManagerResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_product" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return ESalesManagerResponse(success=False, error=str(e), message="Failed to execute get_product") + + # ----------------------------------------------------------------------- + # Tasks + # ----------------------------------------------------------------------- + + async def list_tasks( + self, + *, + page: int | None = None, + per_page: int | None = None + ) -> ESalesManagerResponse: + """List all tasks + + HTTP GET /tasks + + Args: + page: Page number for pagination + per_page: Number of items per page + + Returns: + ESalesManagerResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/tasks" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return ESalesManagerResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_tasks" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return ESalesManagerResponse(success=False, error=str(e), message="Failed to execute list_tasks") + + async def get_task( + self, + task_id: str + ) -> ESalesManagerResponse: + """Get a specific task by ID + + HTTP GET /tasks/{task_id} + + Args: + task_id: The task ID + + Returns: + ESalesManagerResponse with operation result + """ + url = self.base_url + "/tasks/{task_id}".format(task_id=task_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return ESalesManagerResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_task" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return ESalesManagerResponse(success=False, error=str(e), message="Failed to execute get_task") + + # ----------------------------------------------------------------------- + # Reports + # ----------------------------------------------------------------------- + + async def list_reports( + self, + *, + page: int | None = None, + per_page: int | None = None + ) -> ESalesManagerResponse: + """List all reports + + HTTP GET /reports + + Args: + page: Page number for pagination + per_page: Number of items per page + + Returns: + ESalesManagerResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/reports" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return ESalesManagerResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_reports" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return ESalesManagerResponse(success=False, error=str(e), message="Failed to execute list_reports") + + # ----------------------------------------------------------------------- + # Users + # ----------------------------------------------------------------------- + + async def list_users( + self, + *, + page: int | None = None, + per_page: int | None = None + ) -> ESalesManagerResponse: + """List all users + + HTTP GET /users + + Args: + page: Page number for pagination + per_page: Number of items per page + + Returns: + ESalesManagerResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/users" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return ESalesManagerResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_users" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return ESalesManagerResponse(success=False, error=str(e), message="Failed to execute list_users") + + async def get_user( + self, + user_id: str + ) -> ESalesManagerResponse: + """Get a specific user by ID + + HTTP GET /users/{user_id} + + Args: + user_id: The user ID + + Returns: + ESalesManagerResponse with operation result + """ + url = self.base_url + "/users/{user_id}".format(user_id=user_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return ESalesManagerResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_user" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return ESalesManagerResponse(success=False, error=str(e), message="Failed to execute get_user") diff --git a/backend/python/app/sources/external/esalesmanager/example.py b/backend/python/app/sources/external/esalesmanager/example.py new file mode 100644 index 000000000..a556ebc8d --- /dev/null +++ b/backend/python/app/sources/external/esalesmanager/example.py @@ -0,0 +1,133 @@ +# ruff: noqa + +""" +eSalesManager API Usage Examples + +This example demonstrates how to use the eSalesManager DataSource to interact +with the eSalesManager API, covering: +- Authentication (API Key via X-API-Key header) +- Initializing the Client and DataSource +- Listing Customers, Contacts, Activities, Deals +- Fetching Products, Tasks, Reports, Users + +Prerequisites: +1. Obtain an API key from eSalesManager +2. Set ESALESMANAGER_API_KEY environment variable +""" + +import asyncio +import json +import os + +from app.sources.client.esalesmanager.esalesmanager import ( + ESalesManagerClient, + ESalesManagerApiKeyConfig, + ESalesManagerResponse, +) +from app.sources.external.esalesmanager.esalesmanager import ESalesManagerDataSource + +# --- Configuration --- +API_KEY = os.getenv("ESALESMANAGER_API_KEY") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: ESalesManagerResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + for key in ("customers", "contacts", "activities", "deals", "products", + "tasks", "reports", "users", "results"): + if isinstance(data, dict) and key in data: + items = data[key] + print(f" Found {len(items)} {key}.") + if items: + print(f" Sample: {json.dumps(items[0], indent=2)[:400]}...") + return + if isinstance(data, list): + print(f" Found {len(data)} items.") + if data: + print(f" Sample: {json.dumps(data[0], indent=2)[:400]}...") + return + print(f" Data: {json.dumps(data, indent=2)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + # 1. Initialize Client + print_section("Initializing eSalesManager Client") + + if not API_KEY: + print(" No API key found.") + print(" Please set ESALESMANAGER_API_KEY environment variable.") + return + + print(" Using API Key authentication (X-API-Key header)") + config = ESalesManagerApiKeyConfig(api_key=API_KEY) + client = ESalesManagerClient.build_with_config(config) + data_source = ESalesManagerDataSource(client) + print("Client initialized successfully.") + + try: + # 2. List Customers + print_section("Customers") + customers_resp = await data_source.list_customers(page=1, per_page=10) + print_result("List Customers", customers_resp) + + # 3. List Contacts + print_section("Contacts") + contacts_resp = await data_source.list_contacts(page=1, per_page=10) + print_result("List Contacts", contacts_resp) + + # 4. List Activities + print_section("Activities") + activities_resp = await data_source.list_activities(page=1, per_page=10) + print_result("List Activities", activities_resp) + + # 5. List Deals + print_section("Deals") + deals_resp = await data_source.list_deals(page=1, per_page=10) + print_result("List Deals", deals_resp) + + # 6. List Products + print_section("Products") + products_resp = await data_source.list_products(page=1, per_page=10) + print_result("List Products", products_resp) + + # 7. List Tasks + print_section("Tasks") + tasks_resp = await data_source.list_tasks(page=1, per_page=10) + print_result("List Tasks", tasks_resp) + + # 8. List Reports + print_section("Reports") + reports_resp = await data_source.list_reports(page=1, per_page=10) + print_result("List Reports", reports_resp) + + # 9. List Users + print_section("Users") + users_resp = await data_source.list_users(page=1, per_page=10) + print_result("List Users", users_resp) + + finally: + print("\nClosing client connection...") + inner_client = client.get_client() + if hasattr(inner_client, "close"): + await inner_client.close() + + print("\n" + "=" * 80) + print(" All eSalesManager API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/esalesmanager/run_generator.py b/backend/python/app/sources/external/esalesmanager/run_generator.py new file mode 100644 index 000000000..e3593f48c --- /dev/null +++ b/backend/python/app/sources/external/esalesmanager/run_generator.py @@ -0,0 +1,24 @@ +"""Runner script to generate the eSalesManager DataSource wrapper. + +Execute this script to regenerate esalesmanager.py from the endpoint definitions +in code_generator.py. + +Usage: + python -m app.sources.external.esalesmanager.run_generator +""" + +from app.sources.external.esalesmanager.code_generator import generate_datasource + + +def main() -> None: + """Generate the eSalesManager DataSource file.""" + code = generate_datasource() + output_path = __file__.replace("run_generator.py", "esalesmanager.py") + with open(output_path, "w") as f: + f.write(code) + print(f"Generated eSalesManager DataSource -> {output_path}") + print(f" Total characters: {len(code)}") + + +if __name__ == "__main__": + main() diff --git a/backend/python/app/sources/external/fellow/code_generator.py b/backend/python/app/sources/external/fellow/code_generator.py new file mode 100644 index 000000000..735266f4f --- /dev/null +++ b/backend/python/app/sources/external/fellow/code_generator.py @@ -0,0 +1,219 @@ +# ruff: noqa +""" +Fellow DataSource Code Generator + +Defines Fellow API endpoint specifications and generates the DataSource +wrapper class (fellow.py) from them. + +Endpoints: + /meetings, /meetings/{id}, /meetings/{id}/notes, /meetings/{id}/action-items, + /users, /users/{id}, /streams, /streams/{id}, /feedback, /feedback/{id}, + /objectives, /objectives/{id}, /one-on-ones, /one-on-ones/{id} +""" + +from __future__ import annotations + +ENDPOINTS = [ + # Meetings + {"method": "GET", "path": "/meetings", "name": "list_meetings", "section": "Meetings", + "doc": "List all meetings", "query_params": [("limit", "int", "Maximum number of results"), ("offset", "int", "Offset for pagination")]}, + {"method": "GET", "path": "/meetings/{meeting_id}", "name": "get_meeting", "section": "Meetings", + "doc": "Get a specific meeting by ID", "path_params": ["meeting_id"]}, + {"method": "GET", "path": "/meetings/{meeting_id}/notes", "name": "get_meeting_notes", "section": "Meetings", + "doc": "Get notes for a specific meeting", "path_params": ["meeting_id"]}, + {"method": "GET", "path": "/meetings/{meeting_id}/action-items", "name": "get_meeting_action_items", "section": "Meetings", + "doc": "Get action items for a specific meeting", "path_params": ["meeting_id"]}, + # Users + {"method": "GET", "path": "/users", "name": "list_users", "section": "Users", + "doc": "List all users", "query_params": [("limit", "int", "Maximum number of results"), ("offset", "int", "Offset for pagination")]}, + {"method": "GET", "path": "/users/{user_id}", "name": "get_user", "section": "Users", + "doc": "Get a specific user by ID", "path_params": ["user_id"]}, + # Streams + {"method": "GET", "path": "/streams", "name": "list_streams", "section": "Streams", + "doc": "List all streams", "query_params": [("limit", "int", "Maximum number of results"), ("offset", "int", "Offset for pagination")]}, + {"method": "GET", "path": "/streams/{stream_id}", "name": "get_stream", "section": "Streams", + "doc": "Get a specific stream by ID", "path_params": ["stream_id"]}, + # Feedback + {"method": "GET", "path": "/feedback", "name": "list_feedback", "section": "Feedback", + "doc": "List all feedback", "query_params": [("limit", "int", "Maximum number of results"), ("offset", "int", "Offset for pagination")]}, + {"method": "GET", "path": "/feedback/{feedback_id}", "name": "get_feedback", "section": "Feedback", + "doc": "Get a specific feedback item by ID", "path_params": ["feedback_id"]}, + # Objectives + {"method": "GET", "path": "/objectives", "name": "list_objectives", "section": "Objectives", + "doc": "List all objectives", "query_params": [("limit", "int", "Maximum number of results"), ("offset", "int", "Offset for pagination")]}, + {"method": "GET", "path": "/objectives/{objective_id}", "name": "get_objective", "section": "Objectives", + "doc": "Get a specific objective by ID", "path_params": ["objective_id"]}, + # One-on-Ones + {"method": "GET", "path": "/one-on-ones", "name": "list_one_on_ones", "section": "One-on-Ones", + "doc": "List all one-on-ones", "query_params": [("limit", "int", "Maximum number of results"), ("offset", "int", "Offset for pagination")]}, + {"method": "GET", "path": "/one-on-ones/{one_on_one_id}", "name": "get_one_on_one", "section": "One-on-Ones", + "doc": "Get a specific one-on-one by ID", "path_params": ["one_on_one_id"]}, +] + + +def _gen_method(ep: dict) -> str: + """Generate a single async method from an endpoint spec.""" + name = ep["name"] + method = ep["method"] + path = ep["path"] + doc = ep["doc"] + path_params = ep.get("path_params", []) + query_params = ep.get("query_params", []) + body_params = ep.get("body_params", []) + + sig_parts = ["self"] + for p in path_params: + sig_parts.append(f"{p}: str") + for bp in body_params: + sig_parts.append(f"{bp[0]}: {bp[2]}") + if query_params: + sig_parts.append("*") + for qp in query_params: + sig_parts.append(f"{qp[0]}: {qp[1]} | None = None") + + sig = ",\n ".join(sig_parts) + + doc_args = "" + if path_params or query_params or body_params: + doc_args = "\n Args:\n" + for p in path_params: + doc_args += f" {p}: The {p.replace('_', ' ')}\n" + for bp in body_params: + doc_args += f" {bp[0]}: {bp[3]}\n" + for qp in query_params: + doc_args += f" {qp[0]}: {qp[2]}\n" + + query_block = "" + if query_params: + lines = ["\n query_params: dict[str, Any] = {}"] + for qp in query_params: + lines.append(f" if {qp[0]} is not None:") + lines.append(f" query_params['{qp[0]}'] = str({qp[0]})") + query_block = "\n".join(lines) + "\n" + + if path_params: + fmt_args = ", ".join(f"{p}={p}" for p in path_params) + url_line = f' url = self.base_url + "{path}".format({fmt_args})' + else: + url_line = f' url = self.base_url + "{path}"' + + body_block = "" + if body_params: + lines = ["\n body: dict[str, Any] = {}"] + for bp in body_params: + lines.append(f' if {bp[0]} is not None:') + lines.append(f' body["{bp[1]}"] = {bp[0]}') + body_block = "\n".join(lines) + + req_extra = "" + if query_params: + req_extra += "\n query=query_params," + if body_params: + req_extra += "\n body=body," + + return f''' + async def {name}( + {sig} + ) -> FellowResponse: + """{doc} + + HTTP {method} {path} +{doc_args} + Returns: + FellowResponse with operation result + """ +{query_block} +{url_line} +{body_block} + + try: + request = HTTPRequest( + method="{method}", + url=url, + headers={{"Content-Type": "application/json"}},{req_extra} + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FellowResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed {name}" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {{response.status}}" + ) + except Exception as e: + return FellowResponse(success=False, error=str(e), message="Failed to execute {name}") +''' + + +def generate_datasource() -> str: + """Generate the full Fellow DataSource module code.""" + header = '''# ruff: noqa +""" +Fellow REST API DataSource - Auto-generated API wrapper + +Generated from Fellow REST API v2 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.fellow.fellow import FellowClient, FellowResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class FellowDataSource: + """Fellow REST API DataSource + + Provides async wrapper methods for Fellow REST API operations: + - Meetings management + - Meeting notes and action items + - Users management + - Streams management + - Feedback management + - Objectives management + - One-on-Ones management + + All methods return FellowResponse objects. + """ + + def __init__(self, client: FellowClient) -> None: + """Initialize with FellowClient. + + Args: + client: FellowClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'FellowDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> FellowClient: + """Return the underlying FellowClient.""" + return self._client +''' + + methods = [] + current_section = None + for ep in ENDPOINTS: + section = ep.get("section", "") + if section and section != current_section: + current_section = section + methods.append(f"\n # {'-' * 71}\n # {section}\n # {'-' * 71}") + methods.append(_gen_method(ep)) + + return header + "\n".join(methods) + "\n" + + +if __name__ == "__main__": + print(generate_datasource()) diff --git a/backend/python/app/sources/external/fellow/example.py b/backend/python/app/sources/external/fellow/example.py new file mode 100644 index 000000000..2e9db4e20 --- /dev/null +++ b/backend/python/app/sources/external/fellow/example.py @@ -0,0 +1,194 @@ +# ruff: noqa + +""" +Fellow API Usage Examples + +This example demonstrates how to use the Fellow DataSource to interact with +the Fellow API, covering: +- Authentication (OAuth2, API Key) +- Initializing the Client and DataSource +- Listing Meetings, Users, Streams +- Getting meeting notes and action items +- Getting feedback and objectives + +Prerequisites: +For OAuth2: +1. Register an OAuth app with Fellow +2. Set FELLOW_CLIENT_ID and FELLOW_CLIENT_SECRET environment variables + +For API Key: +1. Get your Fellow API key +2. Set FELLOW_API_KEY environment variable +""" + +import asyncio +import json +import os + +from app.sources.client.fellow.fellow import ( + FellowClient, + FellowOAuthConfig, + FellowResponse, + FellowTokenConfig, +) +from app.sources.external.fellow.fellow import FellowDataSource +from app.sources.external.utils.oauth import perform_oauth_flow + +# --- Configuration --- +# OAuth2 credentials (highest priority) +CLIENT_ID = os.getenv("FELLOW_CLIENT_ID") +CLIENT_SECRET = os.getenv("FELLOW_CLIENT_SECRET") + +# API Key (second priority) +API_KEY = os.getenv("FELLOW_API_KEY") + +# OAuth redirect URI +REDIRECT_URI = os.getenv("FELLOW_REDIRECT_URI", "http://localhost:8080/callback") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: FellowResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + for key in ("meetings", "users", "streams", "feedback", + "objectives", "one_on_ones", "notes", "action_items", + "results", "items"): + if isinstance(data, dict) and key in data: + items = data[key] + print(f" Found {len(items)} {key}.") + if items: + print(f" Sample: {json.dumps(items[0], indent=2)[:400]}...") + return + if isinstance(data, list): + print(f" Found {len(data)} items.") + if data: + print(f" Sample: {json.dumps(data[0], indent=2)[:400]}...") + return + print(f" Data: {json.dumps(data, indent=2)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + # 1. Initialize Client + print_section("Initializing Fellow Client") + + config = None + + # Priority 1: OAuth2 + if CLIENT_ID and CLIENT_SECRET: + print(" Using OAuth2 authentication") + try: + token_response = perform_oauth_flow( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + auth_endpoint="https://fellow.app/oauth/authorize", + token_endpoint="https://fellow.app/oauth/token", + redirect_uri=REDIRECT_URI, + scopes=[], + scope_delimiter=" ", + auth_method="body", + ) + access_token = token_response.get("access_token") + if not access_token: + raise Exception("No access_token found in OAuth response") + + config = FellowOAuthConfig( + access_token=access_token, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + ) + print(" OAuth authentication successful") + except Exception as e: + print(f" OAuth flow failed: {e}") + print(" Falling back to other authentication methods...") + + # Priority 2: API Key + if config is None and API_KEY: + print(" Using API Key authentication") + config = FellowTokenConfig(token=API_KEY) + + if config is None: + print(" No valid authentication method found.") + print(" Please set one of the following:") + print(" - FELLOW_CLIENT_ID and FELLOW_CLIENT_SECRET (for OAuth2)") + print(" - FELLOW_API_KEY (for API Key)") + return + + client = FellowClient.build_with_config(config) + data_source = FellowDataSource(client) + print("Client initialized successfully.") + + try: + # 2. List Meetings + print_section("Meetings") + meetings_resp = await data_source.list_meetings(limit=10) + print_result("List Meetings", meetings_resp) + + # Get meeting details if available + meeting_id = None + if meetings_resp.success and meetings_resp.data: + meetings = (meetings_resp.data.get("meetings", []) + if isinstance(meetings_resp.data, dict) else meetings_resp.data) + if isinstance(meetings, list) and meetings: + meeting_id = str(meetings[0].get("id") if isinstance(meetings[0], dict) else meetings[0]) + + if meeting_id: + # 3. Get Meeting Notes + print_section("Meeting Notes") + notes_resp = await data_source.get_meeting_notes(meeting_id=meeting_id) + print_result("Get Meeting Notes", notes_resp) + + # 4. Get Meeting Action Items + print_section("Meeting Action Items") + actions_resp = await data_source.get_meeting_action_items(meeting_id=meeting_id) + print_result("Get Meeting Action Items", actions_resp) + + # 5. List Users + print_section("Users") + users_resp = await data_source.list_users(limit=10) + print_result("List Users", users_resp) + + # 6. List Streams + print_section("Streams") + streams_resp = await data_source.list_streams(limit=10) + print_result("List Streams", streams_resp) + + # 7. List Feedback + print_section("Feedback") + feedback_resp = await data_source.list_feedback(limit=10) + print_result("List Feedback", feedback_resp) + + # 8. List Objectives + print_section("Objectives") + objectives_resp = await data_source.list_objectives(limit=10) + print_result("List Objectives", objectives_resp) + + # 9. List One-on-Ones + print_section("One-on-Ones") + one_on_ones_resp = await data_source.list_one_on_ones(limit=10) + print_result("List One-on-Ones", one_on_ones_resp) + + finally: + print("\nClosing client connection...") + inner_client = client.get_client() + if hasattr(inner_client, "close"): + await inner_client.close() + + print("\n" + "=" * 80) + print(" All Fellow API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/fellow/fellow.py b/backend/python/app/sources/external/fellow/fellow.py new file mode 100644 index 000000000..ccbb97ce1 --- /dev/null +++ b/backend/python/app/sources/external/fellow/fellow.py @@ -0,0 +1,629 @@ +# ruff: noqa +""" +Fellow REST API DataSource - Auto-generated API wrapper + +Generated from Fellow REST API v2 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.fellow.fellow import FellowClient, FellowResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class FellowDataSource: + """Fellow REST API DataSource + + Provides async wrapper methods for Fellow REST API operations: + - Meetings management + - Meeting notes and action items + - Users management + - Streams management + - Feedback management + - Objectives management + - One-on-Ones management + + All methods return FellowResponse objects. + """ + + def __init__(self, client: FellowClient) -> None: + """Initialize with FellowClient. + + Args: + client: FellowClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'FellowDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> FellowClient: + """Return the underlying FellowClient.""" + return self._client + + # ----------------------------------------------------------------------- + # Meetings + # ----------------------------------------------------------------------- + + async def list_meetings( + self, + *, + limit: int | None = None, + offset: int | None = None + ) -> FellowResponse: + """List all meetings + + HTTP GET /meetings + + Args: + limit: Maximum number of results + offset: Offset for pagination + + Returns: + FellowResponse with operation result + """ + + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/meetings" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FellowResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_meetings" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FellowResponse(success=False, error=str(e), message="Failed to execute list_meetings") + + + async def get_meeting( + self, + meeting_id: str + ) -> FellowResponse: + """Get a specific meeting by ID + + HTTP GET /meetings/{meeting_id} + + Args: + meeting_id: The meeting id + + Returns: + FellowResponse with operation result + """ + + url = self.base_url + "/meetings/{meeting_id}".format(meeting_id=meeting_id) + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FellowResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_meeting" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FellowResponse(success=False, error=str(e), message="Failed to execute get_meeting") + + + async def get_meeting_notes( + self, + meeting_id: str + ) -> FellowResponse: + """Get notes for a specific meeting + + HTTP GET /meetings/{meeting_id}/notes + + Args: + meeting_id: The meeting id + + Returns: + FellowResponse with operation result + """ + + url = self.base_url + "/meetings/{meeting_id}/notes".format(meeting_id=meeting_id) + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FellowResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_meeting_notes" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FellowResponse(success=False, error=str(e), message="Failed to execute get_meeting_notes") + + + async def get_meeting_action_items( + self, + meeting_id: str + ) -> FellowResponse: + """Get action items for a specific meeting + + HTTP GET /meetings/{meeting_id}/action-items + + Args: + meeting_id: The meeting id + + Returns: + FellowResponse with operation result + """ + + url = self.base_url + "/meetings/{meeting_id}/action-items".format(meeting_id=meeting_id) + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FellowResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_meeting_action_items" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FellowResponse(success=False, error=str(e), message="Failed to execute get_meeting_action_items") + + + # ----------------------------------------------------------------------- + # Users + # ----------------------------------------------------------------------- + + async def list_users( + self, + *, + limit: int | None = None, + offset: int | None = None + ) -> FellowResponse: + """List all users + + HTTP GET /users + + Args: + limit: Maximum number of results + offset: Offset for pagination + + Returns: + FellowResponse with operation result + """ + + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/users" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FellowResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_users" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FellowResponse(success=False, error=str(e), message="Failed to execute list_users") + + + async def get_user( + self, + user_id: str + ) -> FellowResponse: + """Get a specific user by ID + + HTTP GET /users/{user_id} + + Args: + user_id: The user id + + Returns: + FellowResponse with operation result + """ + + url = self.base_url + "/users/{user_id}".format(user_id=user_id) + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FellowResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_user" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FellowResponse(success=False, error=str(e), message="Failed to execute get_user") + + + # ----------------------------------------------------------------------- + # Streams + # ----------------------------------------------------------------------- + + async def list_streams( + self, + *, + limit: int | None = None, + offset: int | None = None + ) -> FellowResponse: + """List all streams + + HTTP GET /streams + + Args: + limit: Maximum number of results + offset: Offset for pagination + + Returns: + FellowResponse with operation result + """ + + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/streams" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FellowResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_streams" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FellowResponse(success=False, error=str(e), message="Failed to execute list_streams") + + + async def get_stream( + self, + stream_id: str + ) -> FellowResponse: + """Get a specific stream by ID + + HTTP GET /streams/{stream_id} + + Args: + stream_id: The stream id + + Returns: + FellowResponse with operation result + """ + + url = self.base_url + "/streams/{stream_id}".format(stream_id=stream_id) + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FellowResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_stream" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FellowResponse(success=False, error=str(e), message="Failed to execute get_stream") + + + # ----------------------------------------------------------------------- + # Feedback + # ----------------------------------------------------------------------- + + async def list_feedback( + self, + *, + limit: int | None = None, + offset: int | None = None + ) -> FellowResponse: + """List all feedback + + HTTP GET /feedback + + Args: + limit: Maximum number of results + offset: Offset for pagination + + Returns: + FellowResponse with operation result + """ + + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/feedback" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FellowResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_feedback" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FellowResponse(success=False, error=str(e), message="Failed to execute list_feedback") + + + async def get_feedback( + self, + feedback_id: str + ) -> FellowResponse: + """Get a specific feedback item by ID + + HTTP GET /feedback/{feedback_id} + + Args: + feedback_id: The feedback id + + Returns: + FellowResponse with operation result + """ + + url = self.base_url + "/feedback/{feedback_id}".format(feedback_id=feedback_id) + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FellowResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_feedback" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FellowResponse(success=False, error=str(e), message="Failed to execute get_feedback") + + + # ----------------------------------------------------------------------- + # Objectives + # ----------------------------------------------------------------------- + + async def list_objectives( + self, + *, + limit: int | None = None, + offset: int | None = None + ) -> FellowResponse: + """List all objectives + + HTTP GET /objectives + + Args: + limit: Maximum number of results + offset: Offset for pagination + + Returns: + FellowResponse with operation result + """ + + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/objectives" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FellowResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_objectives" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FellowResponse(success=False, error=str(e), message="Failed to execute list_objectives") + + + async def get_objective( + self, + objective_id: str + ) -> FellowResponse: + """Get a specific objective by ID + + HTTP GET /objectives/{objective_id} + + Args: + objective_id: The objective id + + Returns: + FellowResponse with operation result + """ + + url = self.base_url + "/objectives/{objective_id}".format(objective_id=objective_id) + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FellowResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_objective" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FellowResponse(success=False, error=str(e), message="Failed to execute get_objective") + + + # ----------------------------------------------------------------------- + # One-on-Ones + # ----------------------------------------------------------------------- + + async def list_one_on_ones( + self, + *, + limit: int | None = None, + offset: int | None = None + ) -> FellowResponse: + """List all one-on-ones + + HTTP GET /one-on-ones + + Args: + limit: Maximum number of results + offset: Offset for pagination + + Returns: + FellowResponse with operation result + """ + + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/one-on-ones" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FellowResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_one_on_ones" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FellowResponse(success=False, error=str(e), message="Failed to execute list_one_on_ones") + + + async def get_one_on_one( + self, + one_on_one_id: str + ) -> FellowResponse: + """Get a specific one-on-one by ID + + HTTP GET /one-on-ones/{one_on_one_id} + + Args: + one_on_one_id: The one on one id + + Returns: + FellowResponse with operation result + """ + + url = self.base_url + "/one-on-ones/{one_on_one_id}".format(one_on_one_id=one_on_one_id) + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FellowResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_one_on_one" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FellowResponse(success=False, error=str(e), message="Failed to execute get_one_on_one") + diff --git a/backend/python/app/sources/external/fellow/run_generator.py b/backend/python/app/sources/external/fellow/run_generator.py new file mode 100644 index 000000000..ad3360d88 --- /dev/null +++ b/backend/python/app/sources/external/fellow/run_generator.py @@ -0,0 +1,24 @@ +"""Runner script to generate the Fellow DataSource wrapper. + +Execute this script to regenerate fellow.py from the endpoint definitions +in code_generator.py. + +Usage: + python -m app.sources.external.fellow.run_generator +""" + +from app.sources.external.fellow.code_generator import generate_datasource + + +def main() -> None: + """Generate the Fellow DataSource file.""" + code = generate_datasource() + output_path = __file__.replace("run_generator.py", "fellow.py") + with open(output_path, "w") as f: + f.write(code) + print(f"Generated Fellow DataSource -> {output_path}") + print(f" Total characters: {len(code)}") + + +if __name__ == "__main__": + main() diff --git a/backend/python/app/sources/external/fifteenfive/code_generator.py b/backend/python/app/sources/external/fifteenfive/code_generator.py new file mode 100644 index 000000000..b4aee8cd4 --- /dev/null +++ b/backend/python/app/sources/external/fifteenfive/code_generator.py @@ -0,0 +1,220 @@ +# ruff: noqa +""" +15Five DataSource Code Generator + +Defines 15Five API endpoint specifications and generates the DataSource +wrapper class (fifteenfive.py) from them. + +Endpoints: + /user, /user/{id}, /report, /report/{id}, /review, /review/{id}, + /objective, /objective/{id}, /pulse, /pulse/{id}, /group, /group/{id}, + /department, /department/{id}, /high-five, /high-five/{id}, + /one-on-one, /one-on-one/{id} +""" + +from __future__ import annotations + +ENDPOINTS = [ + # Users + {"method": "GET", "path": "/user", "name": "list_users", "section": "Users", + "doc": "List all users", "paginated": True}, + {"method": "GET", "path": "/user/{user_id}", "name": "get_user", "section": "Users", + "doc": "Get a specific user by ID", "path_params": ["user_id"]}, + # Reports + {"method": "GET", "path": "/report", "name": "list_reports", "section": "Reports", + "doc": "List all reports", "paginated": True}, + {"method": "GET", "path": "/report/{report_id}", "name": "get_report", "section": "Reports", + "doc": "Get a specific report by ID", "path_params": ["report_id"]}, + # Reviews + {"method": "GET", "path": "/review", "name": "list_reviews", "section": "Reviews", + "doc": "List all reviews", "paginated": True}, + {"method": "GET", "path": "/review/{review_id}", "name": "get_review", "section": "Reviews", + "doc": "Get a specific review by ID", "path_params": ["review_id"]}, + # Objectives + {"method": "GET", "path": "/objective", "name": "list_objectives", "section": "Objectives", + "doc": "List all objectives", "paginated": True}, + {"method": "GET", "path": "/objective/{objective_id}", "name": "get_objective", "section": "Objectives", + "doc": "Get a specific objective by ID", "path_params": ["objective_id"]}, + # Pulse + {"method": "GET", "path": "/pulse", "name": "list_pulses", "section": "Pulse", + "doc": "List all pulse surveys", "paginated": True}, + {"method": "GET", "path": "/pulse/{pulse_id}", "name": "get_pulse", "section": "Pulse", + "doc": "Get a specific pulse survey by ID", "path_params": ["pulse_id"]}, + # Groups + {"method": "GET", "path": "/group", "name": "list_groups", "section": "Groups", + "doc": "List all groups", "paginated": True}, + {"method": "GET", "path": "/group/{group_id}", "name": "get_group", "section": "Groups", + "doc": "Get a specific group by ID", "path_params": ["group_id"]}, + # Departments + {"method": "GET", "path": "/department", "name": "list_departments", "section": "Departments", + "doc": "List all departments", "paginated": True}, + {"method": "GET", "path": "/department/{department_id}", "name": "get_department", "section": "Departments", + "doc": "Get a specific department by ID", "path_params": ["department_id"]}, + # High-Fives + {"method": "GET", "path": "/high-five", "name": "list_high_fives", "section": "High-Fives", + "doc": "List all high-fives", "paginated": True}, + {"method": "GET", "path": "/high-five/{high_five_id}", "name": "get_high_five", "section": "High-Fives", + "doc": "Get a specific high-five by ID", "path_params": ["high_five_id"]}, + # One-on-Ones + {"method": "GET", "path": "/one-on-one", "name": "list_one_on_ones", "section": "One-on-Ones", + "doc": "List all one-on-ones", "paginated": True}, + {"method": "GET", "path": "/one-on-one/{one_on_one_id}", "name": "get_one_on_one", "section": "One-on-Ones", + "doc": "Get a specific one-on-one by ID", "path_params": ["one_on_one_id"]}, +] + + +def _gen_method(ep: dict) -> str: + """Generate a single async method from an endpoint spec.""" + name = ep["name"] + method = ep["method"] + path = ep["path"] + doc = ep["doc"] + path_params = ep.get("path_params", []) + paginated = ep.get("paginated", False) + + sig_parts = ["self"] + for p in path_params: + sig_parts.append(f"{p}: str") + if paginated: + sig_parts.append("*") + sig_parts.append("page: int | None = None") + sig_parts.append("page_size: int | None = None") + + sig = ",\n ".join(sig_parts) + + doc_args = "" + if path_params or paginated: + doc_args = "\n Args:\n" + for p in path_params: + doc_args += f" {p}: The {p.replace('_', ' ')}\n" + if paginated: + doc_args += " page: Page number for pagination\n" + doc_args += " page_size: Number of items per page\n" + + query_block = "" + if paginated: + query_block = """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if page_size is not None: + query_params['page_size'] = str(page_size) +""" + + if path_params: + fmt_args = ", ".join(f"{p}={p}" for p in path_params) + url_line = f' url = self.base_url + "{path}".format({fmt_args})' + else: + url_line = f' url = self.base_url + "{path}"' + + req_extra = "" + if paginated: + req_extra = "\n query=query_params," + + return f''' + async def {name}( + {sig} + ) -> FifteenFiveResponse: + """{doc} + + HTTP {method} {path} +{doc_args} + Returns: + FifteenFiveResponse with operation result + """ +{query_block} +{url_line} + + try: + request = HTTPRequest( + method="{method}", + url=url, + headers={{"Content-Type": "application/json"}},{req_extra} + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FifteenFiveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed {name}" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {{response.status}}" + ) + except Exception as e: + return FifteenFiveResponse(success=False, error=str(e), message="Failed to execute {name}") +''' + + +def generate_datasource() -> str: + """Generate the full 15Five DataSource module code.""" + header = '''# ruff: noqa +""" +15Five REST API DataSource - Auto-generated API wrapper + +Generated from 15Five REST API documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.fifteenfive.fifteenfive import FifteenFiveClient, FifteenFiveResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class FifteenFiveDataSource: + """15Five REST API DataSource + + Provides async wrapper methods for 15Five REST API operations: + - Users management + - Reports management + - Reviews management + - Objectives management + - Pulse surveys + - Groups management + - Departments management + - High-fives + - One-on-ones + + All methods return FifteenFiveResponse objects. + """ + + def __init__(self, client: FifteenFiveClient) -> None: + """Initialize with FifteenFiveClient. + + Args: + client: FifteenFiveClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'FifteenFiveDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> FifteenFiveClient: + """Return the underlying FifteenFiveClient.""" + return self._client +''' + + methods = [] + current_section = None + for ep in ENDPOINTS: + section = ep.get("section", "") + if section and section != current_section: + current_section = section + methods.append(f"\n # {'-' * 71}\n # {section}\n # {'-' * 71}") + methods.append(_gen_method(ep)) + + return header + "\n".join(methods) + "\n" + + +if __name__ == "__main__": + print(generate_datasource()) diff --git a/backend/python/app/sources/external/fifteenfive/example.py b/backend/python/app/sources/external/fifteenfive/example.py new file mode 100644 index 000000000..fc3501df8 --- /dev/null +++ b/backend/python/app/sources/external/fifteenfive/example.py @@ -0,0 +1,138 @@ +# ruff: noqa + +""" +15Five API Usage Examples + +This example demonstrates how to use the 15Five DataSource to interact with +the 15Five API, covering: +- Authentication (API Key / Bearer Token) +- Initializing the Client and DataSource +- Listing Users, Reports, Reviews, Objectives +- Fetching Pulses, Groups, Departments, High-Fives, One-on-Ones + +Prerequisites: +1. Obtain an API key from 15Five (Settings > API) +2. Set FIFTEENFIVE_API_KEY environment variable +""" + +import asyncio +import json +import os + +from app.sources.client.fifteenfive.fifteenfive import ( + FifteenFiveClient, + FifteenFiveTokenConfig, + FifteenFiveResponse, +) +from app.sources.external.fifteenfive.fifteenfive import FifteenFiveDataSource + +# --- Configuration --- +API_KEY = os.getenv("FIFTEENFIVE_API_KEY") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: FifteenFiveResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + for key in ("results", "users", "reports", "reviews", "objectives", + "pulses", "groups", "departments", "high_fives", "one_on_ones"): + if isinstance(data, dict) and key in data: + items = data[key] + print(f" Found {len(items)} {key}.") + if items: + print(f" Sample: {json.dumps(items[0], indent=2)[:400]}...") + return + if isinstance(data, list): + print(f" Found {len(data)} items.") + if data: + print(f" Sample: {json.dumps(data[0], indent=2)[:400]}...") + return + print(f" Data: {json.dumps(data, indent=2)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + # 1. Initialize Client + print_section("Initializing 15Five Client") + + if not API_KEY: + print(" No API key found.") + print(" Please set FIFTEENFIVE_API_KEY environment variable.") + return + + print(" Using API Key authentication") + config = FifteenFiveTokenConfig(token=API_KEY) + client = FifteenFiveClient.build_with_config(config) + data_source = FifteenFiveDataSource(client) + print("Client initialized successfully.") + + try: + # 2. List Users + print_section("Users") + users_resp = await data_source.list_users(page=1, page_size=10) + print_result("List Users", users_resp) + + # 3. List Reports + print_section("Reports") + reports_resp = await data_source.list_reports(page=1, page_size=10) + print_result("List Reports", reports_resp) + + # 4. List Reviews + print_section("Reviews") + reviews_resp = await data_source.list_reviews(page=1, page_size=10) + print_result("List Reviews", reviews_resp) + + # 5. List Objectives + print_section("Objectives") + objectives_resp = await data_source.list_objectives(page=1, page_size=10) + print_result("List Objectives", objectives_resp) + + # 6. List Pulses + print_section("Pulses") + pulses_resp = await data_source.list_pulses(page=1, page_size=10) + print_result("List Pulses", pulses_resp) + + # 7. List Groups + print_section("Groups") + groups_resp = await data_source.list_groups(page=1, page_size=10) + print_result("List Groups", groups_resp) + + # 8. List Departments + print_section("Departments") + depts_resp = await data_source.list_departments(page=1, page_size=10) + print_result("List Departments", depts_resp) + + # 9. List High-Fives + print_section("High-Fives") + hf_resp = await data_source.list_high_fives(page=1, page_size=10) + print_result("List High-Fives", hf_resp) + + # 10. List One-on-Ones + print_section("One-on-Ones") + ooo_resp = await data_source.list_one_on_ones(page=1, page_size=10) + print_result("List One-on-Ones", ooo_resp) + + finally: + print("\nClosing client connection...") + inner_client = client.get_client() + if hasattr(inner_client, "close"): + await inner_client.close() + + print("\n" + "=" * 80) + print(" All 15Five API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/fifteenfive/fifteenfive.py b/backend/python/app/sources/external/fifteenfive/fifteenfive.py new file mode 100644 index 000000000..60d6377b6 --- /dev/null +++ b/backend/python/app/sources/external/fifteenfive/fifteenfive.py @@ -0,0 +1,759 @@ +# ruff: noqa +""" +15Five REST API DataSource - Auto-generated API wrapper + +Generated from 15Five REST API documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.fifteenfive.fifteenfive import FifteenFiveClient, FifteenFiveResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class FifteenFiveDataSource: + """15Five REST API DataSource + + Provides async wrapper methods for 15Five REST API operations: + - Users management + - Reports management + - Reviews management + - Objectives management + - Pulse surveys + - Groups management + - Departments management + - High-fives + - One-on-ones + + All methods return FifteenFiveResponse objects. + """ + + def __init__(self, client: FifteenFiveClient) -> None: + """Initialize with FifteenFiveClient. + + Args: + client: FifteenFiveClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'FifteenFiveDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> FifteenFiveClient: + """Return the underlying FifteenFiveClient.""" + return self._client + + # ----------------------------------------------------------------------- + # Users + # ----------------------------------------------------------------------- + + async def list_users( + self, + *, + page: int | None = None, + page_size: int | None = None + ) -> FifteenFiveResponse: + """List all users + + HTTP GET /user + + Args: + page: Page number for pagination + page_size: Number of items per page + + Returns: + FifteenFiveResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if page_size is not None: + query_params['page_size'] = str(page_size) + + url = self.base_url + "/user" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FifteenFiveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_users" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FifteenFiveResponse(success=False, error=str(e), message="Failed to execute list_users") + + async def get_user( + self, + user_id: str + ) -> FifteenFiveResponse: + """Get a specific user by ID + + HTTP GET /user/{user_id} + + Args: + user_id: The user ID + + Returns: + FifteenFiveResponse with operation result + """ + url = self.base_url + "/user/{user_id}".format(user_id=user_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FifteenFiveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_user" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FifteenFiveResponse(success=False, error=str(e), message="Failed to execute get_user") + + # ----------------------------------------------------------------------- + # Reports + # ----------------------------------------------------------------------- + + async def list_reports( + self, + *, + page: int | None = None, + page_size: int | None = None + ) -> FifteenFiveResponse: + """List all reports + + HTTP GET /report + + Args: + page: Page number for pagination + page_size: Number of items per page + + Returns: + FifteenFiveResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if page_size is not None: + query_params['page_size'] = str(page_size) + + url = self.base_url + "/report" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FifteenFiveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_reports" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FifteenFiveResponse(success=False, error=str(e), message="Failed to execute list_reports") + + async def get_report( + self, + report_id: str + ) -> FifteenFiveResponse: + """Get a specific report by ID + + HTTP GET /report/{report_id} + + Args: + report_id: The report ID + + Returns: + FifteenFiveResponse with operation result + """ + url = self.base_url + "/report/{report_id}".format(report_id=report_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FifteenFiveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_report" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FifteenFiveResponse(success=False, error=str(e), message="Failed to execute get_report") + + # ----------------------------------------------------------------------- + # Reviews + # ----------------------------------------------------------------------- + + async def list_reviews( + self, + *, + page: int | None = None, + page_size: int | None = None + ) -> FifteenFiveResponse: + """List all reviews + + HTTP GET /review + + Args: + page: Page number for pagination + page_size: Number of items per page + + Returns: + FifteenFiveResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if page_size is not None: + query_params['page_size'] = str(page_size) + + url = self.base_url + "/review" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FifteenFiveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_reviews" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FifteenFiveResponse(success=False, error=str(e), message="Failed to execute list_reviews") + + async def get_review( + self, + review_id: str + ) -> FifteenFiveResponse: + """Get a specific review by ID + + HTTP GET /review/{review_id} + + Args: + review_id: The review ID + + Returns: + FifteenFiveResponse with operation result + """ + url = self.base_url + "/review/{review_id}".format(review_id=review_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FifteenFiveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_review" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FifteenFiveResponse(success=False, error=str(e), message="Failed to execute get_review") + + # ----------------------------------------------------------------------- + # Objectives + # ----------------------------------------------------------------------- + + async def list_objectives( + self, + *, + page: int | None = None, + page_size: int | None = None + ) -> FifteenFiveResponse: + """List all objectives + + HTTP GET /objective + + Args: + page: Page number for pagination + page_size: Number of items per page + + Returns: + FifteenFiveResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if page_size is not None: + query_params['page_size'] = str(page_size) + + url = self.base_url + "/objective" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FifteenFiveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_objectives" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FifteenFiveResponse(success=False, error=str(e), message="Failed to execute list_objectives") + + async def get_objective( + self, + objective_id: str + ) -> FifteenFiveResponse: + """Get a specific objective by ID + + HTTP GET /objective/{objective_id} + + Args: + objective_id: The objective ID + + Returns: + FifteenFiveResponse with operation result + """ + url = self.base_url + "/objective/{objective_id}".format(objective_id=objective_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FifteenFiveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_objective" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FifteenFiveResponse(success=False, error=str(e), message="Failed to execute get_objective") + + # ----------------------------------------------------------------------- + # Pulse + # ----------------------------------------------------------------------- + + async def list_pulses( + self, + *, + page: int | None = None, + page_size: int | None = None + ) -> FifteenFiveResponse: + """List all pulse surveys + + HTTP GET /pulse + + Args: + page: Page number for pagination + page_size: Number of items per page + + Returns: + FifteenFiveResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if page_size is not None: + query_params['page_size'] = str(page_size) + + url = self.base_url + "/pulse" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FifteenFiveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_pulses" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FifteenFiveResponse(success=False, error=str(e), message="Failed to execute list_pulses") + + async def get_pulse( + self, + pulse_id: str + ) -> FifteenFiveResponse: + """Get a specific pulse survey by ID + + HTTP GET /pulse/{pulse_id} + + Args: + pulse_id: The pulse ID + + Returns: + FifteenFiveResponse with operation result + """ + url = self.base_url + "/pulse/{pulse_id}".format(pulse_id=pulse_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FifteenFiveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_pulse" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FifteenFiveResponse(success=False, error=str(e), message="Failed to execute get_pulse") + + # ----------------------------------------------------------------------- + # Groups + # ----------------------------------------------------------------------- + + async def list_groups( + self, + *, + page: int | None = None, + page_size: int | None = None + ) -> FifteenFiveResponse: + """List all groups + + HTTP GET /group + + Args: + page: Page number for pagination + page_size: Number of items per page + + Returns: + FifteenFiveResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if page_size is not None: + query_params['page_size'] = str(page_size) + + url = self.base_url + "/group" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FifteenFiveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_groups" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FifteenFiveResponse(success=False, error=str(e), message="Failed to execute list_groups") + + async def get_group( + self, + group_id: str + ) -> FifteenFiveResponse: + """Get a specific group by ID + + HTTP GET /group/{group_id} + + Args: + group_id: The group ID + + Returns: + FifteenFiveResponse with operation result + """ + url = self.base_url + "/group/{group_id}".format(group_id=group_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FifteenFiveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_group" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FifteenFiveResponse(success=False, error=str(e), message="Failed to execute get_group") + + # ----------------------------------------------------------------------- + # Departments + # ----------------------------------------------------------------------- + + async def list_departments( + self, + *, + page: int | None = None, + page_size: int | None = None + ) -> FifteenFiveResponse: + """List all departments + + HTTP GET /department + + Args: + page: Page number for pagination + page_size: Number of items per page + + Returns: + FifteenFiveResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if page_size is not None: + query_params['page_size'] = str(page_size) + + url = self.base_url + "/department" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FifteenFiveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_departments" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FifteenFiveResponse(success=False, error=str(e), message="Failed to execute list_departments") + + async def get_department( + self, + department_id: str + ) -> FifteenFiveResponse: + """Get a specific department by ID + + HTTP GET /department/{department_id} + + Args: + department_id: The department ID + + Returns: + FifteenFiveResponse with operation result + """ + url = self.base_url + "/department/{department_id}".format(department_id=department_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FifteenFiveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_department" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FifteenFiveResponse(success=False, error=str(e), message="Failed to execute get_department") + + # ----------------------------------------------------------------------- + # High-Fives + # ----------------------------------------------------------------------- + + async def list_high_fives( + self, + *, + page: int | None = None, + page_size: int | None = None + ) -> FifteenFiveResponse: + """List all high-fives + + HTTP GET /high-five + + Args: + page: Page number for pagination + page_size: Number of items per page + + Returns: + FifteenFiveResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if page_size is not None: + query_params['page_size'] = str(page_size) + + url = self.base_url + "/high-five" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FifteenFiveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_high_fives" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FifteenFiveResponse(success=False, error=str(e), message="Failed to execute list_high_fives") + + async def get_high_five( + self, + high_five_id: str + ) -> FifteenFiveResponse: + """Get a specific high-five by ID + + HTTP GET /high-five/{high_five_id} + + Args: + high_five_id: The high-five ID + + Returns: + FifteenFiveResponse with operation result + """ + url = self.base_url + "/high-five/{high_five_id}".format(high_five_id=high_five_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FifteenFiveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_high_five" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FifteenFiveResponse(success=False, error=str(e), message="Failed to execute get_high_five") + + # ----------------------------------------------------------------------- + # One-on-Ones + # ----------------------------------------------------------------------- + + async def list_one_on_ones( + self, + *, + page: int | None = None, + page_size: int | None = None + ) -> FifteenFiveResponse: + """List all one-on-ones + + HTTP GET /one-on-one + + Args: + page: Page number for pagination + page_size: Number of items per page + + Returns: + FifteenFiveResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if page_size is not None: + query_params['page_size'] = str(page_size) + + url = self.base_url + "/one-on-one" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FifteenFiveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_one_on_ones" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FifteenFiveResponse(success=False, error=str(e), message="Failed to execute list_one_on_ones") + + async def get_one_on_one( + self, + one_on_one_id: str + ) -> FifteenFiveResponse: + """Get a specific one-on-one by ID + + HTTP GET /one-on-one/{one_on_one_id} + + Args: + one_on_one_id: The one-on-one ID + + Returns: + FifteenFiveResponse with operation result + """ + url = self.base_url + "/one-on-one/{one_on_one_id}".format(one_on_one_id=one_on_one_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FifteenFiveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_one_on_one" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FifteenFiveResponse(success=False, error=str(e), message="Failed to execute get_one_on_one") diff --git a/backend/python/app/sources/external/fifteenfive/run_generator.py b/backend/python/app/sources/external/fifteenfive/run_generator.py new file mode 100644 index 000000000..aabd6412c --- /dev/null +++ b/backend/python/app/sources/external/fifteenfive/run_generator.py @@ -0,0 +1,24 @@ +"""Runner script to generate the 15Five DataSource wrapper. + +Execute this script to regenerate fifteenfive.py from the endpoint definitions +in code_generator.py. + +Usage: + python -m app.sources.external.fifteenfive.run_generator +""" + +from app.sources.external.fifteenfive.code_generator import generate_datasource + + +def main() -> None: + """Generate the 15Five DataSource file.""" + code = generate_datasource() + output_path = __file__.replace("run_generator.py", "fifteenfive.py") + with open(output_path, "w") as f: + f.write(code) + print(f"Generated 15Five DataSource -> {output_path}") + print(f" Total characters: {len(code)}") + + +if __name__ == "__main__": + main() diff --git a/backend/python/app/sources/external/figma/example.py b/backend/python/app/sources/external/figma/example.py new file mode 100644 index 000000000..9b94d8314 --- /dev/null +++ b/backend/python/app/sources/external/figma/example.py @@ -0,0 +1,245 @@ +# ruff: noqa + +""" +Figma API Usage Examples + +This example demonstrates how to use the Figma DataSource to interact with +the Figma API (v1), covering: +- Authentication (OAuth2, Personal Access Token) +- Initializing the Client and DataSource +- Fetching User Details +- Listing Team Projects and Project Files +- Getting File Details and Comments + +Prerequisites: +For OAuth2: +1. Create a Figma OAuth app at https://www.figma.com/developers/apps +2. Set FIGMA_CLIENT_ID and FIGMA_CLIENT_SECRET environment variables +3. The OAuth flow will automatically open a browser for authorization + +For Personal Access Token: +1. Log in to Figma +2. Go to Settings > Account > Personal access tokens +3. Generate a token and set FIGMA_PERSONAL_TOKEN environment variable + +Scopes (OAuth2): +files:read, file_variables:read, file_variables:write, +file_comments:write, file_dev_resources:read, file_dev_resources:write, +webhooks:write +""" + +import asyncio +import json +import os + +from app.sources.client.figma.figma import ( + FigmaClient, + FigmaOAuthConfig, + FigmaResponse, + FigmaTokenConfig, +) +from app.sources.external.figma.figma import FigmaDataSource +from app.sources.external.utils.oauth import perform_oauth_flow + +# --- Configuration --- +# OAuth2 credentials (highest priority) +CLIENT_ID = os.getenv("FIGMA_CLIENT_ID") +CLIENT_SECRET = os.getenv("FIGMA_CLIENT_SECRET") + +# Personal Access Token (second priority) +PERSONAL_TOKEN = os.getenv("FIGMA_PERSONAL_TOKEN") + +# OAuth redirect URI +REDIRECT_URI = os.getenv("FIGMA_REDIRECT_URI", "http://localhost:8080/callback") + +# Figma Team ID (for team-level operations) +TEAM_ID = os.getenv("FIGMA_TEAM_ID") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: FigmaResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + # Handle list-type responses + for key in ("projects", "files", "comments", "versions", + "meta", "webhooks", "components", "styles"): + if isinstance(data, dict) and key in data: + items = data[key] + if isinstance(items, list): + print(f" Found {len(items)} {key}.") + if items: + print(f" Sample: {json.dumps(items[0], indent=2)[:400]}...") + elif isinstance(items, dict): + print(f" {key}: {json.dumps(items, indent=2)[:400]}...") + return + # Generic response + print(f" Data: {json.dumps(data, indent=2)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + # 1. Initialize Client + print_section("Initializing Figma Client") + + config = None + + # Priority 1: OAuth2 + if CLIENT_ID and CLIENT_SECRET: + print(" Using OAuth2 authentication") + try: + print("Starting OAuth flow...") + # Figma OAuth authorization URL: https://www.figma.com/oauth + # Figma token endpoint: https://www.figma.com/api/oauth/token + token_response = perform_oauth_flow( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + auth_endpoint="https://www.figma.com/oauth", + token_endpoint="https://www.figma.com/api/oauth/token", + redirect_uri=REDIRECT_URI, + scopes=[ + "files:read", + "file_variables:read", + "file_variables:write", + "file_comments:write", + "file_dev_resources:read", + "file_dev_resources:write", + "webhooks:write", + ], + scope_delimiter=",", + auth_method="body", # Figma sends client_id/client_secret in POST body + ) + + access_token = token_response.get("access_token") + if not access_token: + raise Exception("No access_token found in OAuth response") + + config = FigmaOAuthConfig( + access_token=access_token, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + ) + print(" OAuth authentication successful") + except Exception as e: + print(f" OAuth flow failed: {e}") + print(" Falling back to other authentication methods...") + + # Priority 2: Personal Access Token + if config is None and PERSONAL_TOKEN: + print(" Using Personal Access Token authentication") + config = FigmaTokenConfig(token=PERSONAL_TOKEN) + + if config is None: + print(" No valid authentication method found.") + print(" Please set one of the following:") + print(" - FIGMA_CLIENT_ID and FIGMA_CLIENT_SECRET (for OAuth2)") + print(" - FIGMA_PERSONAL_TOKEN (for Personal Access Token)") + return + + client = FigmaClient.build_with_config(config) + data_source = FigmaDataSource(client) + print("Client initialized successfully.") + + try: + # 2. Get Current User + print_section("Current User") + user_resp = await data_source.get_current_user() + print_result("Get Current User", user_resp) + + # 3. List Team Projects + team_id = TEAM_ID + if not team_id: + print("\n FIGMA_TEAM_ID not set. Skipping team-level operations.") + print(" Set FIGMA_TEAM_ID to test team projects, components, styles, etc.") + else: + print_section("Team Projects") + projects_resp = await data_source.list_team_projects(team_id=team_id) + print_result("List Team Projects", projects_resp) + + # Extract first project for further exploration + project_id = None + if projects_resp.success and projects_resp.data: + projects = projects_resp.data.get("projects", []) + if projects: + project_id = str(projects[0].get("id")) + print(f" Using Project: {projects[0].get('name')} (ID: {project_id})") + + if project_id: + # 4. List Project Files + print_section("Project Files") + files_resp = await data_source.list_project_files(project_id=project_id) + print_result("List Project Files", files_resp) + + # Extract first file for file-level operations + file_key = None + if files_resp.success and files_resp.data: + files = files_resp.data.get("files", []) + if files: + file_key = str(files[0].get("key")) + print(f" Using File: {files[0].get('name')} (Key: {file_key})") + + if file_key: + # 5. Get File + print_section("File Details") + file_resp = await data_source.get_file(file_key=file_key, depth=1) + print_result("Get File", file_resp) + + # 6. List Comments + print_section("File Comments") + comments_resp = await data_source.list_comments(file_key=file_key) + print_result("List Comments", comments_resp) + + # 7. List File Versions + print_section("File Versions") + versions_resp = await data_source.list_file_versions(file_key=file_key) + print_result("List File Versions", versions_resp) + + # 8. List File Components + print_section("File Components") + components_resp = await data_source.list_file_components(file_key=file_key) + print_result("List File Components", components_resp) + + # 9. List File Styles + print_section("File Styles") + styles_resp = await data_source.list_file_styles(file_key=file_key) + print_result("List File Styles", styles_resp) + + # 10. List Team Components + print_section("Team Components") + team_components_resp = await data_source.list_team_components(team_id=team_id, page_size=5) + print_result("List Team Components", team_components_resp) + + # 11. List Team Styles + print_section("Team Styles") + team_styles_resp = await data_source.list_team_styles(team_id=team_id, page_size=5) + print_result("List Team Styles", team_styles_resp) + + # 12. List Team Webhooks + print_section("Team Webhooks") + webhooks_resp = await data_source.list_team_webhooks(team_id=team_id) + print_result("List Team Webhooks", webhooks_resp) + + finally: + # Cleanup: Close the HTTP client session + print("\nClosing client connection...") + inner_client = client.get_client() + if hasattr(inner_client, "close"): + await inner_client.close() + + print("\n" + "=" * 80) + print(" All Figma API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/figma/figma.py b/backend/python/app/sources/external/figma/figma.py new file mode 100644 index 000000000..291cd10e5 --- /dev/null +++ b/backend/python/app/sources/external/figma/figma.py @@ -0,0 +1,914 @@ +""" +Figma REST API DataSource - Auto-generated API wrapper + +Generated from Figma REST API v1 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.figma.figma import FigmaClient, FigmaResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class FigmaDataSource: + """Figma REST API DataSource + + Provides async wrapper methods for Figma REST API operations: + - User / Authentication + - Files and File Nodes + - Images + - Comments + - File Versions + - Team Projects and Project Files + - Components and Component Sets + - Styles + - Variables (Local and Published) + - Webhooks + - Activity Logs + + The base URL is https://api.figma.com/v1. + + All methods return FigmaResponse objects. + """ + + def __init__(self, client: FigmaClient) -> None: + """Initialize with FigmaClient. + + Args: + client: FigmaClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'FigmaDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> FigmaClient: + """Return the underlying FigmaClient.""" + return self._client + + async def get_current_user( + self + ) -> FigmaResponse: + """Get the current authenticated user + + Returns: + FigmaResponse with operation result + """ + url = self.base_url + "/me" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FigmaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_current_user" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FigmaResponse(success=False, error=str(e), message="Failed to execute get_current_user") + + async def get_file( + self, + file_key: str, + *, + version: str | None = None, + ids: str | None = None, + depth: int | None = None, + geometry: str | None = None, + plugin_data: str | None = None, + branch_data: bool | None = None + ) -> FigmaResponse: + """Get a Figma file by key + + Args: + file_key: The file key (from the Figma file URL) + version: A specific version ID to get + ids: Comma-separated list of node IDs to retrieve + depth: Positive integer representing how deep into the document tree to traverse + geometry: Set to 'paths' to export vector data + plugin_data: Comma-separated list of plugin IDs or 'shared' for shared plugin data + branch_data: Returns branch metadata for the requested file + + Returns: + FigmaResponse with operation result + """ + query_params: dict[str, Any] = {} + if version is not None: + query_params['version'] = version + if ids is not None: + query_params['ids'] = ids + if depth is not None: + query_params['depth'] = str(depth) + if geometry is not None: + query_params['geometry'] = geometry + if plugin_data is not None: + query_params['plugin_data'] = plugin_data + if branch_data is not None: + query_params['branch_data'] = str(branch_data).lower() + + url = self.base_url + "/files/{file_key}".format(file_key=file_key) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FigmaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_file" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FigmaResponse(success=False, error=str(e), message="Failed to execute get_file") + + async def get_file_nodes( + self, + file_key: str, + ids: str, + version: str | None = None, + depth: int | None = None, + geometry: str | None = None, + plugin_data: str | None = None + ) -> FigmaResponse: + """Get specific nodes from a Figma file + + Args: + file_key: The file key + ids: Comma-separated list of node IDs to retrieve + version: A specific version ID to get + depth: Positive integer for document tree depth + geometry: Set to 'paths' to export vector data + plugin_data: Comma-separated list of plugin IDs or 'shared' + + Returns: + FigmaResponse with operation result + """ + query_params: dict[str, Any] = {} + query_params['ids'] = ids + if version is not None: + query_params['version'] = version + if depth is not None: + query_params['depth'] = str(depth) + if geometry is not None: + query_params['geometry'] = geometry + if plugin_data is not None: + query_params['plugin_data'] = plugin_data + + url = self.base_url + "/files/{file_key}/nodes".format(file_key=file_key) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FigmaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_file_nodes" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FigmaResponse(success=False, error=str(e), message="Failed to execute get_file_nodes") + + async def get_file_images( + self, + file_key: str, + ids: str, + *, + scale: float | None = None, + image_format: str | None = None, + svg_include_id: bool | None = None, + svg_simplify_stroke: bool | None = None, + use_absolute_bounds: bool | None = None, + version: str | None = None + ) -> FigmaResponse: + """Render images from a Figma file + + Args: + file_key: The file key + ids: Comma-separated list of node IDs to render + scale: Image scale factor (0.01 to 4) + image_format: Image format: jpg, png, svg, or pdf + svg_include_id: Include id attribute for all SVG elements + svg_simplify_stroke: Simplify inside/outside strokes and use stroke attribute + use_absolute_bounds: Use full dimensions of the node regardless of cropping + version: A specific version ID to get + + Returns: + FigmaResponse with operation result + """ + query_params: dict[str, Any] = {} + query_params['ids'] = ids + if scale is not None: + query_params['scale'] = str(scale) + if image_format is not None: + query_params['format'] = image_format + if svg_include_id is not None: + query_params['svg_include_id'] = str(svg_include_id).lower() + if svg_simplify_stroke is not None: + query_params['svg_simplify_stroke'] = str(svg_simplify_stroke).lower() + if use_absolute_bounds is not None: + query_params['use_absolute_bounds'] = str(use_absolute_bounds).lower() + if version is not None: + query_params['version'] = version + + url = self.base_url + "/images/{file_key}".format(file_key=file_key) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FigmaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_file_images" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FigmaResponse(success=False, error=str(e), message="Failed to execute get_file_images") + + async def list_comments( + self, + file_key: str + ) -> FigmaResponse: + """List comments on a file + + Args: + file_key: The file key + + Returns: + FigmaResponse with operation result + """ + url = self.base_url + "/files/{file_key}/comments".format(file_key=file_key) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FigmaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_comments" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FigmaResponse(success=False, error=str(e), message="Failed to execute list_comments") + + async def post_comment( + self, + file_key: str, + message: str, + comment_id: str | None = None, + client_meta: dict[str, Any] | None = None + ) -> FigmaResponse: + """Post a comment on a file + + Args: + file_key: The file key + message: The comment text + comment_id: The ID of the comment to reply to + client_meta: Position of the comment (x, y, node_id, node_offset) + + Returns: + FigmaResponse with operation result + """ + url = self.base_url + "/files/{file_key}/comments".format(file_key=file_key) + + body: dict[str, Any] = {} + body['message'] = message + if comment_id is not None: + body['comment_id'] = comment_id + if client_meta is not None: + body['client_meta'] = client_meta + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FigmaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed post_comment" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FigmaResponse(success=False, error=str(e), message="Failed to execute post_comment") + + async def list_file_versions( + self, + file_key: str + ) -> FigmaResponse: + """List version history of a file + + Args: + file_key: The file key + + Returns: + FigmaResponse with operation result + """ + url = self.base_url + "/files/{file_key}/versions".format(file_key=file_key) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FigmaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_file_versions" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FigmaResponse(success=False, error=str(e), message="Failed to execute list_file_versions") + + async def list_team_projects( + self, + team_id: str + ) -> FigmaResponse: + """List projects in a team + + Args: + team_id: The team ID + + Returns: + FigmaResponse with operation result + """ + url = self.base_url + "/teams/{team_id}/projects".format(team_id=team_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FigmaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_team_projects" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FigmaResponse(success=False, error=str(e), message="Failed to execute list_team_projects") + + async def list_project_files( + self, + project_id: str, + *, + branch_data: bool | None = None + ) -> FigmaResponse: + """List files in a project + + Args: + project_id: The project ID + branch_data: Returns branch metadata for the requested files + + Returns: + FigmaResponse with operation result + """ + query_params: dict[str, Any] = {} + if branch_data is not None: + query_params['branch_data'] = str(branch_data).lower() + + url = self.base_url + "/projects/{project_id}/files".format(project_id=project_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FigmaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_project_files" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FigmaResponse(success=False, error=str(e), message="Failed to execute list_project_files") + + async def list_team_components( + self, + team_id: str, + page_size: int | None = None, + after: str | None = None, + before: str | None = None + ) -> FigmaResponse: + """List components published in a team library + + Args: + team_id: The team ID + page_size: Number of items per page (max 30) + after: Cursor for pagination (next page) + before: Cursor for pagination (previous page) + + Returns: + FigmaResponse with operation result + """ + query_params: dict[str, Any] = {} + if page_size is not None: + query_params['page_size'] = str(page_size) + if after is not None: + query_params['after'] = after + if before is not None: + query_params['before'] = before + + url = self.base_url + "/teams/{team_id}/components".format(team_id=team_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FigmaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_team_components" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FigmaResponse(success=False, error=str(e), message="Failed to execute list_team_components") + + async def list_file_components( + self, + file_key: str + ) -> FigmaResponse: + """List components in a file + + Args: + file_key: The file key + + Returns: + FigmaResponse with operation result + """ + url = self.base_url + "/files/{file_key}/components".format(file_key=file_key) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FigmaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_file_components" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FigmaResponse(success=False, error=str(e), message="Failed to execute list_file_components") + + async def list_team_component_sets( + self, + team_id: str, + page_size: int | None = None, + after: str | None = None, + before: str | None = None + ) -> FigmaResponse: + """List component sets published in a team library + + Args: + team_id: The team ID + page_size: Number of items per page (max 30) + after: Cursor for pagination (next page) + before: Cursor for pagination (previous page) + + Returns: + FigmaResponse with operation result + """ + query_params: dict[str, Any] = {} + if page_size is not None: + query_params['page_size'] = str(page_size) + if after is not None: + query_params['after'] = after + if before is not None: + query_params['before'] = before + + url = self.base_url + "/teams/{team_id}/component_sets".format(team_id=team_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FigmaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_team_component_sets" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FigmaResponse(success=False, error=str(e), message="Failed to execute list_team_component_sets") + + async def list_team_styles( + self, + team_id: str, + page_size: int | None = None, + after: str | None = None, + before: str | None = None + ) -> FigmaResponse: + """List styles published in a team library + + Args: + team_id: The team ID + page_size: Number of items per page (max 30) + after: Cursor for pagination (next page) + before: Cursor for pagination (previous page) + + Returns: + FigmaResponse with operation result + """ + query_params: dict[str, Any] = {} + if page_size is not None: + query_params['page_size'] = str(page_size) + if after is not None: + query_params['after'] = after + if before is not None: + query_params['before'] = before + + url = self.base_url + "/teams/{team_id}/styles".format(team_id=team_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FigmaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_team_styles" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FigmaResponse(success=False, error=str(e), message="Failed to execute list_team_styles") + + async def list_file_styles( + self, + file_key: str + ) -> FigmaResponse: + """List styles in a file + + Args: + file_key: The file key + + Returns: + FigmaResponse with operation result + """ + url = self.base_url + "/files/{file_key}/styles".format(file_key=file_key) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FigmaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_file_styles" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FigmaResponse(success=False, error=str(e), message="Failed to execute list_file_styles") + + async def get_local_variables( + self, + file_key: str + ) -> FigmaResponse: + """Get local variables in a file + + Args: + file_key: The file key + + Returns: + FigmaResponse with operation result + """ + url = self.base_url + "/files/{file_key}/variables/local".format(file_key=file_key) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FigmaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_local_variables" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FigmaResponse(success=False, error=str(e), message="Failed to execute get_local_variables") + + async def get_published_variables( + self, + file_key: str + ) -> FigmaResponse: + """Get published variables in a file + + Args: + file_key: The file key + + Returns: + FigmaResponse with operation result + """ + url = self.base_url + "/files/{file_key}/variables/published".format(file_key=file_key) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FigmaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_published_variables" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FigmaResponse(success=False, error=str(e), message="Failed to execute get_published_variables") + + async def get_webhook( + self, + webhook_id: str + ) -> FigmaResponse: + """Get a webhook by ID + + Args: + webhook_id: The webhook ID + + Returns: + FigmaResponse with operation result + """ + url = self.base_url + "/webhooks/{webhook_id}".format(webhook_id=webhook_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FigmaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_webhook" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FigmaResponse(success=False, error=str(e), message="Failed to execute get_webhook") + + async def create_webhook( + self, + event_type: str, + team_id: str, + endpoint: str, + passcode: str | None = None, + description: str | None = None + ) -> FigmaResponse: + """Create a new webhook + + Args: + event_type: The event type to subscribe to + team_id: The team ID to receive events from + endpoint: The endpoint URL to receive webhook events + passcode: A passcode for webhook verification + description: A description for the webhook + + Returns: + FigmaResponse with operation result + """ + url = self.base_url + "/webhooks" + + body: dict[str, Any] = {} + body['event_type'] = event_type + body['team_id'] = team_id + body['endpoint'] = endpoint + if passcode is not None: + body['passcode'] = passcode + if description is not None: + body['description'] = description + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FigmaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed create_webhook" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FigmaResponse(success=False, error=str(e), message="Failed to execute create_webhook") + + async def update_webhook( + self, + webhook_id: str, + event_type: str | None = None, + endpoint: str | None = None, + passcode: str | None = None, + description: str | None = None + ) -> FigmaResponse: + """Update an existing webhook + + Args: + webhook_id: The webhook ID + event_type: The event type to subscribe to + endpoint: The endpoint URL to receive webhook events + passcode: A passcode for webhook verification + description: A description for the webhook + + Returns: + FigmaResponse with operation result + """ + url = self.base_url + "/webhooks/{webhook_id}".format(webhook_id=webhook_id) + + body: dict[str, Any] = {} + if event_type is not None: + body['event_type'] = event_type + if endpoint is not None: + body['endpoint'] = endpoint + if passcode is not None: + body['passcode'] = passcode + if description is not None: + body['description'] = description + + try: + request = HTTPRequest( + method="PUT", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FigmaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed update_webhook" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FigmaResponse(success=False, error=str(e), message="Failed to execute update_webhook") + + async def delete_webhook( + self, + webhook_id: str + ) -> FigmaResponse: + """Delete a webhook + + Args: + webhook_id: The webhook ID + + Returns: + FigmaResponse with operation result + """ + url = self.base_url + "/webhooks/{webhook_id}".format(webhook_id=webhook_id) + + try: + request = HTTPRequest( + method="DELETE", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FigmaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed delete_webhook" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FigmaResponse(success=False, error=str(e), message="Failed to execute delete_webhook") + + async def list_team_webhooks( + self, + team_id: str + ) -> FigmaResponse: + """List webhooks for a team + + Args: + team_id: The team ID + + Returns: + FigmaResponse with operation result + """ + url = self.base_url + "/teams/{team_id}/webhooks".format(team_id=team_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FigmaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_team_webhooks" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FigmaResponse(success=False, error=str(e), message="Failed to execute list_team_webhooks") + + async def list_activity_logs( + self, + events: str | None = None, + limit: int | None = None, + order: str | None = None + ) -> FigmaResponse: + """List activity log events + + Args: + events: Comma-separated list of event types to filter + limit: Maximum number of events to return + order: Sort order: 'asc' or 'desc' + + Returns: + FigmaResponse with operation result + """ + query_params: dict[str, Any] = {} + if events is not None: + query_params['events'] = events + if limit is not None: + query_params['limit'] = str(limit) + if order is not None: + query_params['order'] = order + + url = self.base_url + "/activity_logs" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FigmaResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_activity_logs" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FigmaResponse(success=False, error=str(e), message="Failed to execute list_activity_logs") diff --git a/backend/python/app/sources/external/freshservice/example.py b/backend/python/app/sources/external/freshservice/example.py new file mode 100644 index 000000000..35f39a88b --- /dev/null +++ b/backend/python/app/sources/external/freshservice/example.py @@ -0,0 +1,165 @@ +# ruff: noqa + +""" +Freshservice API Usage Examples + +This example demonstrates how to use the Freshservice DataSource to interact with +the Freshservice API v2, covering: +- Authentication (API Key via Basic Auth) +- Initializing the Client and DataSource +- Listing tickets, agents, requesters +- Ticket CRUD operations +- Listing assets, problems, changes, departments + +Prerequisites: +1. Set FRESHSERVICE_DOMAIN environment variable (e.g., 'company.freshservice.com') +2. Set FRESHSERVICE_API_KEY environment variable with your API key + +You can obtain an API key from: +Freshservice Admin > Profile Settings > API Key +""" + +import asyncio +import json +import os + +from app.sources.client.freshservice.freshservice import ( + FreshserviceApiKeyConfig, + FreshserviceClient, + FreshserviceResponse, +) +from app.sources.external.freshservice.freshservice import FreshserviceDataSource + +# --- Configuration --- +DOMAIN = os.getenv("FRESHSERVICE_DOMAIN") +API_KEY = os.getenv("FRESHSERVICE_API_KEY") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: FreshserviceResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + if isinstance(data, dict): + for key in ("tickets", "requesters", "agents", "assets", "problems", + "changes", "releases", "departments", "groups", + "service_items", "conversations"): + if key in data: + items = data[key] + print(f" Found {len(items)} {key}.") + if items: + print(f" Sample: {json.dumps(items[0], indent=2)[:400]}...") + return + # Single item response + print(f" Data: {json.dumps(data, indent=2)[:500]}...") + else: + print(f" Data: {json.dumps(data, indent=2)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + if not DOMAIN: + print("FRESHSERVICE_DOMAIN is not set") + return + if not API_KEY: + print("FRESHSERVICE_API_KEY is not set") + return + + # 1. Initialize Client + print_section("Initializing Freshservice Client") + client = FreshserviceClient.build_with_api_key_config( + FreshserviceApiKeyConfig(domain=DOMAIN, api_key=API_KEY) + ) + print(f" Connected to: {client.get_domain()}") + print(f" Base URL: {client.get_base_url()}") + + data_source = FreshserviceDataSource(client) + + try: + # 2. List Tickets + print_section("Tickets") + tickets_resp = await data_source.list_tickets(per_page=5) + print_result("List Tickets", tickets_resp) + + # 3. Get first ticket details + if tickets_resp.success and tickets_resp.data: + tickets = tickets_resp.data.get("tickets", []) + if tickets: + ticket_id = tickets[0].get("id") + print_section(f"Ticket Details (ID: {ticket_id})") + ticket_resp = await data_source.get_ticket(id=ticket_id) + print_result("Get Ticket", ticket_resp) + + # 4. List ticket conversations + print_section(f"Ticket Conversations (ID: {ticket_id})") + convs_resp = await data_source.list_ticket_conversations(id=ticket_id) + print_result("List Conversations", convs_resp) + + # 5. List Requesters + print_section("Requesters") + requesters_resp = await data_source.list_requesters(per_page=5) + print_result("List Requesters", requesters_resp) + + # 6. List Agents + print_section("Agents") + agents_resp = await data_source.list_agents(per_page=5) + print_result("List Agents", agents_resp) + + # 7. List Assets + print_section("Assets") + assets_resp = await data_source.list_assets(per_page=5) + print_result("List Assets", assets_resp) + + # 8. List Problems + print_section("Problems") + problems_resp = await data_source.list_problems(per_page=5) + print_result("List Problems", problems_resp) + + # 9. List Changes + print_section("Changes") + changes_resp = await data_source.list_changes(per_page=5) + print_result("List Changes", changes_resp) + + # 10. List Releases + print_section("Releases") + releases_resp = await data_source.list_releases(per_page=5) + print_result("List Releases", releases_resp) + + # 11. List Departments + print_section("Departments") + departments_resp = await data_source.list_departments(per_page=5) + print_result("List Departments", departments_resp) + + # 12. List Groups + print_section("Groups") + groups_resp = await data_source.list_groups(per_page=5) + print_result("List Groups", groups_resp) + + # 13. List Service Catalog Items + print_section("Service Catalog Items") + catalog_resp = await data_source.list_service_catalog_items(per_page=5) + print_result("List Service Catalog Items", catalog_resp) + + finally: + print("\nClosing client connection...") + inner_client = client.get_client() + if hasattr(inner_client, "close"): + await inner_client.close() + + print("\n" + "=" * 80) + print(" All Freshservice API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/freshservice/freshservice.py b/backend/python/app/sources/external/freshservice/freshservice.py new file mode 100644 index 000000000..6285bb5b8 --- /dev/null +++ b/backend/python/app/sources/external/freshservice/freshservice.py @@ -0,0 +1,890 @@ +# ruff: noqa: A002 +""" +Freshservice REST API DataSource - Auto-generated API wrapper + +Generated from Freshservice REST API v2 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.freshservice.freshservice import ( + FreshserviceClient, + FreshserviceResponse, +) +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class FreshserviceDataSource: + """Freshservice REST API DataSource + + Provides async wrapper methods for Freshservice REST API operations: + - Ticket CRUD and management + - Ticket conversations + - Requesters and agents + - Assets + - Problems, changes, releases + - Departments, groups + - Service catalog items + + All methods return FreshserviceResponse objects. + """ + + def __init__(self, client: FreshserviceClient) -> None: + """Initialize with FreshserviceClient. + + Args: + client: FreshserviceClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'FreshserviceDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> FreshserviceClient: + """Return the underlying FreshserviceClient.""" + return self._client + + async def list_tickets( + self, + page: int | None = None, + per_page: int | None = None, + filter_: str | None = None, + order_by: str | None = None, + order_type: str | None = None, + updated_since: str | None = None, + requester_id: int | None = None + ) -> FreshserviceResponse: + """List all tickets with optional filters + + Args: + page: Page number for pagination + per_page: Number of tickets per page (max 100) + filter_: Predefined filter name + order_by: Field to order by (e.g., created_at, updated_at) + order_type: Order direction: asc or desc + updated_since: Filter tickets updated since this timestamp (ISO format) + requester_id: Filter by requester ID + + Returns: + FreshserviceResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + if filter_ is not None: + query_params['filter'] = filter_ + if order_by is not None: + query_params['order_by'] = order_by + if order_type is not None: + query_params['order_type'] = order_type + if updated_since is not None: + query_params['updated_since'] = updated_since + if requester_id is not None: + query_params['requester_id'] = str(requester_id) + + url = self.base_url + "/tickets" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FreshserviceResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_tickets" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FreshserviceResponse(success=False, error=str(e), message="Failed to execute list_tickets") + + async def get_ticket( + self, + id: int + ) -> FreshserviceResponse: + """Get a specific ticket by ID + + Args: + id: Ticket ID + + Returns: + FreshserviceResponse with operation result + """ + url = self.base_url + "/tickets/{id}".format(id=id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FreshserviceResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_ticket" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FreshserviceResponse(success=False, error=str(e), message="Failed to execute get_ticket") + + async def create_ticket( + self, + subject: str, + description: str | None = None, + email: str | None = None, + requester_id: int | None = None, + phone: str | None = None, + priority: int | None = None, + status: int | None = None, + source: int | None = None, + type_: str | None = None, + tags: list[str] | None = None, + cc_emails: list[str] | None = None, + custom_fields: dict[str, Any] | None = None, + department_id: int | None = None, + group_id: int | None = None, + category: str | None = None, + sub_category: str | None = None, + item_category: str | None = None, + responder_id: int | None = None, + due_by: str | None = None, + fr_due_by: str | None = None, + urgency: int | None = None, + impact: int | None = None + ) -> FreshserviceResponse: + """Create a new ticket + + Args: + subject: Subject of the ticket + description: HTML content of the ticket + email: Email of the requester + requester_id: User ID of the requester + phone: Phone number of the requester + priority: Priority: 1=Low, 2=Medium, 3=High, 4=Urgent + status: Status: 2=Open, 3=Pending, 4=Resolved, 5=Closed + source: Source of the ticket + type_: Type of the ticket + tags: Tags for the ticket + cc_emails: CC email addresses + custom_fields: Custom field values + department_id: Department ID + group_id: Group ID + category: Category of the ticket + sub_category: Sub-category of the ticket + item_category: Item category + responder_id: Agent ID to assign + due_by: Due date (ISO format) + fr_due_by: First response due date (ISO format) + urgency: Urgency of the ticket + impact: Impact of the ticket + + Returns: + FreshserviceResponse with operation result + """ + url = self.base_url + "/tickets" + + request_body: dict[str, Any] = {} + request_body['subject'] = subject + if description is not None: + request_body['description'] = description + if email is not None: + request_body['email'] = email + if requester_id is not None: + request_body['requester_id'] = requester_id + if phone is not None: + request_body['phone'] = phone + if priority is not None: + request_body['priority'] = priority + if status is not None: + request_body['status'] = status + if source is not None: + request_body['source'] = source + if type_ is not None: + request_body['type'] = type_ + if tags is not None: + request_body['tags'] = tags + if cc_emails is not None: + request_body['cc_emails'] = cc_emails + if custom_fields is not None: + request_body['custom_fields'] = custom_fields + if department_id is not None: + request_body['department_id'] = department_id + if group_id is not None: + request_body['group_id'] = group_id + if category is not None: + request_body['category'] = category + if sub_category is not None: + request_body['sub_category'] = sub_category + if item_category is not None: + request_body['item_category'] = item_category + if responder_id is not None: + request_body['responder_id'] = responder_id + if due_by is not None: + request_body['due_by'] = due_by + if fr_due_by is not None: + request_body['fr_due_by'] = fr_due_by + if urgency is not None: + request_body['urgency'] = urgency + if impact is not None: + request_body['impact'] = impact + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=request_body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FreshserviceResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed create_ticket" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FreshserviceResponse(success=False, error=str(e), message="Failed to execute create_ticket") + + async def update_ticket( + self, + id: int, + subject: str | None = None, + description: str | None = None, + priority: int | None = None, + status: int | None = None, + type_: str | None = None, + tags: list[str] | None = None, + custom_fields: dict[str, Any] | None = None, + department_id: int | None = None, + group_id: int | None = None, + category: str | None = None, + sub_category: str | None = None, + item_category: str | None = None, + responder_id: int | None = None, + urgency: int | None = None, + impact: int | None = None + ) -> FreshserviceResponse: + """Update an existing ticket + + Args: + id: Ticket ID + subject: Subject of the ticket + description: HTML content of the ticket + priority: Priority: 1=Low, 2=Medium, 3=High, 4=Urgent + status: Status: 2=Open, 3=Pending, 4=Resolved, 5=Closed + type_: Type of the ticket + tags: Tags for the ticket + custom_fields: Custom field values + department_id: Department ID + group_id: Group ID + category: Category + sub_category: Sub-category + item_category: Item category + responder_id: Agent ID to assign + urgency: Urgency + impact: Impact + + Returns: + FreshserviceResponse with operation result + """ + url = self.base_url + "/tickets/{id}".format(id=id) + + request_body: dict[str, Any] = {} + if subject is not None: + request_body['subject'] = subject + if description is not None: + request_body['description'] = description + if priority is not None: + request_body['priority'] = priority + if status is not None: + request_body['status'] = status + if type_ is not None: + request_body['type'] = type_ + if tags is not None: + request_body['tags'] = tags + if custom_fields is not None: + request_body['custom_fields'] = custom_fields + if department_id is not None: + request_body['department_id'] = department_id + if group_id is not None: + request_body['group_id'] = group_id + if category is not None: + request_body['category'] = category + if sub_category is not None: + request_body['sub_category'] = sub_category + if item_category is not None: + request_body['item_category'] = item_category + if responder_id is not None: + request_body['responder_id'] = responder_id + if urgency is not None: + request_body['urgency'] = urgency + if impact is not None: + request_body['impact'] = impact + + try: + request = HTTPRequest( + method="PUT", + url=url, + headers={"Content-Type": "application/json"}, + body=request_body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FreshserviceResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed update_ticket" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FreshserviceResponse(success=False, error=str(e), message="Failed to execute update_ticket") + + async def delete_ticket( + self, + id: int + ) -> FreshserviceResponse: + """Delete a ticket + + Args: + id: Ticket ID + + Returns: + FreshserviceResponse with operation result + """ + url = self.base_url + "/tickets/{id}".format(id=id) + + try: + request = HTTPRequest( + method="DELETE", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FreshserviceResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed delete_ticket" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FreshserviceResponse(success=False, error=str(e), message="Failed to execute delete_ticket") + + async def list_ticket_conversations( + self, + id: int, + page: int | None = None, + per_page: int | None = None + ) -> FreshserviceResponse: + """List all conversations of a ticket + + Args: + id: Ticket ID + page: Page number + per_page: Items per page + + Returns: + FreshserviceResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/tickets/{id}/conversations".format(id=id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FreshserviceResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_ticket_conversations" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FreshserviceResponse(success=False, error=str(e), message="Failed to execute list_ticket_conversations") + + async def list_requesters( + self, + page: int | None = None, + per_page: int | None = None, + email: str | None = None, + query_: str | None = None + ) -> FreshserviceResponse: + """List all requesters + + Args: + page: Page number + per_page: Items per page + email: Filter by email + query_: Search query string + + Returns: + FreshserviceResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + if email is not None: + query_params['email'] = email + if query_ is not None: + query_params['query'] = query_ + + url = self.base_url + "/requesters" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FreshserviceResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_requesters" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FreshserviceResponse(success=False, error=str(e), message="Failed to execute list_requesters") + + async def get_requester( + self, + id: int + ) -> FreshserviceResponse: + """Get a specific requester by ID + + Args: + id: Requester ID + + Returns: + FreshserviceResponse with operation result + """ + url = self.base_url + "/requesters/{id}".format(id=id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FreshserviceResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_requester" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FreshserviceResponse(success=False, error=str(e), message="Failed to execute get_requester") + + async def list_agents( + self, + page: int | None = None, + per_page: int | None = None, + email: str | None = None, + state: str | None = None + ) -> FreshserviceResponse: + """List all agents + + Args: + page: Page number + per_page: Items per page + email: Filter by email + state: Filter by agent state (fulltime, occasional) + + Returns: + FreshserviceResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + if email is not None: + query_params['email'] = email + if state is not None: + query_params['state'] = state + + url = self.base_url + "/agents" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FreshserviceResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_agents" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FreshserviceResponse(success=False, error=str(e), message="Failed to execute list_agents") + + async def get_agent( + self, + id: int + ) -> FreshserviceResponse: + """Get a specific agent by ID + + Args: + id: Agent ID + + Returns: + FreshserviceResponse with operation result + """ + url = self.base_url + "/agents/{id}".format(id=id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FreshserviceResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_agent" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FreshserviceResponse(success=False, error=str(e), message="Failed to execute get_agent") + + async def list_assets( + self, + page: int | None = None, + per_page: int | None = None, + filter_: str | None = None + ) -> FreshserviceResponse: + """List all assets + + Args: + page: Page number + per_page: Items per page + filter_: Filter name + + Returns: + FreshserviceResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + if filter_ is not None: + query_params['filter'] = filter_ + + url = self.base_url + "/assets" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FreshserviceResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_assets" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FreshserviceResponse(success=False, error=str(e), message="Failed to execute list_assets") + + async def get_asset( + self, + display_id: int + ) -> FreshserviceResponse: + """Get a specific asset by display ID + + Args: + display_id: Asset display ID + + Returns: + FreshserviceResponse with operation result + """ + url = self.base_url + "/assets/{display_id}".format(display_id=display_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FreshserviceResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_asset" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FreshserviceResponse(success=False, error=str(e), message="Failed to execute get_asset") + + async def list_problems( + self, + page: int | None = None, + per_page: int | None = None + ) -> FreshserviceResponse: + """List all problems + + Args: + page: Page number + per_page: Items per page + + Returns: + FreshserviceResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/problems" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FreshserviceResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_problems" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FreshserviceResponse(success=False, error=str(e), message="Failed to execute list_problems") + + async def list_changes( + self, + page: int | None = None, + per_page: int | None = None + ) -> FreshserviceResponse: + """List all changes + + Args: + page: Page number + per_page: Items per page + + Returns: + FreshserviceResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/changes" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FreshserviceResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_changes" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FreshserviceResponse(success=False, error=str(e), message="Failed to execute list_changes") + + async def list_releases( + self, + page: int | None = None, + per_page: int | None = None + ) -> FreshserviceResponse: + """List all releases + + Args: + page: Page number + per_page: Items per page + + Returns: + FreshserviceResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/releases" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FreshserviceResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_releases" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FreshserviceResponse(success=False, error=str(e), message="Failed to execute list_releases") + + async def list_departments( + self, + page: int | None = None, + per_page: int | None = None + ) -> FreshserviceResponse: + """List all departments + + Args: + page: Page number + per_page: Items per page + + Returns: + FreshserviceResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/departments" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FreshserviceResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_departments" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FreshserviceResponse(success=False, error=str(e), message="Failed to execute list_departments") + + async def list_groups( + self, + page: int | None = None, + per_page: int | None = None + ) -> FreshserviceResponse: + """List all groups + + Args: + page: Page number + per_page: Items per page + + Returns: + FreshserviceResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/groups" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FreshserviceResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_groups" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FreshserviceResponse(success=False, error=str(e), message="Failed to execute list_groups") + + async def list_service_catalog_items( + self, + page: int | None = None, + per_page: int | None = None + ) -> FreshserviceResponse: + """List all service catalog items + + Args: + page: Page number + per_page: Items per page + + Returns: + FreshserviceResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/service_catalog/items" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return FreshserviceResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_service_catalog_items" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return FreshserviceResponse(success=False, error=str(e), message="Failed to execute list_service_catalog_items") diff --git a/backend/python/app/sources/external/google/admin/admin.py b/backend/python/app/sources/external/google/admin/admin.py index 7e6436704..32daa0f5e 100644 --- a/backend/python/app/sources/external/google/admin/admin.py +++ b/backend/python/app/sources/external/google/admin/admin.py @@ -316,77 +316,6 @@ async def customer_devices_chromeos_commands_get( request = self.client.customer_devices_chromeos_commands().get(**kwargs) # type: ignore return request.execute() - async def asps_delete( - self, - userKey: str, - codeId: int - ) -> Dict[str, Any]: - """Google Admin SDK Directory API: Deletes an ASP issued by a user. - - HTTP DELETE admin/directory/v1/users/{userKey}/asps/{codeId} - - Args: - userKey (str, required): Identifies the user in the API request. The value can be the user's primary email address, alias email address, or unique user ID. - codeId (int, required): The unique ID of the ASP to be deleted. - - Returns: - Dict[str, Any]: API response - """ - kwargs = {} - if userKey is not None: - kwargs['userKey'] = userKey - if codeId is not None: - kwargs['codeId'] = codeId - - request = self.client.asps().delete(**kwargs) # type: ignore - return request.execute() - - async def asps_get( - self, - userKey: str, - codeId: int - ) -> Dict[str, Any]: - """Google Admin SDK Directory API: Gets information about an ASP issued by a user. - - HTTP GET admin/directory/v1/users/{userKey}/asps/{codeId} - - Args: - userKey (str, required): Identifies the user in the API request. The value can be the user's primary email address, alias email address, or unique user ID. - codeId (int, required): The unique ID of the ASP. - - Returns: - Dict[str, Any]: API response - """ - kwargs = {} - if userKey is not None: - kwargs['userKey'] = userKey - if codeId is not None: - kwargs['codeId'] = codeId - - request = self.client.asps().get(**kwargs) # type: ignore - return request.execute() - - async def asps_list( - self, - userKey: str - ) -> Dict[str, Any]: - """Google Admin SDK Directory API: Lists the ASPs issued by a user. - - HTTP GET admin/directory/v1/users/{userKey}/asps - - Args: - userKey (str, required): Identifies the user in the API request. The value can be the user's primary email address, alias email address, or unique user ID. - - Returns: - Dict[str, Any]: API response - """ - kwargs = {} - if userKey is not None: - kwargs['userKey'] = userKey - - request = self.client.asps().list(**kwargs) # type: ignore - return request.execute() - async def channels_stop(self) -> Dict[str, Any]: """Google Admin SDK Directory API: Stops watching resources through this channel. @@ -406,79 +335,6 @@ async def channels_stop(self) -> Dict[str, Any]: request = self.client.channels().stop(**kwargs) # type: ignore return request.execute() - async def customers_get( - self, - customerKey: str - ) -> Dict[str, Any]: - """Google Admin SDK Directory API: Retrieves a customer. - - HTTP GET admin/directory/v1/customers/{customerKey} - - Args: - customerKey (str, required): Id of the customer to be retrieved - - Returns: - Dict[str, Any]: API response - """ - kwargs = {} - if customerKey is not None: - kwargs['customerKey'] = customerKey - - request = self.client.customers().get(**kwargs) # type: ignore - return request.execute() - - async def customers_update( - self, - customerKey: str - ) -> Dict[str, Any]: - """Google Admin SDK Directory API: Updates a customer. - - HTTP PUT admin/directory/v1/customers/{customerKey} - - Args: - customerKey (str, required): Id of the customer to be updated - - Returns: - Dict[str, Any]: API response - """ - kwargs = {} - if customerKey is not None: - kwargs['customerKey'] = customerKey - - # Handle request body if needed - if 'body' in kwargs: - body = kwargs.pop('body') - request = self.client.customers().update(**kwargs, body=body) # type: ignore - else: - request = self.client.customers().update(**kwargs) # type: ignore - return request.execute() - - async def customers_patch( - self, - customerKey: str - ) -> Dict[str, Any]: - """Google Admin SDK Directory API: Patches a customer. - - HTTP PATCH admin/directory/v1/customers/{customerKey} - - Args: - customerKey (str, required): Id of the customer to be updated - - Returns: - Dict[str, Any]: API response - """ - kwargs = {} - if customerKey is not None: - kwargs['customerKey'] = customerKey - - # Handle request body if needed - if 'body' in kwargs: - body = kwargs.pop('body') - request = self.client.customers().patch(**kwargs, body=body) # type: ignore - else: - request = self.client.customers().patch(**kwargs) # type: ignore - return request.execute() - async def customers_chrome_printers_list_printer_models( self, parent: str, @@ -898,204 +754,6 @@ async def customers_chrome_print_servers_batch_delete_print_servers( request = self.client.customers_chrome_printServers().batchDeletePrintServers(**kwargs) # type: ignore return request.execute() - async def domain_aliases_delete( - self, - customer: str, - domainAliasName: str - ) -> Dict[str, Any]: - """Google Admin SDK Directory API: Deletes a domain Alias of the customer. - - HTTP DELETE admin/directory/v1/customer/{customer}/domainaliases/{domainAliasName} - - Args: - customer (str, required): Immutable ID of the Google Workspace account. - domainAliasName (str, required): Name of domain alias to be retrieved. - - Returns: - Dict[str, Any]: API response - """ - kwargs = {} - if customer is not None: - kwargs['customer'] = customer - if domainAliasName is not None: - kwargs['domainAliasName'] = domainAliasName - - request = self.client.domainAliases().delete(**kwargs) # type: ignore - return request.execute() - - async def domain_aliases_get( - self, - customer: str, - domainAliasName: str - ) -> Dict[str, Any]: - """Google Admin SDK Directory API: Retrieves a domain alias of the customer. - - HTTP GET admin/directory/v1/customer/{customer}/domainaliases/{domainAliasName} - - Args: - customer (str, required): The unique ID for the customer's Google Workspace account. In case of a multi-domain account, to fetch all groups for a customer, use this field instead of `domain`. You can also use the `my_customer` alias to represent your account's `customerId`. The `customerId` is also returned as part of the [Users](https://developers.google.com/workspace/admin/directory/v1/reference/users) resource. You must provide either the `customer` or the `domain` parameter. - domainAliasName (str, required): Name of domain alias to be retrieved. - - Returns: - Dict[str, Any]: API response - """ - kwargs = {} - if customer is not None: - kwargs['customer'] = customer - if domainAliasName is not None: - kwargs['domainAliasName'] = domainAliasName - - request = self.client.domainAliases().get(**kwargs) # type: ignore - return request.execute() - - async def domain_aliases_insert( - self, - customer: str - ) -> Dict[str, Any]: - """Google Admin SDK Directory API: Inserts a domain alias of the customer. - - HTTP POST admin/directory/v1/customer/{customer}/domainaliases - - Args: - customer (str, required): Immutable ID of the Google Workspace account. - - Returns: - Dict[str, Any]: API response - """ - kwargs = {} - if customer is not None: - kwargs['customer'] = customer - - # Handle request body if needed - if 'body' in kwargs: - body = kwargs.pop('body') - request = self.client.domainAliases().insert(**kwargs, body=body) # type: ignore - else: - request = self.client.domainAliases().insert(**kwargs) # type: ignore - return request.execute() - - async def domain_aliases_list( - self, - customer: str, - parentDomainName: Optional[str] = None - ) -> Dict[str, Any]: - """Google Admin SDK Directory API: Lists the domain aliases of the customer. - - HTTP GET admin/directory/v1/customer/{customer}/domainaliases - - Args: - customer (str, required): The unique ID for the customer's Google Workspace account. In case of a multi-domain account, to fetch all groups for a customer, use this field instead of `domain`. You can also use the `my_customer` alias to represent your account's `customerId`. The `customerId` is also returned as part of the [Users](https://developers.google.com/workspace/admin/directory/v1/reference/users) resource. You must provide either the `customer` or the `domain` parameter. - parentDomainName (str, optional): Name of the parent domain for which domain aliases are to be fetched. - - Returns: - Dict[str, Any]: API response - """ - kwargs = {} - if customer is not None: - kwargs['customer'] = customer - if parentDomainName is not None: - kwargs['parentDomainName'] = parentDomainName - - request = self.client.domainAliases().list(**kwargs) # type: ignore - return request.execute() - - async def domains_delete( - self, - customer: str, - domainName: str - ) -> Dict[str, Any]: - """Google Admin SDK Directory API: Deletes a domain of the customer. - - HTTP DELETE admin/directory/v1/customer/{customer}/domains/{domainName} - - Args: - customer (str, required): Immutable ID of the Google Workspace account. - domainName (str, required): Name of domain to be deleted - - Returns: - Dict[str, Any]: API response - """ - kwargs = {} - if customer is not None: - kwargs['customer'] = customer - if domainName is not None: - kwargs['domainName'] = domainName - - request = self.client.domains().delete(**kwargs) # type: ignore - return request.execute() - - async def domains_get( - self, - customer: str, - domainName: str - ) -> Dict[str, Any]: - """Google Admin SDK Directory API: Retrieves a domain of the customer. - - HTTP GET admin/directory/v1/customer/{customer}/domains/{domainName} - - Args: - customer (str, required): The unique ID for the customer's Google Workspace account. In case of a multi-domain account, to fetch all groups for a customer, use this field instead of `domain`. You can also use the `my_customer` alias to represent your account's `customerId`. The `customerId` is also returned as part of the [Users](https://developers.google.com/workspace/admin/directory/v1/reference/users) resource. You must provide either the `customer` or the `domain` parameter. - domainName (str, required): Name of domain to be retrieved - - Returns: - Dict[str, Any]: API response - """ - kwargs = {} - if customer is not None: - kwargs['customer'] = customer - if domainName is not None: - kwargs['domainName'] = domainName - - request = self.client.domains().get(**kwargs) # type: ignore - return request.execute() - - async def domains_insert( - self, - customer: str - ) -> Dict[str, Any]: - """Google Admin SDK Directory API: Inserts a domain of the customer. - - HTTP POST admin/directory/v1/customer/{customer}/domains - - Args: - customer (str, required): Immutable ID of the Google Workspace account. - - Returns: - Dict[str, Any]: API response - """ - kwargs = {} - if customer is not None: - kwargs['customer'] = customer - - # Handle request body if needed - if 'body' in kwargs: - body = kwargs.pop('body') - request = self.client.domains().insert(**kwargs, body=body) # type: ignore - else: - request = self.client.domains().insert(**kwargs) # type: ignore - return request.execute() - - async def domains_list( - self, - customer: str - ) -> Dict[str, Any]: - """Google Admin SDK Directory API: Lists the domains of the customer. - - HTTP GET admin/directory/v1/customer/{customer}/domains - - Args: - customer (str, required): The unique ID for the customer's Google Workspace account. In case of a multi-domain account, to fetch all groups for a customer, use this field instead of `domain`. You can also use the `my_customer` alias to represent your account's `customerId`. The `customerId` is also returned as part of the [Users](https://developers.google.com/workspace/admin/directory/v1/reference/users) resource. You must provide either the `customer` or the `domain` parameter. - - Returns: - Dict[str, Any]: API response - """ - kwargs = {} - if customer is not None: - kwargs['customer'] = customer - - request = self.client.domains().list(**kwargs) # type: ignore - return request.execute() - async def groups_delete( self, groupKey: str @@ -1810,154 +1468,16 @@ async def orgunits_patch( """ kwargs = {} if customerId is not None: - kwargs['customerId'] = customerId - if orgUnitPath is not None: - kwargs['orgUnitPath'] = orgUnitPath - - # Handle request body if needed - if 'body' in kwargs: - body = kwargs.pop('body') - request = self.client.orgunits().patch(**kwargs, body=body) # type: ignore - else: - request = self.client.orgunits().patch(**kwargs) # type: ignore - return request.execute() - - async def privileges_list( - self, - customer: str - ) -> Dict[str, Any]: - """Google Admin SDK Directory API: Retrieves a paginated list of all privileges for a customer. - - HTTP GET admin/directory/v1/customer/{customer}/roles/ALL/privileges - - Args: - customer (str, required): The unique ID for the customer's Google Workspace account. In case of a multi-domain account, to fetch all groups for a customer, use this field instead of `domain`. You can also use the `my_customer` alias to represent your account's `customerId`. The `customerId` is also returned as part of the [Users](https://developers.google.com/workspace/admin/directory/v1/reference/users) resource. You must provide either the `customer` or the `domain` parameter. - - Returns: - Dict[str, Any]: API response - """ - kwargs = {} - if customer is not None: - kwargs['customer'] = customer - - request = self.client.privileges().list(**kwargs) # type: ignore - return request.execute() - - async def role_assignments_delete( - self, - customer: str, - roleAssignmentId: str - ) -> Dict[str, Any]: - """Google Admin SDK Directory API: Deletes a role assignment. - - HTTP DELETE admin/directory/v1/customer/{customer}/roleassignments/{roleAssignmentId} - - Args: - customer (str, required): Immutable ID of the Google Workspace account. - roleAssignmentId (str, required): Immutable ID of the role assignment. - - Returns: - Dict[str, Any]: API response - """ - kwargs = {} - if customer is not None: - kwargs['customer'] = customer - if roleAssignmentId is not None: - kwargs['roleAssignmentId'] = roleAssignmentId - - request = self.client.roleAssignments().delete(**kwargs) # type: ignore - return request.execute() - - async def role_assignments_get( - self, - customer: str, - roleAssignmentId: str - ) -> Dict[str, Any]: - """Google Admin SDK Directory API: Retrieves a role assignment. - - HTTP GET admin/directory/v1/customer/{customer}/roleassignments/{roleAssignmentId} - - Args: - customer (str, required): The unique ID for the customer's Google Workspace account. In case of a multi-domain account, to fetch all groups for a customer, use this field instead of `domain`. You can also use the `my_customer` alias to represent your account's `customerId`. The `customerId` is also returned as part of the [Users](https://developers.google.com/workspace/admin/directory/v1/reference/users) resource. You must provide either the `customer` or the `domain` parameter. - roleAssignmentId (str, required): Immutable ID of the role assignment. - - Returns: - Dict[str, Any]: API response - """ - kwargs = {} - if customer is not None: - kwargs['customer'] = customer - if roleAssignmentId is not None: - kwargs['roleAssignmentId'] = roleAssignmentId - - request = self.client.roleAssignments().get(**kwargs) # type: ignore - return request.execute() - - async def role_assignments_insert( - self, - customer: str - ) -> Dict[str, Any]: - """Google Admin SDK Directory API: Creates a role assignment. - - HTTP POST admin/directory/v1/customer/{customer}/roleassignments - - Args: - customer (str, required): Immutable ID of the Google Workspace account. - - Returns: - Dict[str, Any]: API response - """ - kwargs = {} - if customer is not None: - kwargs['customer'] = customer - - # Handle request body if needed - if 'body' in kwargs: - body = kwargs.pop('body') - request = self.client.roleAssignments().insert(**kwargs, body=body) # type: ignore - else: - request = self.client.roleAssignments().insert(**kwargs) # type: ignore - return request.execute() - - async def role_assignments_list( - self, - customer: str, - maxResults: Optional[int] = None, - pageToken: Optional[str] = None, - roleId: Optional[str] = None, - userKey: Optional[str] = None, - includeIndirectRoleAssignments: Optional[bool] = None - ) -> Dict[str, Any]: - """Google Admin SDK Directory API: Retrieves a paginated list of all roleAssignments. - - HTTP GET admin/directory/v1/customer/{customer}/roleassignments - - Args: - customer (str, required): The unique ID for the customer's Google Workspace account. In case of a multi-domain account, to fetch all groups for a customer, use this field instead of `domain`. You can also use the `my_customer` alias to represent your account's `customerId`. The `customerId` is also returned as part of the [Users](https://developers.google.com/workspace/admin/directory/v1/reference/users) resource. You must provide either the `customer` or the `domain` parameter. - maxResults (int, optional): Maximum number of results to return. - pageToken (str, optional): Token to specify the next page in the list. - roleId (str, optional): Immutable ID of a role. If included in the request, returns only role assignments containing this role ID. - userKey (str, optional): The primary email address, alias email address, or unique user or group ID. If included in the request, returns role assignments only for this user or group. - includeIndirectRoleAssignments (bool, optional): When set to `true`, fetches indirect role assignments (i.e. role assignment via a group) as well as direct ones. Defaults to `false`. You must specify `user_key` or the indirect role assignments will not be included. - - Returns: - Dict[str, Any]: API response - """ - kwargs = {} - if customer is not None: - kwargs['customer'] = customer - if maxResults is not None: - kwargs['maxResults'] = maxResults - if pageToken is not None: - kwargs['pageToken'] = pageToken - if roleId is not None: - kwargs['roleId'] = roleId - if userKey is not None: - kwargs['userKey'] = userKey - if includeIndirectRoleAssignments is not None: - kwargs['includeIndirectRoleAssignments'] = includeIndirectRoleAssignments + kwargs['customerId'] = customerId + if orgUnitPath is not None: + kwargs['orgUnitPath'] = orgUnitPath - request = self.client.roleAssignments().list(**kwargs) # type: ignore + # Handle request body if needed + if 'body' in kwargs: + body = kwargs.pop('body') + request = self.client.orgunits().patch(**kwargs, body=body) # type: ignore + else: + request = self.client.orgunits().patch(**kwargs) # type: ignore return request.execute() async def resources_buildings_delete( @@ -2505,171 +2025,6 @@ async def resources_features_patch( request = self.client.resources_features().patch(**kwargs) # type: ignore return request.execute() - async def roles_delete( - self, - customer: str, - roleId: str - ) -> Dict[str, Any]: - """Google Admin SDK Directory API: Deletes a role. - - HTTP DELETE admin/directory/v1/customer/{customer}/roles/{roleId} - - Args: - customer (str, required): Immutable ID of the Google Workspace account. - roleId (str, required): Immutable ID of the role. - - Returns: - Dict[str, Any]: API response - """ - kwargs = {} - if customer is not None: - kwargs['customer'] = customer - if roleId is not None: - kwargs['roleId'] = roleId - - request = self.client.roles().delete(**kwargs) # type: ignore - return request.execute() - - async def roles_get( - self, - customer: str, - roleId: str - ) -> Dict[str, Any]: - """Google Admin SDK Directory API: Retrieves a role. - - HTTP GET admin/directory/v1/customer/{customer}/roles/{roleId} - - Args: - customer (str, required): The unique ID for the customer's Google Workspace account. In case of a multi-domain account, to fetch all groups for a customer, use this field instead of `domain`. You can also use the `my_customer` alias to represent your account's `customerId`. The `customerId` is also returned as part of the [Users](https://developers.google.com/workspace/admin/directory/v1/reference/users) resource. You must provide either the `customer` or the `domain` parameter. - roleId (str, required): Immutable ID of the role. - - Returns: - Dict[str, Any]: API response - """ - kwargs = {} - if customer is not None: - kwargs['customer'] = customer - if roleId is not None: - kwargs['roleId'] = roleId - - request = self.client.roles().get(**kwargs) # type: ignore - return request.execute() - - async def roles_insert( - self, - customer: str - ) -> Dict[str, Any]: - """Google Admin SDK Directory API: Creates a role. - - HTTP POST admin/directory/v1/customer/{customer}/roles - - Args: - customer (str, required): Immutable ID of the Google Workspace account. - - Returns: - Dict[str, Any]: API response - """ - kwargs = {} - if customer is not None: - kwargs['customer'] = customer - - # Handle request body if needed - if 'body' in kwargs: - body = kwargs.pop('body') - request = self.client.roles().insert(**kwargs, body=body) # type: ignore - else: - request = self.client.roles().insert(**kwargs) # type: ignore - return request.execute() - - async def roles_list( - self, - customer: str, - maxResults: Optional[int] = None, - pageToken: Optional[str] = None - ) -> Dict[str, Any]: - """Google Admin SDK Directory API: Retrieves a paginated list of all the roles in a domain. - - HTTP GET admin/directory/v1/customer/{customer}/roles - - Args: - customer (str, required): The unique ID for the customer's Google Workspace account. In case of a multi-domain account, to fetch all groups for a customer, use this field instead of `domain`. You can also use the `my_customer` alias to represent your account's `customerId`. The `customerId` is also returned as part of the [Users](https://developers.google.com/workspace/admin/directory/v1/reference/users) resource. You must provide either the `customer` or the `domain` parameter. - maxResults (int, optional): Maximum number of results to return. - pageToken (str, optional): Token to specify the next page in the list. - - Returns: - Dict[str, Any]: API response - """ - kwargs = {} - if customer is not None: - kwargs['customer'] = customer - if maxResults is not None: - kwargs['maxResults'] = maxResults - if pageToken is not None: - kwargs['pageToken'] = pageToken - - request = self.client.roles().list(**kwargs) # type: ignore - return request.execute() - - async def roles_update( - self, - customer: str, - roleId: str - ) -> Dict[str, Any]: - """Google Admin SDK Directory API: Updates a role. - - HTTP PUT admin/directory/v1/customer/{customer}/roles/{roleId} - - Args: - customer (str, required): Immutable ID of the Google Workspace account. - roleId (str, required): Immutable ID of the role. - - Returns: - Dict[str, Any]: API response - """ - kwargs = {} - if customer is not None: - kwargs['customer'] = customer - if roleId is not None: - kwargs['roleId'] = roleId - - # Handle request body if needed - if 'body' in kwargs: - body = kwargs.pop('body') - request = self.client.roles().update(**kwargs, body=body) # type: ignore - else: - request = self.client.roles().update(**kwargs) # type: ignore - return request.execute() - - async def roles_patch( - self, - customer: str, - roleId: str - ) -> Dict[str, Any]: - """Google Admin SDK Directory API: Patches a role. - - HTTP PATCH admin/directory/v1/customer/{customer}/roles/{roleId} - - Args: - customer (str, required): Immutable ID of the Google Workspace account. - roleId (str, required): Immutable ID of the role. - - Returns: - Dict[str, Any]: API response - """ - kwargs = {} - if customer is not None: - kwargs['customer'] = customer - if roleId is not None: - kwargs['roleId'] = roleId - - # Handle request body if needed - if 'body' in kwargs: - body = kwargs.pop('body') - request = self.client.roles().patch(**kwargs, body=body) # type: ignore - else: - request = self.client.roles().patch(**kwargs) # type: ignore - return request.execute() - async def schemas_delete( self, customerId: str, @@ -2827,103 +2182,6 @@ async def schemas_update( request = self.client.schemas().update(**kwargs) # type: ignore return request.execute() - async def tokens_delete( - self, - userKey: str, - clientId: str - ) -> Dict[str, Any]: - """Google Admin SDK Directory API: Deletes all access tokens issued by a user for an application. - - HTTP DELETE admin/directory/v1/users/{userKey}/tokens/{clientId} - - Args: - userKey (str, required): Identifies the user in the API request. The value can be the user's primary email address, alias email address, or unique user ID. - clientId (str, required): The Client ID of the application the token is issued to. - - Returns: - Dict[str, Any]: API response - """ - kwargs = {} - if userKey is not None: - kwargs['userKey'] = userKey - if clientId is not None: - kwargs['clientId'] = clientId - - request = self.client.tokens().delete(**kwargs) # type: ignore - return request.execute() - - async def tokens_get( - self, - userKey: str, - clientId: str - ) -> Dict[str, Any]: - """Google Admin SDK Directory API: Gets information about an access token issued by a user. - - HTTP GET admin/directory/v1/users/{userKey}/tokens/{clientId} - - Args: - userKey (str, required): Identifies the user in the API request. The value can be the user's primary email address, alias email address, or unique user ID. - clientId (str, required): The Client ID of the application the token is issued to. - - Returns: - Dict[str, Any]: API response - """ - kwargs = {} - if userKey is not None: - kwargs['userKey'] = userKey - if clientId is not None: - kwargs['clientId'] = clientId - - request = self.client.tokens().get(**kwargs) # type: ignore - return request.execute() - - async def tokens_list( - self, - userKey: str - ) -> Dict[str, Any]: - """Google Admin SDK Directory API: Returns the set of tokens specified user has issued to 3rd party applications. - - HTTP GET admin/directory/v1/users/{userKey}/tokens - - Args: - userKey (str, required): Identifies the user in the API request. The value can be the user's primary email address, alias email address, or unique user ID. - - Returns: - Dict[str, Any]: API response - """ - kwargs = {} - if userKey is not None: - kwargs['userKey'] = userKey - - request = self.client.tokens().list(**kwargs) # type: ignore - return request.execute() - - async def two_step_verification_turn_off( - self, - userKey: str - ) -> Dict[str, Any]: - """Google Admin SDK Directory API: Turns off 2-Step Verification for user. - - HTTP POST admin/directory/v1/users/{userKey}/twoStepVerification/turnOff - - Args: - userKey (str, required): Identifies the user in the API request. The value can be the user's primary email address, alias email address, or unique user ID. - - Returns: - Dict[str, Any]: API response - """ - kwargs = {} - if userKey is not None: - kwargs['userKey'] = userKey - - # Handle request body if needed - if 'body' in kwargs: - body = kwargs.pop('body') - request = self.client.twoStepVerification().turnOff(**kwargs, body=body) # type: ignore - else: - request = self.client.twoStepVerification().turnOff(**kwargs) # type: ignore - return request.execute() - async def users_delete( self, userKey: str @@ -3469,79 +2727,6 @@ async def users_photos_patch( request = self.client.users_photos().patch(**kwargs) # type: ignore return request.execute() - async def verification_codes_generate( - self, - userKey: str - ) -> Dict[str, Any]: - """Google Admin SDK Directory API: Generates new backup verification codes for the user. - - HTTP POST admin/directory/v1/users/{userKey}/verificationCodes/generate - - Args: - userKey (str, required): Email or immutable ID of the user - - Returns: - Dict[str, Any]: API response - """ - kwargs = {} - if userKey is not None: - kwargs['userKey'] = userKey - - # Handle request body if needed - if 'body' in kwargs: - body = kwargs.pop('body') - request = self.client.verificationCodes().generate(**kwargs, body=body) # type: ignore - else: - request = self.client.verificationCodes().generate(**kwargs) # type: ignore - return request.execute() - - async def verification_codes_invalidate( - self, - userKey: str - ) -> Dict[str, Any]: - """Google Admin SDK Directory API: Invalidates the current backup verification codes for the user. - - HTTP POST admin/directory/v1/users/{userKey}/verificationCodes/invalidate - - Args: - userKey (str, required): Email or immutable ID of the user - - Returns: - Dict[str, Any]: API response - """ - kwargs = {} - if userKey is not None: - kwargs['userKey'] = userKey - - # Handle request body if needed - if 'body' in kwargs: - body = kwargs.pop('body') - request = self.client.verificationCodes().invalidate(**kwargs, body=body) # type: ignore - else: - request = self.client.verificationCodes().invalidate(**kwargs) # type: ignore - return request.execute() - - async def verification_codes_list( - self, - userKey: str - ) -> Dict[str, Any]: - """Google Admin SDK Directory API: Returns the current set of valid backup verification codes for the specified user. - - HTTP GET admin/directory/v1/users/{userKey}/verificationCodes - - Args: - userKey (str, required): Identifies the user in the API request. The value can be the user's primary email address, alias email address, or unique user ID. - - Returns: - Dict[str, Any]: API response - """ - kwargs = {} - if userKey is not None: - kwargs['userKey'] = userKey - - request = self.client.verificationCodes().list(**kwargs) # type: ignore - return request.execute() - async def get_client(self) -> object: """Get the underlying Google API client.""" return self.client diff --git a/backend/python/app/sources/external/google/workspace_sso/__init__.py b/backend/python/app/sources/external/google/workspace_sso/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/python/app/sources/external/google/workspace_sso/example.py b/backend/python/app/sources/external/google/workspace_sso/example.py new file mode 100644 index 000000000..4b95fb492 --- /dev/null +++ b/backend/python/app/sources/external/google/workspace_sso/example.py @@ -0,0 +1,158 @@ +# ruff: noqa +""" +Example script to demonstrate how to use the Google Workspace SSO API +""" +import asyncio +import json +import os +from typing import Any, Dict, List, Optional + +from app.sources.client.google.google import GoogleClient +from app.sources.external.google.workspace_sso.workspace_sso import GoogleWorkspaceSSODataSource + +try: + from google.oauth2 import service_account # type: ignore + from googleapiclient.discovery import build # type: ignore +except ImportError: + print("Google API client libraries not found. Please install them using 'pip install google-api-python-client google-auth-httplib2 google-auth-oauthlib'") + raise + + +async def build_enterprise_client_from_credentials( + service_name: str = "admin", + service_account_info: Optional[Dict[str, Any]] = None, + service_account_file: Optional[str] = None, + user_email: Optional[str] = None, + scopes: Optional[list] = None, + version: str = "directory_v1", +) -> GoogleClient: + """ + Build GoogleClient for enterprise account using service account credentials from .env. + + Args: + service_name: Name of the Google service (e.g., "admin", "drive") + service_account_info: Service account JSON key as a dictionary (optional) + service_account_file: Path to service account JSON file (optional, from GOOGLE_SERVICE_ACCOUNT_FILE) + user_email: Optional user email for impersonation (from GOOGLE_ADMIN_EMAIL or service account client_email) + scopes: Optional list of scopes (uses defaults if not provided) + version: API version (default: "directory_v1" for admin) + + Returns: + GoogleClient instance + """ + # Load service account info from file if provided + if service_account_file: + with open(service_account_file, 'r') as f: + service_account_info = json.load(f) + elif not service_account_info: + # Try to load from environment variable as JSON string + service_account_json = os.getenv("GOOGLE_SERVICE_ACCOUNT_JSON") + if service_account_json: + service_account_info = json.loads(service_account_json) + else: + # Try to load from file path in env + service_account_file_path = os.getenv("GOOGLE_SERVICE_ACCOUNT_FILE") + if service_account_file_path: + with open(service_account_file_path, 'r') as f: + service_account_info = json.load(f) + else: + raise ValueError( + "service_account_info, service_account_file, GOOGLE_SERVICE_ACCOUNT_JSON, " + "or GOOGLE_SERVICE_ACCOUNT_FILE must be provided" + ) + + # Get optimized scopes for the service + optimized_scopes = GoogleClient._get_optimized_scopes(service_name, scopes) + + # Get admin email from service account info or use provided user_email + admin_email = os.getenv("GOOGLE_ADMIN_EMAIL") + if not admin_email: + raise ValueError( + "Either service_account_info must contain 'client_email', user_email must be provided, " + "or GOOGLE_ADMIN_EMAIL must be set in environment" + ) + + # Create service account credentials + google_credentials = service_account.Credentials.from_service_account_info( + service_account_info, + scopes=optimized_scopes, + subject=(user_email or admin_email), + ) + + # Create Google service client + client = build( + service_name, + version, + credentials=google_credentials, + cache_discovery=False, + ) + + return GoogleClient(client) + + +async def main() -> None: + # Build enterprise client from .env credentials + # Supports: + # - GOOGLE_SERVICE_ACCOUNT_FILE: Path to service account JSON file + # - GOOGLE_SERVICE_ACCOUNT_JSON: Service account JSON as string + # - GOOGLE_ADMIN_EMAIL: Admin email for impersonation (optional, uses client_email if not provided) + + enterprise_google_client = await build_enterprise_client_from_credentials( + service_name="admin", + version="directory_v1", + service_account_file=os.getenv("GOOGLE_SERVICE_ACCOUNT_FILE"), + user_email=os.getenv("GOOGLE_ADMIN_EMAIL"), + ) + + workspace_sso_client = GoogleWorkspaceSSODataSource(enterprise_google_client.get_client()) + + # List all domains + print("Listing all domains...") + try: + results = await workspace_sso_client.domains_list( + customer="my_customer" + ) + print(f"Success! Found {len(results.get('domains', []))} domains") + print(results) + except Exception as e: + print(f"Error listing domains: {e}") + print(f"Error type: {type(e).__name__}") + + # List all roles + print("\nListing all roles...") + try: + roles_results = await workspace_sso_client.roles_list( + customer="my_customer" + ) + print(f"Success! Found {len(roles_results.get('items', []))} roles") + print(roles_results) + except Exception as e: + print(f"Error listing roles: {e}") + print(f"Error type: {type(e).__name__}") + + # List all privileges + print("\nListing all privileges...") + try: + privileges_results = await workspace_sso_client.privileges_list( + customer="my_customer" + ) + print(f"Success! Found {len(privileges_results.get('items', []))} privileges") + except Exception as e: + print(f"Error listing privileges: {e}") + print(f"Error type: {type(e).__name__}") + + # Get customer info + print("\nGetting customer info...") + try: + customer_info = await workspace_sso_client.customers_get( + customerKey="my_customer" + ) + print(f"Success! Customer: {customer_info.get('customerDomain', 'N/A')}") + print(customer_info) + except Exception as e: + print(f"Error getting customer: {e}") + print(f"Error type: {type(e).__name__}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/google/workspace_sso/workspace_sso.py b/backend/python/app/sources/external/google/workspace_sso/workspace_sso.py new file mode 100644 index 000000000..8aeb45a8c --- /dev/null +++ b/backend/python/app/sources/external/google/workspace_sso/workspace_sso.py @@ -0,0 +1,817 @@ +from typing import Any, Optional + +from app.sources.client.google.google import GoogleClient + + +class GoogleWorkspaceSSODataSource: + """ + Google Workspace SSO connector for SSO, security, and access management operations. + Uses Google SDK client internally for all operations. + This class wraps Google Admin SDK Directory API methods related to SSO/security + concerns including domains, domain aliases, roles, role assignments, privileges, + tokens, verification codes, 2-step verification, ASPs, and customer management. + """ + def __init__( + self, + client: GoogleClient + ) -> None: + """ + Initialize with Google Admin SDK Directory API client. + Args: + client: Google Admin SDK Directory API client from build('admin', 'directory_v1', credentials=credentials) + """ + super().__init__() + self.client = client + + # ==================== Domains ==================== + + async def domains_list( + self, + customer: str + ) -> dict[str, Any]: + """Google Admin SDK Directory API: Lists the domains of the customer. + + HTTP GET admin/directory/v1/customer/{customer}/domains + + Args: + customer (str, required): The unique ID for the customer's Google Workspace account. In case of a multi-domain account, to fetch all groups for a customer, use this field instead of `domain`. You can also use the `my_customer` alias to represent your account's `customerId`. The `customerId` is also returned as part of the [Users](https://developers.google.com/workspace/admin/directory/v1/reference/users) resource. You must provide either the `customer` or the `domain` parameter. + + Returns: + Dict[str, Any]: API response + """ + kwargs = {} + kwargs['customer'] = customer + + request = self.client.domains().list(**kwargs) # type: ignore + return request.execute() # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + async def domains_get( + self, + customer: str, + domainName: str + ) -> dict[str, Any]: + """Google Admin SDK Directory API: Retrieves a domain of the customer. + + HTTP GET admin/directory/v1/customer/{customer}/domains/{domainName} + + Args: + customer (str, required): The unique ID for the customer's Google Workspace account. In case of a multi-domain account, to fetch all groups for a customer, use this field instead of `domain`. You can also use the `my_customer` alias to represent your account's `customerId`. The `customerId` is also returned as part of the [Users](https://developers.google.com/workspace/admin/directory/v1/reference/users) resource. You must provide either the `customer` or the `domain` parameter. + domainName (str, required): Name of domain to be retrieved + + Returns: + Dict[str, Any]: API response + """ + kwargs = {} + kwargs['customer'] = customer + kwargs['domainName'] = domainName + + request = self.client.domains().get(**kwargs) # type: ignore + return request.execute() # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + async def domains_insert( + self, + customer: str + ) -> dict[str, Any]: + """Google Admin SDK Directory API: Inserts a domain of the customer. + + HTTP POST admin/directory/v1/customer/{customer}/domains + + Args: + customer (str, required): Immutable ID of the Google Workspace account. + + Returns: + Dict[str, Any]: API response + """ + kwargs = {} + kwargs['customer'] = customer + + # Handle request body if needed + if 'body' in kwargs: + body: Any = kwargs.pop('body') # type: ignore[reportUnknownVariableType, reportUnknownMemberType] + request = self.client.domains().insert(**kwargs, body=body) # type: ignore + else: + request = self.client.domains().insert(**kwargs) # type: ignore + return request.execute() # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + async def domains_delete( + self, + customer: str, + domainName: str + ) -> dict[str, Any]: + """Google Admin SDK Directory API: Deletes a domain of the customer. + + HTTP DELETE admin/directory/v1/customer/{customer}/domains/{domainName} + + Args: + customer (str, required): Immutable ID of the Google Workspace account. + domainName (str, required): Name of domain to be deleted + + Returns: + Dict[str, Any]: API response + """ + kwargs = {} + kwargs['customer'] = customer + kwargs['domainName'] = domainName + + request = self.client.domains().delete(**kwargs) # type: ignore + return request.execute() # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + # ==================== Domain Aliases ==================== + + async def domain_aliases_list( + self, + customer: str, + parentDomainName: Optional[str] = None + ) -> dict[str, Any]: + """Google Admin SDK Directory API: Lists the domain aliases of the customer. + + HTTP GET admin/directory/v1/customer/{customer}/domainaliases + + Args: + customer (str, required): The unique ID for the customer's Google Workspace account. In case of a multi-domain account, to fetch all groups for a customer, use this field instead of `domain`. You can also use the `my_customer` alias to represent your account's `customerId`. The `customerId` is also returned as part of the [Users](https://developers.google.com/workspace/admin/directory/v1/reference/users) resource. You must provide either the `customer` or the `domain` parameter. + parentDomainName (str, optional): Name of the parent domain for which domain aliases are to be fetched. + + Returns: + Dict[str, Any]: API response + """ + kwargs = {} + kwargs['customer'] = customer + if parentDomainName is not None: + kwargs['parentDomainName'] = parentDomainName + + request = self.client.domainAliases().list(**kwargs) # type: ignore + return request.execute() # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + async def domain_aliases_get( + self, + customer: str, + domainAliasName: str + ) -> dict[str, Any]: + """Google Admin SDK Directory API: Retrieves a domain alias of the customer. + + HTTP GET admin/directory/v1/customer/{customer}/domainaliases/{domainAliasName} + + Args: + customer (str, required): The unique ID for the customer's Google Workspace account. In case of a multi-domain account, to fetch all groups for a customer, use this field instead of `domain`. You can also use the `my_customer` alias to represent your account's `customerId`. The `customerId` is also returned as part of the [Users](https://developers.google.com/workspace/admin/directory/v1/reference/users) resource. You must provide either the `customer` or the `domain` parameter. + domainAliasName (str, required): Name of domain alias to be retrieved. + + Returns: + Dict[str, Any]: API response + """ + kwargs = {} + kwargs['customer'] = customer + kwargs['domainAliasName'] = domainAliasName + + request = self.client.domainAliases().get(**kwargs) # type: ignore + return request.execute() # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + async def domain_aliases_insert( + self, + customer: str + ) -> dict[str, Any]: + """Google Admin SDK Directory API: Inserts a domain alias of the customer. + + HTTP POST admin/directory/v1/customer/{customer}/domainaliases + + Args: + customer (str, required): Immutable ID of the Google Workspace account. + + Returns: + Dict[str, Any]: API response + """ + kwargs = {} + kwargs['customer'] = customer + + # Handle request body if needed + if 'body' in kwargs: + body: Any = kwargs.pop('body') # type: ignore[reportUnknownVariableType, reportUnknownMemberType] + request = self.client.domainAliases().insert(**kwargs, body=body) # type: ignore + else: + request = self.client.domainAliases().insert(**kwargs) # type: ignore + return request.execute() # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + async def domain_aliases_delete( + self, + customer: str, + domainAliasName: str + ) -> dict[str, Any]: + """Google Admin SDK Directory API: Deletes a domain Alias of the customer. + + HTTP DELETE admin/directory/v1/customer/{customer}/domainaliases/{domainAliasName} + + Args: + customer (str, required): Immutable ID of the Google Workspace account. + domainAliasName (str, required): Name of domain alias to be retrieved. + + Returns: + Dict[str, Any]: API response + """ + kwargs = {} + kwargs['customer'] = customer + kwargs['domainAliasName'] = domainAliasName + + request = self.client.domainAliases().delete(**kwargs) # type: ignore + return request.execute() # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + # ==================== Roles ==================== + + async def roles_list( + self, + customer: str, + maxResults: Optional[int] = None, + pageToken: Optional[str] = None + ) -> dict[str, Any]: + """Google Admin SDK Directory API: Retrieves a paginated list of all the roles in a domain. + + HTTP GET admin/directory/v1/customer/{customer}/roles + + Args: + customer (str, required): The unique ID for the customer's Google Workspace account. In case of a multi-domain account, to fetch all groups for a customer, use this field instead of `domain`. You can also use the `my_customer` alias to represent your account's `customerId`. The `customerId` is also returned as part of the [Users](https://developers.google.com/workspace/admin/directory/v1/reference/users) resource. You must provide either the `customer` or the `domain` parameter. + maxResults (int, optional): Maximum number of results to return. + pageToken (str, optional): Token to specify the next page in the list. + + Returns: + Dict[str, Any]: API response + """ + kwargs = {} + kwargs['customer'] = customer + if maxResults is not None: + kwargs['maxResults'] = maxResults + if pageToken is not None: + kwargs['pageToken'] = pageToken + + request = self.client.roles().list(**kwargs) # type: ignore + return request.execute() # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + async def roles_get( + self, + customer: str, + roleId: str + ) -> dict[str, Any]: + """Google Admin SDK Directory API: Retrieves a role. + + HTTP GET admin/directory/v1/customer/{customer}/roles/{roleId} + + Args: + customer (str, required): The unique ID for the customer's Google Workspace account. In case of a multi-domain account, to fetch all groups for a customer, use this field instead of `domain`. You can also use the `my_customer` alias to represent your account's `customerId`. The `customerId` is also returned as part of the [Users](https://developers.google.com/workspace/admin/directory/v1/reference/users) resource. You must provide either the `customer` or the `domain` parameter. + roleId (str, required): Immutable ID of the role. + + Returns: + Dict[str, Any]: API response + """ + kwargs = {} + kwargs['customer'] = customer + kwargs['roleId'] = roleId + + request = self.client.roles().get(**kwargs) # type: ignore + return request.execute() # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + async def roles_insert( + self, + customer: str + ) -> dict[str, Any]: + """Google Admin SDK Directory API: Creates a role. + + HTTP POST admin/directory/v1/customer/{customer}/roles + + Args: + customer (str, required): Immutable ID of the Google Workspace account. + + Returns: + Dict[str, Any]: API response + """ + kwargs = {} + kwargs['customer'] = customer + + # Handle request body if needed + if 'body' in kwargs: + body: Any = kwargs.pop('body') # type: ignore[reportUnknownVariableType, reportUnknownMemberType] + request = self.client.roles().insert(**kwargs, body=body) # type: ignore + else: + request = self.client.roles().insert(**kwargs) # type: ignore + return request.execute() # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + async def roles_update( + self, + customer: str, + roleId: str + ) -> dict[str, Any]: + """Google Admin SDK Directory API: Updates a role. + + HTTP PUT admin/directory/v1/customer/{customer}/roles/{roleId} + + Args: + customer (str, required): Immutable ID of the Google Workspace account. + roleId (str, required): Immutable ID of the role. + + Returns: + Dict[str, Any]: API response + """ + kwargs = {} + kwargs['customer'] = customer + kwargs['roleId'] = roleId + + # Handle request body if needed + if 'body' in kwargs: + body: Any = kwargs.pop('body') # type: ignore[reportUnknownVariableType, reportUnknownMemberType] + request = self.client.roles().update(**kwargs, body=body) # type: ignore + else: + request = self.client.roles().update(**kwargs) # type: ignore + return request.execute() # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + async def roles_patch( + self, + customer: str, + roleId: str + ) -> dict[str, Any]: + """Google Admin SDK Directory API: Patches a role. + + HTTP PATCH admin/directory/v1/customer/{customer}/roles/{roleId} + + Args: + customer (str, required): Immutable ID of the Google Workspace account. + roleId (str, required): Immutable ID of the role. + + Returns: + Dict[str, Any]: API response + """ + kwargs = {} + kwargs['customer'] = customer + kwargs['roleId'] = roleId + + # Handle request body if needed + if 'body' in kwargs: + body: Any = kwargs.pop('body') # type: ignore[reportUnknownVariableType, reportUnknownMemberType] + request = self.client.roles().patch(**kwargs, body=body) # type: ignore + else: + request = self.client.roles().patch(**kwargs) # type: ignore + return request.execute() # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + async def roles_delete( + self, + customer: str, + roleId: str + ) -> dict[str, Any]: + """Google Admin SDK Directory API: Deletes a role. + + HTTP DELETE admin/directory/v1/customer/{customer}/roles/{roleId} + + Args: + customer (str, required): Immutable ID of the Google Workspace account. + roleId (str, required): Immutable ID of the role. + + Returns: + Dict[str, Any]: API response + """ + kwargs = {} + kwargs['customer'] = customer + kwargs['roleId'] = roleId + + request = self.client.roles().delete(**kwargs) # type: ignore + return request.execute() # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + # ==================== Role Assignments ==================== + + async def role_assignments_list( + self, + customer: str, + maxResults: Optional[int] = None, + pageToken: Optional[str] = None, + roleId: Optional[str] = None, + userKey: Optional[str] = None, + includeIndirectRoleAssignments: Optional[bool] = None # noqa: FBT001 + ) -> dict[str, Any]: + """Google Admin SDK Directory API: Retrieves a paginated list of all roleAssignments. + + HTTP GET admin/directory/v1/customer/{customer}/roleassignments + + Args: + customer (str, required): The unique ID for the customer's Google Workspace account. In case of a multi-domain account, to fetch all groups for a customer, use this field instead of `domain`. You can also use the `my_customer` alias to represent your account's `customerId`. The `customerId` is also returned as part of the [Users](https://developers.google.com/workspace/admin/directory/v1/reference/users) resource. You must provide either the `customer` or the `domain` parameter. + maxResults (int, optional): Maximum number of results to return. + pageToken (str, optional): Token to specify the next page in the list. + roleId (str, optional): Immutable ID of a role. If included in the request, returns only role assignments containing this role ID. + userKey (str, optional): The primary email address, alias email address, or unique user or group ID. If included in the request, returns role assignments only for this user or group. + includeIndirectRoleAssignments (bool, optional): When set to `true`, fetches indirect role assignments (i.e. role assignment via a group) as well as direct ones. Defaults to `false`. You must specify `user_key` or the indirect role assignments will not be included. + + Returns: + Dict[str, Any]: API response + """ + kwargs = {} + kwargs['customer'] = customer + if maxResults is not None: + kwargs['maxResults'] = maxResults + if pageToken is not None: + kwargs['pageToken'] = pageToken + if roleId is not None: + kwargs['roleId'] = roleId + if userKey is not None: + kwargs['userKey'] = userKey + if includeIndirectRoleAssignments is not None: + kwargs['includeIndirectRoleAssignments'] = includeIndirectRoleAssignments + + request = self.client.roleAssignments().list(**kwargs) # type: ignore + return request.execute() # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + async def role_assignments_get( + self, + customer: str, + roleAssignmentId: str + ) -> dict[str, Any]: + """Google Admin SDK Directory API: Retrieves a role assignment. + + HTTP GET admin/directory/v1/customer/{customer}/roleassignments/{roleAssignmentId} + + Args: + customer (str, required): The unique ID for the customer's Google Workspace account. In case of a multi-domain account, to fetch all groups for a customer, use this field instead of `domain`. You can also use the `my_customer` alias to represent your account's `customerId`. The `customerId` is also returned as part of the [Users](https://developers.google.com/workspace/admin/directory/v1/reference/users) resource. You must provide either the `customer` or the `domain` parameter. + roleAssignmentId (str, required): Immutable ID of the role assignment. + + Returns: + Dict[str, Any]: API response + """ + kwargs = {} + kwargs['customer'] = customer + kwargs['roleAssignmentId'] = roleAssignmentId + + request = self.client.roleAssignments().get(**kwargs) # type: ignore + return request.execute() # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + async def role_assignments_insert( + self, + customer: str + ) -> dict[str, Any]: + """Google Admin SDK Directory API: Creates a role assignment. + + HTTP POST admin/directory/v1/customer/{customer}/roleassignments + + Args: + customer (str, required): Immutable ID of the Google Workspace account. + + Returns: + Dict[str, Any]: API response + """ + kwargs = {} + kwargs['customer'] = customer + + # Handle request body if needed + if 'body' in kwargs: + body: Any = kwargs.pop('body') # type: ignore[reportUnknownVariableType, reportUnknownMemberType] + request = self.client.roleAssignments().insert(**kwargs, body=body) # type: ignore + else: + request = self.client.roleAssignments().insert(**kwargs) # type: ignore + return request.execute() # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + async def role_assignments_delete( + self, + customer: str, + roleAssignmentId: str + ) -> dict[str, Any]: + """Google Admin SDK Directory API: Deletes a role assignment. + + HTTP DELETE admin/directory/v1/customer/{customer}/roleassignments/{roleAssignmentId} + + Args: + customer (str, required): Immutable ID of the Google Workspace account. + roleAssignmentId (str, required): Immutable ID of the role assignment. + + Returns: + Dict[str, Any]: API response + """ + kwargs = {} + kwargs['customer'] = customer + kwargs['roleAssignmentId'] = roleAssignmentId + + request = self.client.roleAssignments().delete(**kwargs) # type: ignore + return request.execute() # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + # ==================== Privileges ==================== + + async def privileges_list( + self, + customer: str + ) -> dict[str, Any]: + """Google Admin SDK Directory API: Retrieves a paginated list of all privileges for a customer. + + HTTP GET admin/directory/v1/customer/{customer}/roles/ALL/privileges + + Args: + customer (str, required): The unique ID for the customer's Google Workspace account. In case of a multi-domain account, to fetch all groups for a customer, use this field instead of `domain`. You can also use the `my_customer` alias to represent your account's `customerId`. The `customerId` is also returned as part of the [Users](https://developers.google.com/workspace/admin/directory/v1/reference/users) resource. You must provide either the `customer` or the `domain` parameter. + + Returns: + Dict[str, Any]: API response + """ + kwargs = {} + kwargs['customer'] = customer + + request = self.client.privileges().list(**kwargs) # type: ignore + return request.execute() # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + # ==================== Tokens / OAuth ==================== + + async def tokens_list( + self, + userKey: str + ) -> dict[str, Any]: + """Google Admin SDK Directory API: Returns the set of tokens specified user has issued to 3rd party applications. + + HTTP GET admin/directory/v1/users/{userKey}/tokens + + Args: + userKey (str, required): Identifies the user in the API request. The value can be the user's primary email address, alias email address, or unique user ID. + + Returns: + Dict[str, Any]: API response + """ + kwargs = {} + kwargs['userKey'] = userKey + + request = self.client.tokens().list(**kwargs) # type: ignore + return request.execute() # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + async def tokens_get( + self, + userKey: str, + clientId: str + ) -> dict[str, Any]: + """Google Admin SDK Directory API: Gets information about an access token issued by a user. + + HTTP GET admin/directory/v1/users/{userKey}/tokens/{clientId} + + Args: + userKey (str, required): Identifies the user in the API request. The value can be the user's primary email address, alias email address, or unique user ID. + clientId (str, required): The Client ID of the application the token is issued to. + + Returns: + Dict[str, Any]: API response + """ + kwargs = {} + kwargs['userKey'] = userKey + kwargs['clientId'] = clientId + + request = self.client.tokens().get(**kwargs) # type: ignore + return request.execute() # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + async def tokens_delete( + self, + userKey: str, + clientId: str + ) -> dict[str, Any]: + """Google Admin SDK Directory API: Deletes all access tokens issued by a user for an application. + + HTTP DELETE admin/directory/v1/users/{userKey}/tokens/{clientId} + + Args: + userKey (str, required): Identifies the user in the API request. The value can be the user's primary email address, alias email address, or unique user ID. + clientId (str, required): The Client ID of the application the token is issued to. + + Returns: + Dict[str, Any]: API response + """ + kwargs = {} + kwargs['userKey'] = userKey + kwargs['clientId'] = clientId + + request = self.client.tokens().delete(**kwargs) # type: ignore + return request.execute() # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + # ==================== Verification Codes ==================== + + async def verification_codes_list( + self, + userKey: str + ) -> dict[str, Any]: + """Google Admin SDK Directory API: Returns the current set of valid backup verification codes for the specified user. + + HTTP GET admin/directory/v1/users/{userKey}/verificationCodes + + Args: + userKey (str, required): Identifies the user in the API request. The value can be the user's primary email address, alias email address, or unique user ID. + + Returns: + Dict[str, Any]: API response + """ + kwargs = {} + kwargs['userKey'] = userKey + + request = self.client.verificationCodes().list(**kwargs) # type: ignore + return request.execute() # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + async def verification_codes_generate( + self, + userKey: str + ) -> dict[str, Any]: + """Google Admin SDK Directory API: Generates new backup verification codes for the user. + + HTTP POST admin/directory/v1/users/{userKey}/verificationCodes/generate + + Args: + userKey (str, required): Email or immutable ID of the user + + Returns: + Dict[str, Any]: API response + """ + kwargs = {} + kwargs['userKey'] = userKey + + # Handle request body if needed + if 'body' in kwargs: + body: Any = kwargs.pop('body') # type: ignore[reportUnknownVariableType, reportUnknownMemberType] + request = self.client.verificationCodes().generate(**kwargs, body=body) # type: ignore + else: + request = self.client.verificationCodes().generate(**kwargs) # type: ignore + return request.execute() # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + async def verification_codes_invalidate( + self, + userKey: str + ) -> dict[str, Any]: + """Google Admin SDK Directory API: Invalidates the current backup verification codes for the user. + + HTTP POST admin/directory/v1/users/{userKey}/verificationCodes/invalidate + + Args: + userKey (str, required): Email or immutable ID of the user + + Returns: + Dict[str, Any]: API response + """ + kwargs = {} + kwargs['userKey'] = userKey + + # Handle request body if needed + if 'body' in kwargs: + body: Any = kwargs.pop('body') # type: ignore[reportUnknownVariableType, reportUnknownMemberType] + request = self.client.verificationCodes().invalidate(**kwargs, body=body) # type: ignore + else: + request = self.client.verificationCodes().invalidate(**kwargs) # type: ignore + return request.execute() # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + # ==================== 2-Step Verification ==================== + + async def two_step_verification_turn_off( + self, + userKey: str + ) -> dict[str, Any]: + """Google Admin SDK Directory API: Turns off 2-Step Verification for user. + + HTTP POST admin/directory/v1/users/{userKey}/twoStepVerification/turnOff + + Args: + userKey (str, required): Identifies the user in the API request. The value can be the user's primary email address, alias email address, or unique user ID. + + Returns: + Dict[str, Any]: API response + """ + kwargs = {} + kwargs['userKey'] = userKey + + # Handle request body if needed + if 'body' in kwargs: + body: Any = kwargs.pop('body') # type: ignore[reportUnknownVariableType, reportUnknownMemberType] + request = self.client.twoStepVerification().turnOff(**kwargs, body=body) # type: ignore + else: + request = self.client.twoStepVerification().turnOff(**kwargs) # type: ignore + return request.execute() # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + # ==================== ASPs (Application-Specific Passwords) ==================== + + async def asps_list( + self, + userKey: str + ) -> dict[str, Any]: + """Google Admin SDK Directory API: Lists the ASPs issued by a user. + + HTTP GET admin/directory/v1/users/{userKey}/asps + + Args: + userKey (str, required): Identifies the user in the API request. The value can be the user's primary email address, alias email address, or unique user ID. + + Returns: + Dict[str, Any]: API response + """ + kwargs = {} + kwargs['userKey'] = userKey + + request = self.client.asps().list(**kwargs) # type: ignore + return request.execute() # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + async def asps_get( + self, + userKey: str, + codeId: int + ) -> dict[str, Any]: + """Google Admin SDK Directory API: Gets information about an ASP issued by a user. + + HTTP GET admin/directory/v1/users/{userKey}/asps/{codeId} + + Args: + userKey (str, required): Identifies the user in the API request. The value can be the user's primary email address, alias email address, or unique user ID. + codeId (int, required): The unique ID of the ASP. + + Returns: + Dict[str, Any]: API response + """ + kwargs = {} + kwargs['userKey'] = userKey + kwargs['codeId'] = codeId + + request = self.client.asps().get(**kwargs) # type: ignore + return request.execute() # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + async def asps_delete( + self, + userKey: str, + codeId: int + ) -> dict[str, Any]: + """Google Admin SDK Directory API: Deletes an ASP issued by a user. + + HTTP DELETE admin/directory/v1/users/{userKey}/asps/{codeId} + + Args: + userKey (str, required): Identifies the user in the API request. The value can be the user's primary email address, alias email address, or unique user ID. + codeId (int, required): The unique ID of the ASP to be deleted. + + Returns: + Dict[str, Any]: API response + """ + kwargs = {} + kwargs['userKey'] = userKey + kwargs['codeId'] = codeId + + request = self.client.asps().delete(**kwargs) # type: ignore + return request.execute() # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + # ==================== Customers ==================== + + async def customers_get( + self, + customerKey: str + ) -> dict[str, Any]: + """Google Admin SDK Directory API: Retrieves a customer. + + HTTP GET admin/directory/v1/customers/{customerKey} + + Args: + customerKey (str, required): Id of the customer to be retrieved + + Returns: + Dict[str, Any]: API response + """ + kwargs = {} + kwargs['customerKey'] = customerKey + + request = self.client.customers().get(**kwargs) # type: ignore + return request.execute() # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + async def customers_update( + self, + customerKey: str + ) -> dict[str, Any]: + """Google Admin SDK Directory API: Updates a customer. + + HTTP PUT admin/directory/v1/customers/{customerKey} + + Args: + customerKey (str, required): Id of the customer to be updated + + Returns: + Dict[str, Any]: API response + """ + kwargs = {} + kwargs['customerKey'] = customerKey + + # Handle request body if needed + if 'body' in kwargs: + body: Any = kwargs.pop('body') # type: ignore[reportUnknownVariableType, reportUnknownMemberType] + request = self.client.customers().update(**kwargs, body=body) # type: ignore + else: + request = self.client.customers().update(**kwargs) # type: ignore + return request.execute() # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + async def customers_patch( + self, + customerKey: str + ) -> dict[str, Any]: + """Google Admin SDK Directory API: Patches a customer. + + HTTP PATCH admin/directory/v1/customers/{customerKey} + + Args: + customerKey (str, required): Id of the customer to be updated + + Returns: + Dict[str, Any]: API response + """ + kwargs = {} + kwargs['customerKey'] = customerKey + + # Handle request body if needed + if 'body' in kwargs: + body: Any = kwargs.pop('body') # type: ignore[reportUnknownVariableType, reportUnknownMemberType] + request = self.client.customers().patch(**kwargs, body=body) # type: ignore + else: + request = self.client.customers().patch(**kwargs) # type: ignore + return request.execute() # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + async def get_client(self) -> object: + """Get the underlying Google API client.""" + return self.client diff --git a/backend/python/app/sources/external/greenhouse/example.py b/backend/python/app/sources/external/greenhouse/example.py new file mode 100644 index 000000000..52b995444 --- /dev/null +++ b/backend/python/app/sources/external/greenhouse/example.py @@ -0,0 +1,122 @@ +# ruff: noqa + +""" +Greenhouse Harvest API Usage Examples + +This example demonstrates how to use the Greenhouse DataSource to interact with +the Greenhouse Harvest API, covering: +- Authentication (API Key via HTTP Basic Auth) +- Initializing the Client and DataSource +- Listing Candidates +- Listing Jobs +- Listing Departments +- Listing Users + +Prerequisites: +1. Create a Greenhouse Harvest API key at: + https://app.greenhouse.io/configure/dev_center/credentials +2. Set GREENHOUSE_API_KEY environment variable +""" + +import asyncio +import json +import os + +from app.sources.client.greenhouse.greenhouse import ( + GreenhouseApiKeyConfig, + GreenhouseClient, + GreenhouseResponse, +) +from app.sources.external.greenhouse.greenhouse import GreenhouseDataSource + +# --- Configuration --- +API_KEY = os.getenv("GREENHOUSE_API_KEY") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: GreenhouseResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + # Handle list-type responses (Greenhouse returns arrays at top level) + for key in ("candidates", "applications", "jobs", "departments", + "offices", "users"): + if isinstance(data, dict) and key in data: + items = data[key] + print(f" Found {len(items)} {key}.") + if items: + print(f" Sample: {json.dumps(items[0], indent=2)[:400]}...") + return + # Greenhouse list endpoints return arrays directly + if isinstance(data, list): + print(f" Found {len(data)} items.") + if data: + print(f" Sample: {json.dumps(data[0], indent=2)[:400]}...") + return + # Generic response + print(f" Data: {json.dumps(data, indent=2)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + # 1. Initialize Client + print_section("Initializing Greenhouse Client") + + if not API_KEY: + print(" No valid authentication method found.") + print(" Please set the following environment variable:") + print(" - GREENHOUSE_API_KEY (Harvest API key)") + return + + print(" Using API Key authentication") + config = GreenhouseApiKeyConfig(api_key=API_KEY) + + client = GreenhouseClient.build_with_config(config) + data_source = GreenhouseDataSource(client) + print("Client initialized successfully.") + + try: + # 2. List Candidates + print_section("Candidates") + candidates_resp = await data_source.list_candidates(per_page=5) + print_result("List Candidates", candidates_resp) + + # 3. List Jobs + print_section("Jobs") + jobs_resp = await data_source.list_jobs(per_page=5) + print_result("List Jobs", jobs_resp) + + # 4. List Departments + print_section("Departments") + departments_resp = await data_source.list_departments(per_page=5) + print_result("List Departments", departments_resp) + + # 5. List Users + print_section("Users") + users_resp = await data_source.list_users(per_page=5) + print_result("List Users", users_resp) + + finally: + # Cleanup: Close the HTTP client session + print("\nClosing client connection...") + inner_client = client.get_client() + if hasattr(inner_client, "close"): + await inner_client.close() + + print("\n" + "=" * 80) + print(" All Greenhouse API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/greenhouse/greenhouse.py b/backend/python/app/sources/external/greenhouse/greenhouse.py new file mode 100644 index 000000000..ca89f75d3 --- /dev/null +++ b/backend/python/app/sources/external/greenhouse/greenhouse.py @@ -0,0 +1,982 @@ +""" +Greenhouse Harvest REST API DataSource - Auto-generated API wrapper + +Generated from Greenhouse Harvest API v1 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.greenhouse.greenhouse import ( + GreenhouseClient, + GreenhouseResponse, +) +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class GreenhouseDataSource: + """Greenhouse Harvest REST API DataSource + + Provides async wrapper methods for Greenhouse Harvest API operations: + - Candidates and Applications + - Jobs and Job Stages + - Offers + - Departments and Offices + - Users + - Scorecards, Scheduled Interviews + - Sources, Rejection Reasons, Custom Fields + - Activity Feed + + The base URL is https://harvest.greenhouse.io/v1. + + All methods return GreenhouseResponse objects. + """ + + def __init__(self, client: GreenhouseClient) -> None: + """Initialize with GreenhouseClient. + + Args: + client: GreenhouseClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'GreenhouseDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> GreenhouseClient: + """Return the underlying GreenhouseClient.""" + return self._client + + async def list_candidates( + self, + *, + per_page: int | None = None, + page: int | None = None, + created_after: str | None = None, + created_before: str | None = None, + updated_after: str | None = None, + updated_before: str | None = None, + job_id: str | None = None + ) -> GreenhouseResponse: + """List all candidates + + Args: + per_page: Number of results per page (max 500) + page: Page number to retrieve + created_after: Return candidates created after this date (ISO 8601) + created_before: Return candidates created before this date (ISO 8601) + updated_after: Return candidates updated after this date (ISO 8601) + updated_before: Return candidates updated before this date (ISO 8601) + job_id: Filter candidates by job ID + + Returns: + GreenhouseResponse with operation result + """ + query_params: dict[str, Any] = {} + if per_page is not None: + query_params['per_page'] = str(per_page) + if page is not None: + query_params['page'] = str(page) + if created_after is not None: + query_params['created_after'] = created_after + if created_before is not None: + query_params['created_before'] = created_before + if updated_after is not None: + query_params['updated_after'] = updated_after + if updated_before is not None: + query_params['updated_before'] = updated_before + if job_id is not None: + query_params['job_id'] = job_id + + url = self.base_url + "/candidates" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return GreenhouseResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_candidates" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return GreenhouseResponse(success=False, error=str(e), message="Failed to execute list_candidates") + + async def get_candidate( + self, + candidate_id: str + ) -> GreenhouseResponse: + """Get a single candidate by ID + + Args: + candidate_id: The candidate ID + + Returns: + GreenhouseResponse with operation result + """ + url = self.base_url + "/candidates/{candidate_id}".format(candidate_id=candidate_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return GreenhouseResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_candidate" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return GreenhouseResponse(success=False, error=str(e), message="Failed to execute get_candidate") + + async def list_applications( + self, + *, + per_page: int | None = None, + page: int | None = None, + created_after: str | None = None, + created_before: str | None = None, + last_activity_after: str | None = None, + job_id: str | None = None, + status: str | None = None + ) -> GreenhouseResponse: + """List all applications + + Args: + per_page: Number of results per page (max 500) + page: Page number to retrieve + created_after: Return applications created after this date (ISO 8601) + created_before: Return applications created before this date (ISO 8601) + last_activity_after: Return applications with activity after this date (ISO 8601) + job_id: Filter applications by job ID + status: Filter by application status (active, converted, hired, rejected) + + Returns: + GreenhouseResponse with operation result + """ + query_params: dict[str, Any] = {} + if per_page is not None: + query_params['per_page'] = str(per_page) + if page is not None: + query_params['page'] = str(page) + if created_after is not None: + query_params['created_after'] = created_after + if created_before is not None: + query_params['created_before'] = created_before + if last_activity_after is not None: + query_params['last_activity_after'] = last_activity_after + if job_id is not None: + query_params['job_id'] = job_id + if status is not None: + query_params['status'] = status + + url = self.base_url + "/applications" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return GreenhouseResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_applications" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return GreenhouseResponse(success=False, error=str(e), message="Failed to execute list_applications") + + async def get_application( + self, + application_id: str + ) -> GreenhouseResponse: + """Get a single application by ID + + Args: + application_id: The application ID + + Returns: + GreenhouseResponse with operation result + """ + url = self.base_url + "/applications/{application_id}".format(application_id=application_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return GreenhouseResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_application" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return GreenhouseResponse(success=False, error=str(e), message="Failed to execute get_application") + + async def list_jobs( + self, + *, + per_page: int | None = None, + page: int | None = None, + status: str | None = None, + department_id: str | None = None, + office_id: str | None = None, + created_after: str | None = None, + created_before: str | None = None, + updated_after: str | None = None, + updated_before: str | None = None + ) -> GreenhouseResponse: + """List all jobs + + Args: + per_page: Number of results per page (max 500) + page: Page number to retrieve + status: Filter by job status (open, closed, draft) + department_id: Filter jobs by department ID + office_id: Filter jobs by office ID + created_after: Return jobs created after this date (ISO 8601) + created_before: Return jobs created before this date (ISO 8601) + updated_after: Return jobs updated after this date (ISO 8601) + updated_before: Return jobs updated before this date (ISO 8601) + + Returns: + GreenhouseResponse with operation result + """ + query_params: dict[str, Any] = {} + if per_page is not None: + query_params['per_page'] = str(per_page) + if page is not None: + query_params['page'] = str(page) + if status is not None: + query_params['status'] = status + if department_id is not None: + query_params['department_id'] = department_id + if office_id is not None: + query_params['office_id'] = office_id + if created_after is not None: + query_params['created_after'] = created_after + if created_before is not None: + query_params['created_before'] = created_before + if updated_after is not None: + query_params['updated_after'] = updated_after + if updated_before is not None: + query_params['updated_before'] = updated_before + + url = self.base_url + "/jobs" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return GreenhouseResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_jobs" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return GreenhouseResponse(success=False, error=str(e), message="Failed to execute list_jobs") + + async def get_job( + self, + job_id: str + ) -> GreenhouseResponse: + """Get a single job by ID + + Args: + job_id: The job ID + + Returns: + GreenhouseResponse with operation result + """ + url = self.base_url + "/jobs/{job_id}".format(job_id=job_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return GreenhouseResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_job" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return GreenhouseResponse(success=False, error=str(e), message="Failed to execute get_job") + + async def list_job_stages( + self, + *, + per_page: int | None = None, + page: int | None = None, + created_after: str | None = None, + updated_after: str | None = None + ) -> GreenhouseResponse: + """List all job stages + + Args: + per_page: Number of results per page (max 500) + page: Page number to retrieve + created_after: Return job stages created after this date (ISO 8601) + updated_after: Return job stages updated after this date (ISO 8601) + + Returns: + GreenhouseResponse with operation result + """ + query_params: dict[str, Any] = {} + if per_page is not None: + query_params['per_page'] = str(per_page) + if page is not None: + query_params['page'] = str(page) + if created_after is not None: + query_params['created_after'] = created_after + if updated_after is not None: + query_params['updated_after'] = updated_after + + url = self.base_url + "/job_stages" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return GreenhouseResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_job_stages" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return GreenhouseResponse(success=False, error=str(e), message="Failed to execute list_job_stages") + + async def get_job_stage( + self, + job_stage_id: str + ) -> GreenhouseResponse: + """Get a single job stage by ID + + Args: + job_stage_id: The job stage ID + + Returns: + GreenhouseResponse with operation result + """ + url = self.base_url + "/job_stages/{job_stage_id}".format(job_stage_id=job_stage_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return GreenhouseResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_job_stage" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return GreenhouseResponse(success=False, error=str(e), message="Failed to execute get_job_stage") + + async def list_offers( + self, + *, + per_page: int | None = None, + page: int | None = None, + created_after: str | None = None, + created_before: str | None = None, + updated_after: str | None = None, + updated_before: str | None = None, + status: str | None = None + ) -> GreenhouseResponse: + """List all offers + + Args: + per_page: Number of results per page (max 500) + page: Page number to retrieve + created_after: Return offers created after this date (ISO 8601) + created_before: Return offers created before this date (ISO 8601) + updated_after: Return offers updated after this date (ISO 8601) + updated_before: Return offers updated before this date (ISO 8601) + status: Filter by offer status (unresolved, accepted, rejected, deprecated) + + Returns: + GreenhouseResponse with operation result + """ + query_params: dict[str, Any] = {} + if per_page is not None: + query_params['per_page'] = str(per_page) + if page is not None: + query_params['page'] = str(page) + if created_after is not None: + query_params['created_after'] = created_after + if created_before is not None: + query_params['created_before'] = created_before + if updated_after is not None: + query_params['updated_after'] = updated_after + if updated_before is not None: + query_params['updated_before'] = updated_before + if status is not None: + query_params['status'] = status + + url = self.base_url + "/offers" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return GreenhouseResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_offers" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return GreenhouseResponse(success=False, error=str(e), message="Failed to execute list_offers") + + async def get_offer( + self, + offer_id: str + ) -> GreenhouseResponse: + """Get a single offer by ID + + Args: + offer_id: The offer ID + + Returns: + GreenhouseResponse with operation result + """ + url = self.base_url + "/offers/{offer_id}".format(offer_id=offer_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return GreenhouseResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_offer" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return GreenhouseResponse(success=False, error=str(e), message="Failed to execute get_offer") + + async def list_departments( + self, + *, + per_page: int | None = None, + page: int | None = None + ) -> GreenhouseResponse: + """List all departments + + Args: + per_page: Number of results per page (max 500) + page: Page number to retrieve + + Returns: + GreenhouseResponse with operation result + """ + query_params: dict[str, Any] = {} + if per_page is not None: + query_params['per_page'] = str(per_page) + if page is not None: + query_params['page'] = str(page) + + url = self.base_url + "/departments" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return GreenhouseResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_departments" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return GreenhouseResponse(success=False, error=str(e), message="Failed to execute list_departments") + + async def get_department( + self, + department_id: str + ) -> GreenhouseResponse: + """Get a single department by ID + + Args: + department_id: The department ID + + Returns: + GreenhouseResponse with operation result + """ + url = self.base_url + "/departments/{department_id}".format(department_id=department_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return GreenhouseResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_department" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return GreenhouseResponse(success=False, error=str(e), message="Failed to execute get_department") + + async def list_offices( + self, + *, + per_page: int | None = None, + page: int | None = None + ) -> GreenhouseResponse: + """List all offices + + Args: + per_page: Number of results per page (max 500) + page: Page number to retrieve + + Returns: + GreenhouseResponse with operation result + """ + query_params: dict[str, Any] = {} + if per_page is not None: + query_params['per_page'] = str(per_page) + if page is not None: + query_params['page'] = str(page) + + url = self.base_url + "/offices" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return GreenhouseResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_offices" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return GreenhouseResponse(success=False, error=str(e), message="Failed to execute list_offices") + + async def get_office( + self, + office_id: str + ) -> GreenhouseResponse: + """Get a single office by ID + + Args: + office_id: The office ID + + Returns: + GreenhouseResponse with operation result + """ + url = self.base_url + "/offices/{office_id}".format(office_id=office_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return GreenhouseResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_office" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return GreenhouseResponse(success=False, error=str(e), message="Failed to execute get_office") + + async def list_users( + self, + *, + per_page: int | None = None, + page: int | None = None, + created_after: str | None = None, + updated_after: str | None = None, + email: str | None = None + ) -> GreenhouseResponse: + """List all users + + Args: + per_page: Number of results per page (max 500) + page: Page number to retrieve + created_after: Return users created after this date (ISO 8601) + updated_after: Return users updated after this date (ISO 8601) + email: Filter users by email address + + Returns: + GreenhouseResponse with operation result + """ + query_params: dict[str, Any] = {} + if per_page is not None: + query_params['per_page'] = str(per_page) + if page is not None: + query_params['page'] = str(page) + if created_after is not None: + query_params['created_after'] = created_after + if updated_after is not None: + query_params['updated_after'] = updated_after + if email is not None: + query_params['email'] = email + + url = self.base_url + "/users" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return GreenhouseResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_users" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return GreenhouseResponse(success=False, error=str(e), message="Failed to execute list_users") + + async def get_user( + self, + user_id: str + ) -> GreenhouseResponse: + """Get a single user by ID + + Args: + user_id: The user ID + + Returns: + GreenhouseResponse with operation result + """ + url = self.base_url + "/users/{user_id}".format(user_id=user_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return GreenhouseResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_user" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return GreenhouseResponse(success=False, error=str(e), message="Failed to execute get_user") + + async def list_scorecards( + self, + *, + per_page: int | None = None, + page: int | None = None, + created_after: str | None = None, + updated_after: str | None = None, + application_id: str | None = None + ) -> GreenhouseResponse: + """List all scorecards + + Args: + per_page: Number of results per page (max 500) + page: Page number to retrieve + created_after: Return scorecards created after this date (ISO 8601) + updated_after: Return scorecards updated after this date (ISO 8601) + application_id: Filter scorecards by application ID + + Returns: + GreenhouseResponse with operation result + """ + query_params: dict[str, Any] = {} + if per_page is not None: + query_params['per_page'] = str(per_page) + if page is not None: + query_params['page'] = str(page) + if created_after is not None: + query_params['created_after'] = created_after + if updated_after is not None: + query_params['updated_after'] = updated_after + if application_id is not None: + query_params['application_id'] = application_id + + url = self.base_url + "/scorecards" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return GreenhouseResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_scorecards" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return GreenhouseResponse(success=False, error=str(e), message="Failed to execute list_scorecards") + + async def list_scheduled_interviews( + self, + *, + per_page: int | None = None, + page: int | None = None, + created_after: str | None = None, + updated_after: str | None = None, + starts_after: str | None = None, + starts_before: str | None = None + ) -> GreenhouseResponse: + """List all scheduled interviews + + Args: + per_page: Number of results per page (max 500) + page: Page number to retrieve + created_after: Return interviews created after this date (ISO 8601) + updated_after: Return interviews updated after this date (ISO 8601) + starts_after: Return interviews starting after this date (ISO 8601) + starts_before: Return interviews starting before this date (ISO 8601) + + Returns: + GreenhouseResponse with operation result + """ + query_params: dict[str, Any] = {} + if per_page is not None: + query_params['per_page'] = str(per_page) + if page is not None: + query_params['page'] = str(page) + if created_after is not None: + query_params['created_after'] = created_after + if updated_after is not None: + query_params['updated_after'] = updated_after + if starts_after is not None: + query_params['starts_after'] = starts_after + if starts_before is not None: + query_params['starts_before'] = starts_before + + url = self.base_url + "/scheduled_interviews" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return GreenhouseResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_scheduled_interviews" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return GreenhouseResponse(success=False, error=str(e), message="Failed to execute list_scheduled_interviews") + + async def list_sources( + self, + *, + per_page: int | None = None, + page: int | None = None + ) -> GreenhouseResponse: + """List all sources + + Args: + per_page: Number of results per page (max 500) + page: Page number to retrieve + + Returns: + GreenhouseResponse with operation result + """ + query_params: dict[str, Any] = {} + if per_page is not None: + query_params['per_page'] = str(per_page) + if page is not None: + query_params['page'] = str(page) + + url = self.base_url + "/sources" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return GreenhouseResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_sources" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return GreenhouseResponse(success=False, error=str(e), message="Failed to execute list_sources") + + async def list_rejection_reasons( + self, + *, + per_page: int | None = None, + page: int | None = None + ) -> GreenhouseResponse: + """List all rejection reasons + + Args: + per_page: Number of results per page (max 500) + page: Page number to retrieve + + Returns: + GreenhouseResponse with operation result + """ + query_params: dict[str, Any] = {} + if per_page is not None: + query_params['per_page'] = str(per_page) + if page is not None: + query_params['page'] = str(page) + + url = self.base_url + "/rejection_reasons" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return GreenhouseResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_rejection_reasons" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return GreenhouseResponse(success=False, error=str(e), message="Failed to execute list_rejection_reasons") + + async def list_custom_fields( + self, + *, + field_type: str | None = None + ) -> GreenhouseResponse: + """List all custom fields + + Args: + field_type: Filter by field type (candidate, application, offer, job, etc.) + + Returns: + GreenhouseResponse with operation result + """ + query_params: dict[str, Any] = {} + if field_type is not None: + query_params['field_type'] = field_type + + url = self.base_url + "/custom_fields" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return GreenhouseResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_custom_fields" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return GreenhouseResponse(success=False, error=str(e), message="Failed to execute list_custom_fields") + + async def get_activity_feed( + self, + candidate_id: str + ) -> GreenhouseResponse: + """Get the activity feed for a candidate + + Args: + candidate_id: The candidate ID + + Returns: + GreenhouseResponse with operation result + """ + url = self.base_url + "/candidates/{candidate_id}/activity_feed".format(candidate_id=candidate_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return GreenhouseResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_activity_feed" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return GreenhouseResponse(success=False, error=str(e), message="Failed to execute get_activity_feed") diff --git a/backend/python/app/sources/external/guru/code_generator.py b/backend/python/app/sources/external/guru/code_generator.py new file mode 100644 index 000000000..13673203f --- /dev/null +++ b/backend/python/app/sources/external/guru/code_generator.py @@ -0,0 +1,227 @@ +# ruff: noqa +""" +Guru DataSource Code Generator + +Defines Guru API endpoint specifications and generates the DataSource +wrapper class (guru.py) from them. + +Endpoints: + /cards, /cards/{id}, /cards/{id}/extended, /boards, /boards/{id}, + /boards/{id}/items, /collections, /collections/{id}, /groups, + /groups/{id}, /members, /search/cardmgr (POST), /teams/teaminfo, + /analytics/card +""" + +from __future__ import annotations + +ENDPOINTS = [ + # Cards + {"method": "GET", "path": "/cards", "name": "list_cards", "section": "Cards", + "doc": "List all cards", "paginated": True}, + {"method": "GET", "path": "/cards/{card_id}", "name": "get_card", "section": "Cards", + "doc": "Get a specific card by ID", "path_params": ["card_id"]}, + {"method": "GET", "path": "/cards/{card_id}/extended", "name": "get_card_extended", "section": "Cards", + "doc": "Get extended card details by ID", "path_params": ["card_id"]}, + # Boards + {"method": "GET", "path": "/boards", "name": "list_boards", "section": "Boards", + "doc": "List all boards"}, + {"method": "GET", "path": "/boards/{board_id}", "name": "get_board", "section": "Boards", + "doc": "Get a specific board by ID", "path_params": ["board_id"]}, + {"method": "GET", "path": "/boards/{board_id}/items", "name": "get_board_items", "section": "Boards", + "doc": "Get items on a specific board", "path_params": ["board_id"]}, + # Collections + {"method": "GET", "path": "/collections", "name": "list_collections", "section": "Collections", + "doc": "List all collections"}, + {"method": "GET", "path": "/collections/{collection_id}", "name": "get_collection", "section": "Collections", + "doc": "Get a specific collection by ID", "path_params": ["collection_id"]}, + # Groups + {"method": "GET", "path": "/groups", "name": "list_groups", "section": "Groups", + "doc": "List all groups"}, + {"method": "GET", "path": "/groups/{group_id}", "name": "get_group", "section": "Groups", + "doc": "Get a specific group by ID", "path_params": ["group_id"]}, + # Members + {"method": "GET", "path": "/members", "name": "list_members", "section": "Members", + "doc": "List all members"}, + # Search + {"method": "POST", "path": "/search/cardmgr", "name": "search_cards", "section": "Search", + "doc": "Search cards using the card manager search", + "body_params": [("search_terms", "searchTerms", "str", "Search query string")]}, + # Team Info + {"method": "GET", "path": "/teams/teaminfo", "name": "get_team_info", "section": "Team Info", + "doc": "Get team information"}, + # Analytics + {"method": "GET", "path": "/analytics/card", "name": "get_card_analytics", "section": "Analytics", + "doc": "Get card analytics"}, +] + + +def _gen_method(ep: dict) -> str: + """Generate a single async method from an endpoint spec.""" + name = ep["name"] + method = ep["method"] + path = ep["path"] + doc = ep["doc"] + path_params = ep.get("path_params", []) + paginated = ep.get("paginated", False) + body_params = ep.get("body_params", []) + + sig_parts = ["self"] + for p in path_params: + sig_parts.append(f"{p}: str") + for bp in body_params: + sig_parts.append(f"{bp[0]}: {bp[2]}") + if paginated: + sig_parts.append("*") + sig_parts.append("page: int | None = None") + sig_parts.append("per_page: int | None = None") + + sig = ",\n ".join(sig_parts) + + doc_args = "" + if path_params or paginated or body_params: + doc_args = "\n Args:\n" + for p in path_params: + doc_args += f" {p}: The {p.replace('_', ' ')}\n" + for bp in body_params: + doc_args += f" {bp[0]}: {bp[3]}\n" + if paginated: + doc_args += " page: Page number for pagination\n" + doc_args += " per_page: Number of items per page\n" + + query_block = "" + if paginated: + query_block = """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) +""" + + if path_params: + fmt_args = ", ".join(f"{p}={p}" for p in path_params) + url_line = f' url = self.base_url + "{path}".format({fmt_args})' + else: + url_line = f' url = self.base_url + "{path}"' + + body_block = "" + if body_params: + lines = ["\n body: dict[str, Any] = {"] + for bp in body_params: + lines.append(f' "{bp[1]}": {bp[0]},') + lines.append(" }") + body_block = "\n".join(lines) + + req_extra = "" + if paginated: + req_extra += "\n query=query_params," + if body_params: + req_extra += "\n body=body," + + return f''' + async def {name}( + {sig} + ) -> GuruResponse: + """{doc} + + HTTP {method} {path} +{doc_args} + Returns: + GuruResponse with operation result + """ +{query_block} +{url_line} +{body_block} + + try: + request = HTTPRequest( + method="{method}", + url=url, + headers={{"Content-Type": "application/json"}},{req_extra} + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return GuruResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed {name}" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {{response.status}}" + ) + except Exception as e: + return GuruResponse(success=False, error=str(e), message="Failed to execute {name}") +''' + + +def generate_datasource() -> str: + """Generate the full Guru DataSource module code.""" + header = '''# ruff: noqa +""" +Guru REST API DataSource - Auto-generated API wrapper + +Generated from Guru REST API v1 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.guru.guru import GuruClient, GuruResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class GuruDataSource: + """Guru REST API DataSource + + Provides async wrapper methods for Guru REST API operations: + - Cards management + - Boards management + - Collections management + - Groups management + - Members management + - Search + - Team info + - Analytics + + All methods return GuruResponse objects. + """ + + def __init__(self, client: GuruClient) -> None: + """Initialize with GuruClient. + + Args: + client: GuruClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'GuruDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> GuruClient: + """Return the underlying GuruClient.""" + return self._client +''' + + methods = [] + current_section = None + for ep in ENDPOINTS: + section = ep.get("section", "") + if section and section != current_section: + current_section = section + methods.append(f"\n # {'-' * 71}\n # {section}\n # {'-' * 71}") + methods.append(_gen_method(ep)) + + return header + "\n".join(methods) + "\n" + + +if __name__ == "__main__": + print(generate_datasource()) diff --git a/backend/python/app/sources/external/guru/example.py b/backend/python/app/sources/external/guru/example.py new file mode 100644 index 000000000..e367eb3d7 --- /dev/null +++ b/backend/python/app/sources/external/guru/example.py @@ -0,0 +1,183 @@ +# ruff: noqa + +""" +Guru API Usage Examples + +This example demonstrates how to use the Guru DataSource to interact with +the Guru API, covering: +- Authentication (Basic Auth or OAuth2) +- Initializing the Client and DataSource +- Listing Cards, Boards, Collections, Groups +- Searching cards +- Getting team info and analytics + +Prerequisites: +For Basic Auth: +1. Get your Guru username (email) and API token +2. Set GURU_USERNAME and GURU_API_TOKEN environment variables + +For OAuth2: +1. Register an OAuth app with Guru +2. Set GURU_CLIENT_ID and GURU_CLIENT_SECRET environment variables +""" + +import asyncio +import json +import os + +from app.sources.client.guru.guru import ( + GuruClient, + GuruBasicAuthConfig, + GuruOAuthConfig, + GuruResponse, +) +from app.sources.external.guru.guru import GuruDataSource +from app.sources.external.utils.oauth import perform_oauth_flow + +# --- Configuration --- +# Basic Auth credentials +GURU_USERNAME = os.getenv("GURU_USERNAME") +GURU_API_TOKEN = os.getenv("GURU_API_TOKEN") + +# OAuth2 credentials +CLIENT_ID = os.getenv("GURU_CLIENT_ID") +CLIENT_SECRET = os.getenv("GURU_CLIENT_SECRET") + +# OAuth redirect URI +REDIRECT_URI = os.getenv("GURU_REDIRECT_URI", "http://localhost:8080/callback") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: GuruResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + for key in ("cards", "boards", "collections", "groups", "members", + "results"): + if isinstance(data, dict) and key in data: + items = data[key] + print(f" Found {len(items)} {key}.") + if items: + print(f" Sample: {json.dumps(items[0], indent=2)[:400]}...") + return + if isinstance(data, list): + print(f" Found {len(data)} items.") + if data: + print(f" Sample: {json.dumps(data[0], indent=2)[:400]}...") + return + print(f" Data: {json.dumps(data, indent=2)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + # 1. Initialize Client + print_section("Initializing Guru Client") + + config = None + + # Priority 1: OAuth2 + if CLIENT_ID and CLIENT_SECRET: + print(" Using OAuth2 authentication") + try: + token_response = perform_oauth_flow( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + auth_endpoint="https://api.getguru.com/oauth/authorize", + token_endpoint="https://api.getguru.com/oauth/token", + redirect_uri=REDIRECT_URI, + scopes=[], + scope_delimiter=" ", + auth_method="header", + ) + access_token = token_response.get("access_token") + if not access_token: + raise Exception("No access_token found in OAuth response") + + config = GuruOAuthConfig( + access_token=access_token, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + ) + print(" OAuth authentication successful") + except Exception as e: + print(f" OAuth flow failed: {e}") + print(" Falling back to other authentication methods...") + + # Priority 2: Basic Auth + if config is None and GURU_USERNAME and GURU_API_TOKEN: + print(" Using Basic Auth authentication") + config = GuruBasicAuthConfig( + username=GURU_USERNAME, + api_token=GURU_API_TOKEN, + ) + + if config is None: + print(" No valid authentication method found.") + print(" Please set one of the following:") + print(" - GURU_CLIENT_ID and GURU_CLIENT_SECRET (for OAuth2)") + print(" - GURU_USERNAME and GURU_API_TOKEN (for Basic Auth)") + return + + client = GuruClient.build_with_config(config) + data_source = GuruDataSource(client) + print("Client initialized successfully.") + + try: + # 2. Get Team Info + print_section("Team Info") + team_resp = await data_source.get_team_info() + print_result("Get Team Info", team_resp) + + # 3. List Cards + print_section("Cards") + cards_resp = await data_source.list_cards(page=1, per_page=10) + print_result("List Cards", cards_resp) + + # 4. List Boards + print_section("Boards") + boards_resp = await data_source.list_boards() + print_result("List Boards", boards_resp) + + # 5. List Collections + print_section("Collections") + colls_resp = await data_source.list_collections() + print_result("List Collections", colls_resp) + + # 6. List Groups + print_section("Groups") + groups_resp = await data_source.list_groups() + print_result("List Groups", groups_resp) + + # 7. Search Cards + print_section("Search Cards") + search_resp = await data_source.search_cards(search_terms="getting started") + print_result("Search Cards", search_resp) + + # 8. Card Analytics + print_section("Card Analytics") + analytics_resp = await data_source.get_card_analytics() + print_result("Card Analytics", analytics_resp) + + finally: + print("\nClosing client connection...") + inner_client = client.get_client() + if hasattr(inner_client, "close"): + await inner_client.close() + + print("\n" + "=" * 80) + print(" All Guru API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/guru/guru.py b/backend/python/app/sources/external/guru/guru.py new file mode 100644 index 000000000..8f5d2dd32 --- /dev/null +++ b/backend/python/app/sources/external/guru/guru.py @@ -0,0 +1,527 @@ +# ruff: noqa +""" +Guru REST API DataSource - Auto-generated API wrapper + +Generated from Guru REST API v1 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.guru.guru import GuruClient, GuruResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class GuruDataSource: + """Guru REST API DataSource + + Provides async wrapper methods for Guru REST API operations: + - Cards management + - Boards management + - Collections management + - Groups management + - Members management + - Search + - Team info + - Analytics + + All methods return GuruResponse objects. + """ + + def __init__(self, client: GuruClient) -> None: + """Initialize with GuruClient. + + Args: + client: GuruClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'GuruDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> GuruClient: + """Return the underlying GuruClient.""" + return self._client + + # ----------------------------------------------------------------------- + # Cards + # ----------------------------------------------------------------------- + + async def list_cards( + self, + *, + page: int | None = None, + per_page: int | None = None + ) -> GuruResponse: + """List all cards + + HTTP GET /cards + + Args: + page: Page number for pagination + per_page: Number of items per page + + Returns: + GuruResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/cards" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return GuruResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_cards" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return GuruResponse(success=False, error=str(e), message="Failed to execute list_cards") + + async def get_card( + self, + card_id: str + ) -> GuruResponse: + """Get a specific card by ID + + HTTP GET /cards/{card_id} + + Args: + card_id: The card ID + + Returns: + GuruResponse with operation result + """ + url = self.base_url + "/cards/{card_id}".format(card_id=card_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return GuruResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_card" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return GuruResponse(success=False, error=str(e), message="Failed to execute get_card") + + async def get_card_extended( + self, + card_id: str + ) -> GuruResponse: + """Get extended card details by ID + + HTTP GET /cards/{card_id}/extended + + Args: + card_id: The card ID + + Returns: + GuruResponse with operation result + """ + url = self.base_url + "/cards/{card_id}/extended".format(card_id=card_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return GuruResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_card_extended" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return GuruResponse(success=False, error=str(e), message="Failed to execute get_card_extended") + + # ----------------------------------------------------------------------- + # Boards + # ----------------------------------------------------------------------- + + async def list_boards( + self + ) -> GuruResponse: + """List all boards + + HTTP GET /boards + + Returns: + GuruResponse with operation result + """ + url = self.base_url + "/boards" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return GuruResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_boards" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return GuruResponse(success=False, error=str(e), message="Failed to execute list_boards") + + async def get_board( + self, + board_id: str + ) -> GuruResponse: + """Get a specific board by ID + + HTTP GET /boards/{board_id} + + Args: + board_id: The board ID + + Returns: + GuruResponse with operation result + """ + url = self.base_url + "/boards/{board_id}".format(board_id=board_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return GuruResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_board" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return GuruResponse(success=False, error=str(e), message="Failed to execute get_board") + + async def get_board_items( + self, + board_id: str + ) -> GuruResponse: + """Get items on a specific board + + HTTP GET /boards/{board_id}/items + + Args: + board_id: The board ID + + Returns: + GuruResponse with operation result + """ + url = self.base_url + "/boards/{board_id}/items".format(board_id=board_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return GuruResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_board_items" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return GuruResponse(success=False, error=str(e), message="Failed to execute get_board_items") + + # ----------------------------------------------------------------------- + # Collections + # ----------------------------------------------------------------------- + + async def list_collections( + self + ) -> GuruResponse: + """List all collections + + HTTP GET /collections + + Returns: + GuruResponse with operation result + """ + url = self.base_url + "/collections" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return GuruResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_collections" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return GuruResponse(success=False, error=str(e), message="Failed to execute list_collections") + + async def get_collection( + self, + collection_id: str + ) -> GuruResponse: + """Get a specific collection by ID + + HTTP GET /collections/{collection_id} + + Args: + collection_id: The collection ID + + Returns: + GuruResponse with operation result + """ + url = self.base_url + "/collections/{collection_id}".format(collection_id=collection_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return GuruResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_collection" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return GuruResponse(success=False, error=str(e), message="Failed to execute get_collection") + + # ----------------------------------------------------------------------- + # Groups + # ----------------------------------------------------------------------- + + async def list_groups( + self + ) -> GuruResponse: + """List all groups + + HTTP GET /groups + + Returns: + GuruResponse with operation result + """ + url = self.base_url + "/groups" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return GuruResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_groups" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return GuruResponse(success=False, error=str(e), message="Failed to execute list_groups") + + async def get_group( + self, + group_id: str + ) -> GuruResponse: + """Get a specific group by ID + + HTTP GET /groups/{group_id} + + Args: + group_id: The group ID + + Returns: + GuruResponse with operation result + """ + url = self.base_url + "/groups/{group_id}".format(group_id=group_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return GuruResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_group" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return GuruResponse(success=False, error=str(e), message="Failed to execute get_group") + + # ----------------------------------------------------------------------- + # Members + # ----------------------------------------------------------------------- + + async def list_members( + self + ) -> GuruResponse: + """List all members + + HTTP GET /members + + Returns: + GuruResponse with operation result + """ + url = self.base_url + "/members" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return GuruResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_members" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return GuruResponse(success=False, error=str(e), message="Failed to execute list_members") + + # ----------------------------------------------------------------------- + # Search + # ----------------------------------------------------------------------- + + async def search_cards( + self, + search_terms: str + ) -> GuruResponse: + """Search cards using the card manager search + + HTTP POST /search/cardmgr + + Args: + search_terms: Search query string + + Returns: + GuruResponse with operation result + """ + url = self.base_url + "/search/cardmgr" + + body: dict[str, Any] = { + "searchTerms": search_terms, + } + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return GuruResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed search_cards" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return GuruResponse(success=False, error=str(e), message="Failed to execute search_cards") + + # ----------------------------------------------------------------------- + # Team Info + # ----------------------------------------------------------------------- + + async def get_team_info( + self + ) -> GuruResponse: + """Get team information + + HTTP GET /teams/teaminfo + + Returns: + GuruResponse with operation result + """ + url = self.base_url + "/teams/teaminfo" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return GuruResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_team_info" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return GuruResponse(success=False, error=str(e), message="Failed to execute get_team_info") + + # ----------------------------------------------------------------------- + # Analytics + # ----------------------------------------------------------------------- + + async def get_card_analytics( + self + ) -> GuruResponse: + """Get card analytics + + HTTP GET /analytics/card + + Returns: + GuruResponse with operation result + """ + url = self.base_url + "/analytics/card" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return GuruResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_card_analytics" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return GuruResponse(success=False, error=str(e), message="Failed to execute get_card_analytics") diff --git a/backend/python/app/sources/external/guru/run_generator.py b/backend/python/app/sources/external/guru/run_generator.py new file mode 100644 index 000000000..583ed364d --- /dev/null +++ b/backend/python/app/sources/external/guru/run_generator.py @@ -0,0 +1,24 @@ +"""Runner script to generate the Guru DataSource wrapper. + +Execute this script to regenerate guru.py from the endpoint definitions +in code_generator.py. + +Usage: + python -m app.sources.external.guru.run_generator +""" + +from app.sources.external.guru.code_generator import generate_datasource + + +def main() -> None: + """Generate the Guru DataSource file.""" + code = generate_datasource() + output_path = __file__.replace("run_generator.py", "guru.py") + with open(output_path, "w") as f: + f.write(code) + print(f"Generated Guru DataSource -> {output_path}") + print(f" Total characters: {len(code)}") + + +if __name__ == "__main__": + main() diff --git a/backend/python/app/sources/external/harvest/example.py b/backend/python/app/sources/external/harvest/example.py new file mode 100644 index 000000000..b9d7babbe --- /dev/null +++ b/backend/python/app/sources/external/harvest/example.py @@ -0,0 +1,213 @@ +# ruff: noqa + +""" +Harvest API Usage Examples + +This example demonstrates how to use the Harvest DataSource to interact with +the Harvest API v2, covering: +- Authentication (OAuth2, Personal Access Token) +- Initializing the Client and DataSource +- Fetching Current User and Company Info +- Listing Projects, Time Entries, Clients, Tasks +- Listing Invoices, Expenses, Roles + +Prerequisites: +For OAuth2: +1. Create a Harvest OAuth2 app at https://id.getharvest.com/oauth2/access_tokens/new +2. Set HARVEST_CLIENT_ID and HARVEST_CLIENT_SECRET environment variables +3. Set HARVEST_ACCOUNT_ID environment variable +4. The OAuth flow will automatically open a browser for authorization + +For Personal Access Token: +1. Go to https://id.getharvest.com/developers +2. Create a new personal access token +3. Set HARVEST_ACCESS_TOKEN and HARVEST_ACCOUNT_ID environment variables +""" + +import asyncio +import json +import os + +from app.sources.client.harvest.harvest import ( + HarvestClient, + HarvestOAuthConfig, + HarvestTokenConfig, + HarvestResponse, +) +from app.sources.external.harvest.harvest import HarvestDataSource +from app.sources.external.utils.oauth import perform_oauth_flow + +# --- Configuration --- +# OAuth2 credentials (highest priority) +CLIENT_ID = os.getenv("HARVEST_CLIENT_ID") +CLIENT_SECRET = os.getenv("HARVEST_CLIENT_SECRET") + +# Personal Access Token (second priority) +ACCESS_TOKEN = os.getenv("HARVEST_ACCESS_TOKEN") + +# Account ID (required for all requests) +ACCOUNT_ID = os.getenv("HARVEST_ACCOUNT_ID") + +# OAuth redirect URI +REDIRECT_URI = os.getenv("HARVEST_REDIRECT_URI", "http://localhost:8080/callback") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: HarvestResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + # Handle list-type responses (users, time_entries, projects, clients, tasks, invoices, expenses) + for key in ("users", "time_entries", "projects", "clients", + "tasks", "invoices", "expenses", "roles", + "project_assignments"): + if isinstance(data, dict) and key in data: + items = data[key] + print(f" Found {len(items)} {key}.") + if items: + print(f" Sample: {json.dumps(items[0], indent=2)[:400]}...") + return + # Generic response + print(f" Data: {json.dumps(data, indent=2)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + # 1. Initialize Client + print_section("Initializing Harvest Client") + + if not ACCOUNT_ID: + print(" HARVEST_ACCOUNT_ID is required for all Harvest API requests.") + print(" Please set the HARVEST_ACCOUNT_ID environment variable.") + return + + config = None + + # Priority 1: OAuth2 + if CLIENT_ID and CLIENT_SECRET: + print(" Using OAuth2 authentication") + try: + print("Starting OAuth flow...") + # Harvest OAuth authorization URL: https://id.getharvest.com/oauth2/authorize + # Harvest token endpoint: https://id.getharvest.com/oauth2/token + token_response = perform_oauth_flow( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + auth_endpoint="https://id.getharvest.com/oauth2/authorize", + token_endpoint="https://id.getharvest.com/oauth2/token", + redirect_uri=REDIRECT_URI, + scopes=[], # Harvest doesn't use scopes in auth URL + scope_delimiter=" ", + auth_method="body", # Harvest sends credentials in POST body + ) + + access_token = token_response.get("access_token") + if not access_token: + raise Exception("No access_token found in OAuth response") + + config = HarvestOAuthConfig( + access_token=access_token, + account_id=ACCOUNT_ID, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + ) + print(" OAuth authentication successful") + except Exception as e: + print(f" OAuth flow failed: {e}") + print(" Falling back to other authentication methods...") + + # Priority 2: Personal Access Token + if config is None and ACCESS_TOKEN: + print(" Using Personal Access Token authentication") + config = HarvestTokenConfig( + token=ACCESS_TOKEN, + account_id=ACCOUNT_ID, + ) + + if config is None: + print(" No valid authentication method found.") + print(" Please set one of the following:") + print(" - HARVEST_CLIENT_ID and HARVEST_CLIENT_SECRET (for OAuth2)") + print(" - HARVEST_ACCESS_TOKEN (for Personal Access Token)") + print(" And always set HARVEST_ACCOUNT_ID") + return + + client = HarvestClient.build_with_config(config) + data_source = HarvestDataSource(client) + print("Client initialized successfully.") + + try: + # 2. Get Current User + print_section("Current User") + user_resp = await data_source.get_current_user() + print_result("Get Current User", user_resp) + + # 3. Get Company Info + print_section("Company Info") + company_resp = await data_source.get_company() + print_result("Get Company", company_resp) + + # 4. List Projects + print_section("Projects") + projects_resp = await data_source.list_projects() + print_result("List Projects", projects_resp) + + # 5. List Time Entries + print_section("Time Entries") + time_entries_resp = await data_source.list_time_entries() + print_result("List Time Entries", time_entries_resp) + + # 6. List Clients + print_section("Clients") + clients_resp = await data_source.list_clients() + print_result("List Clients", clients_resp) + + # 7. List Tasks + print_section("Tasks") + tasks_resp = await data_source.list_tasks() + print_result("List Tasks", tasks_resp) + + # 8. List Invoices + print_section("Invoices") + invoices_resp = await data_source.list_invoices() + print_result("List Invoices", invoices_resp) + + # 9. List Expenses + print_section("Expenses") + expenses_resp = await data_source.list_expenses() + print_result("List Expenses", expenses_resp) + + # 10. List Roles + print_section("Roles") + roles_resp = await data_source.list_roles() + print_result("List Roles", roles_resp) + + # 11. List Project Assignments (current user) + print_section("Project Assignments (Current User)") + assignments_resp = await data_source.list_project_assignments() + print_result("List Project Assignments", assignments_resp) + + finally: + # Cleanup: Close the HTTP client session + print("\nClosing client connection...") + inner_client = client.get_client() + if hasattr(inner_client, "close"): + await inner_client.close() + + print("\n" + "=" * 80) + print(" All Harvest API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/harvest/harvest.py b/backend/python/app/sources/external/harvest/harvest.py new file mode 100644 index 000000000..60c39782c --- /dev/null +++ b/backend/python/app/sources/external/harvest/harvest.py @@ -0,0 +1,938 @@ +""" +Harvest REST API DataSource - Auto-generated API wrapper + +Generated from Harvest REST API v2 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.harvest.harvest import HarvestClient, HarvestResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class HarvestDataSource: + """Harvest REST API DataSource + + Provides async wrapper methods for Harvest REST API operations: + - Users and user management + - Time entries CRUD + - Projects and clients + - Tasks, invoices, expenses + - Company info, roles + - Project assignments + + All requests require a Harvest-Account-Id header, which is set + by the HarvestClient during initialization. + + All methods return HarvestResponse objects. + """ + + def __init__(self, client: HarvestClient) -> None: + """Initialize with HarvestClient. + + Args: + client: HarvestClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'HarvestDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> HarvestClient: + """Return the underlying HarvestClient.""" + return self._client + + async def get_current_user( + self + ) -> HarvestResponse: + """Get the currently authenticated user + + Returns: + HarvestResponse with operation result + """ + url = self.base_url + "/users/me" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HarvestResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_current_user" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return HarvestResponse(success=False, error=str(e), message="Failed to execute get_current_user") + + async def list_users( + self, + *, + is_active: bool | None = None, + page: int | None = None, + per_page: int | None = None, + updated_since: str | None = None + ) -> HarvestResponse: + """List all users + + Args: + is_active: Filter by active status + page: Page number for pagination + per_page: Number of records per page + updated_since: Only return users updated since this datetime (ISO 8601) + + Returns: + HarvestResponse with operation result + """ + query_params: dict[str, Any] = {} + if is_active is not None: + query_params['is_active'] = str(is_active).lower() + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + if updated_since is not None: + query_params['updated_since'] = updated_since + + url = self.base_url + "/users" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HarvestResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_users" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return HarvestResponse(success=False, error=str(e), message="Failed to execute list_users") + + async def get_user( + self, + user_id: str + ) -> HarvestResponse: + """Get a specific user by ID + + Args: + user_id: The user ID + + Returns: + HarvestResponse with operation result + """ + url = self.base_url + "/users/{user_id}".format(user_id=user_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HarvestResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_user" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return HarvestResponse(success=False, error=str(e), message="Failed to execute get_user") + + async def list_time_entries( + self, + *, + user_id: str | None = None, + client_id_: str | None = None, + project_id: str | None = None, + is_billed: bool | None = None, + is_running: bool | None = None, + updated_since: str | None = None, + from_: str | None = None, + to_: str | None = None, + page: int | None = None, + per_page: int | None = None + ) -> HarvestResponse: + """List all time entries + + Args: + user_id: Filter by user ID + client_id_: Filter by client ID + project_id: Filter by project ID + is_billed: Filter by billed status + is_running: Filter by running status + updated_since: Only return time entries updated since this datetime (ISO 8601) + from_: Start date for filtering (YYYY-MM-DD) + to_: End date for filtering (YYYY-MM-DD) + page: Page number for pagination + per_page: Number of records per page + + Returns: + HarvestResponse with operation result + """ + query_params: dict[str, Any] = {} + if user_id is not None: + query_params['user_id'] = user_id + if client_id_ is not None: + query_params['client_id'] = client_id_ + if project_id is not None: + query_params['project_id'] = project_id + if is_billed is not None: + query_params['is_billed'] = str(is_billed).lower() + if is_running is not None: + query_params['is_running'] = str(is_running).lower() + if updated_since is not None: + query_params['updated_since'] = updated_since + if from_ is not None: + query_params['from'] = from_ + if to_ is not None: + query_params['to'] = to_ + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/time_entries" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HarvestResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_time_entries" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return HarvestResponse(success=False, error=str(e), message="Failed to execute list_time_entries") + + async def get_time_entry( + self, + time_entry_id: str + ) -> HarvestResponse: + """Get a specific time entry by ID + + Args: + time_entry_id: The time entry ID + + Returns: + HarvestResponse with operation result + """ + url = self.base_url + "/time_entries/{time_entry_id}".format(time_entry_id=time_entry_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HarvestResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_time_entry" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return HarvestResponse(success=False, error=str(e), message="Failed to execute get_time_entry") + + async def create_time_entry( + self, + body: dict[str, Any] + ) -> HarvestResponse: + """Create a new time entry + + Args: + body: Time entry data (project_id, task_id, spent_date, etc.) + + Returns: + HarvestResponse with operation result + """ + url = self.base_url + "/time_entries" + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HarvestResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed create_time_entry" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return HarvestResponse(success=False, error=str(e), message="Failed to execute create_time_entry") + + async def update_time_entry( + self, + time_entry_id: str, + body: dict[str, Any] + ) -> HarvestResponse: + """Update an existing time entry + + Args: + time_entry_id: The time entry ID + body: Time entry fields to update + + Returns: + HarvestResponse with operation result + """ + url = self.base_url + "/time_entries/{time_entry_id}".format(time_entry_id=time_entry_id) + + try: + request = HTTPRequest( + method="PATCH", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HarvestResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed update_time_entry" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return HarvestResponse(success=False, error=str(e), message="Failed to execute update_time_entry") + + async def delete_time_entry( + self, + time_entry_id: str + ) -> HarvestResponse: + """Delete a time entry + + Args: + time_entry_id: The time entry ID + + Returns: + HarvestResponse with operation result + """ + url = self.base_url + "/time_entries/{time_entry_id}".format(time_entry_id=time_entry_id) + + try: + request = HTTPRequest( + method="DELETE", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HarvestResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed delete_time_entry" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return HarvestResponse(success=False, error=str(e), message="Failed to execute delete_time_entry") + + async def list_projects( + self, + *, + is_active: bool | None = None, + client_id_: str | None = None, + updated_since: str | None = None, + page: int | None = None, + per_page: int | None = None + ) -> HarvestResponse: + """List all projects + + Args: + is_active: Filter by active status + client_id_: Filter by client ID + updated_since: Only return projects updated since this datetime (ISO 8601) + page: Page number for pagination + per_page: Number of records per page + + Returns: + HarvestResponse with operation result + """ + query_params: dict[str, Any] = {} + if is_active is not None: + query_params['is_active'] = str(is_active).lower() + if client_id_ is not None: + query_params['client_id'] = client_id_ + if updated_since is not None: + query_params['updated_since'] = updated_since + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/projects" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HarvestResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_projects" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return HarvestResponse(success=False, error=str(e), message="Failed to execute list_projects") + + async def get_project( + self, + project_id: str + ) -> HarvestResponse: + """Get a specific project by ID + + Args: + project_id: The project ID + + Returns: + HarvestResponse with operation result + """ + url = self.base_url + "/projects/{project_id}".format(project_id=project_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HarvestResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_project" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return HarvestResponse(success=False, error=str(e), message="Failed to execute get_project") + + async def list_clients( + self, + *, + is_active: bool | None = None, + updated_since: str | None = None, + page: int | None = None, + per_page: int | None = None + ) -> HarvestResponse: + """List all clients + + Args: + is_active: Filter by active status + updated_since: Only return clients updated since this datetime (ISO 8601) + page: Page number for pagination + per_page: Number of records per page + + Returns: + HarvestResponse with operation result + """ + query_params: dict[str, Any] = {} + if is_active is not None: + query_params['is_active'] = str(is_active).lower() + if updated_since is not None: + query_params['updated_since'] = updated_since + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/clients" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HarvestResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_clients" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return HarvestResponse(success=False, error=str(e), message="Failed to execute list_clients") + + async def get_client_by_id( + self, + client_id_param: str + ) -> HarvestResponse: + """Get a specific client by ID + + Args: + client_id_param: The client ID + + Returns: + HarvestResponse with operation result + """ + url = self.base_url + "/clients/{client_id_param}".format(client_id_param=client_id_param) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HarvestResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_client_by_id" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return HarvestResponse(success=False, error=str(e), message="Failed to execute get_client_by_id") + + async def list_tasks( + self, + *, + is_active: bool | None = None, + updated_since: str | None = None, + page: int | None = None, + per_page: int | None = None + ) -> HarvestResponse: + """List all tasks + + Args: + is_active: Filter by active status + updated_since: Only return tasks updated since this datetime (ISO 8601) + page: Page number for pagination + per_page: Number of records per page + + Returns: + HarvestResponse with operation result + """ + query_params: dict[str, Any] = {} + if is_active is not None: + query_params['is_active'] = str(is_active).lower() + if updated_since is not None: + query_params['updated_since'] = updated_since + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/tasks" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HarvestResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_tasks" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return HarvestResponse(success=False, error=str(e), message="Failed to execute list_tasks") + + async def get_task( + self, + task_id: str + ) -> HarvestResponse: + """Get a specific task by ID + + Args: + task_id: The task ID + + Returns: + HarvestResponse with operation result + """ + url = self.base_url + "/tasks/{task_id}".format(task_id=task_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HarvestResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_task" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return HarvestResponse(success=False, error=str(e), message="Failed to execute get_task") + + async def list_invoices( + self, + client_id_: str | None = None, + project_id: str | None = None, + updated_since: str | None = None, + from_: str | None = None, + to_: str | None = None, + state: str | None = None, + page: int | None = None, + per_page: int | None = None + ) -> HarvestResponse: + """List all invoices + + Args: + client_id_: Filter by client ID + project_id: Filter by project ID + updated_since: Only return invoices updated since this datetime (ISO 8601) + from_: Start date for filtering (YYYY-MM-DD) + to_: End date for filtering (YYYY-MM-DD) + state: Filter by invoice state (draft, open, paid, closed) + page: Page number for pagination + per_page: Number of records per page + + Returns: + HarvestResponse with operation result + """ + query_params: dict[str, Any] = {} + if client_id_ is not None: + query_params['client_id'] = client_id_ + if project_id is not None: + query_params['project_id'] = project_id + if updated_since is not None: + query_params['updated_since'] = updated_since + if from_ is not None: + query_params['from'] = from_ + if to_ is not None: + query_params['to'] = to_ + if state is not None: + query_params['state'] = state + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/invoices" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HarvestResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_invoices" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return HarvestResponse(success=False, error=str(e), message="Failed to execute list_invoices") + + async def get_invoice( + self, + invoice_id: str + ) -> HarvestResponse: + """Get a specific invoice by ID + + Args: + invoice_id: The invoice ID + + Returns: + HarvestResponse with operation result + """ + url = self.base_url + "/invoices/{invoice_id}".format(invoice_id=invoice_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HarvestResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_invoice" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return HarvestResponse(success=False, error=str(e), message="Failed to execute get_invoice") + + async def list_expenses( + self, + *, + user_id: str | None = None, + client_id_: str | None = None, + project_id: str | None = None, + is_billed: bool | None = None, + updated_since: str | None = None, + from_: str | None = None, + to_: str | None = None, + page: int | None = None, + per_page: int | None = None + ) -> HarvestResponse: + """List all expenses + + Args: + user_id: Filter by user ID + client_id_: Filter by client ID + project_id: Filter by project ID + is_billed: Filter by billed status + updated_since: Only return expenses updated since this datetime (ISO 8601) + from_: Start date for filtering (YYYY-MM-DD) + to_: End date for filtering (YYYY-MM-DD) + page: Page number for pagination + per_page: Number of records per page + + Returns: + HarvestResponse with operation result + """ + query_params: dict[str, Any] = {} + if user_id is not None: + query_params['user_id'] = user_id + if client_id_ is not None: + query_params['client_id'] = client_id_ + if project_id is not None: + query_params['project_id'] = project_id + if is_billed is not None: + query_params['is_billed'] = str(is_billed).lower() + if updated_since is not None: + query_params['updated_since'] = updated_since + if from_ is not None: + query_params['from'] = from_ + if to_ is not None: + query_params['to'] = to_ + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/expenses" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HarvestResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_expenses" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return HarvestResponse(success=False, error=str(e), message="Failed to execute list_expenses") + + async def get_expense( + self, + expense_id: str + ) -> HarvestResponse: + """Get a specific expense by ID + + Args: + expense_id: The expense ID + + Returns: + HarvestResponse with operation result + """ + url = self.base_url + "/expenses/{expense_id}".format(expense_id=expense_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HarvestResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_expense" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return HarvestResponse(success=False, error=str(e), message="Failed to execute get_expense") + + async def get_company( + self + ) -> HarvestResponse: + """Get the company information for the authenticated user's account + + Returns: + HarvestResponse with operation result + """ + url = self.base_url + "/company" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HarvestResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_company" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return HarvestResponse(success=False, error=str(e), message="Failed to execute get_company") + + async def list_roles( + self, + page: int | None = None, + per_page: int | None = None + ) -> HarvestResponse: + """List all roles + + Args: + page: Page number for pagination + per_page: Number of records per page + + Returns: + HarvestResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/roles" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HarvestResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_roles" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return HarvestResponse(success=False, error=str(e), message="Failed to execute list_roles") + + async def list_project_assignments( + self, + page: int | None = None, + per_page: int | None = None, + updated_since: str | None = None + ) -> HarvestResponse: + """List project assignments for the currently authenticated user + + Args: + page: Page number for pagination + per_page: Number of records per page + updated_since: Only return assignments updated since this datetime (ISO 8601) + + Returns: + HarvestResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + if updated_since is not None: + query_params['updated_since'] = updated_since + + url = self.base_url + "/project_assignments" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HarvestResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_project_assignments" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return HarvestResponse(success=False, error=str(e), message="Failed to execute list_project_assignments") + + async def list_user_project_assignments( + self, + user_id: str, + page: int | None = None, + per_page: int | None = None, + updated_since: str | None = None + ) -> HarvestResponse: + """List project assignments for a specific user + + Args: + user_id: The user ID + page: Page number for pagination + per_page: Number of records per page + updated_since: Only return assignments updated since this datetime (ISO 8601) + + Returns: + HarvestResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + if updated_since is not None: + query_params['updated_since'] = updated_since + + url = self.base_url + "/users/{user_id}/project_assignments".format(user_id=user_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HarvestResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_user_project_assignments" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return HarvestResponse(success=False, error=str(e), message="Failed to execute list_user_project_assignments") diff --git a/backend/python/app/sources/external/haystack/code_generator.py b/backend/python/app/sources/external/haystack/code_generator.py new file mode 100644 index 000000000..0a9bdb78c --- /dev/null +++ b/backend/python/app/sources/external/haystack/code_generator.py @@ -0,0 +1,245 @@ +# ruff: noqa +""" +Haystack DataSource Code Generator + +Defines Haystack API endpoint specifications and generates the DataSource +wrapper class (haystack.py) from them. + +Endpoints: + /people, /people/{id}, /teams, /teams/{id}, /locations, /locations/{id}, + /departments, /departments/{id}, /announcements, /announcements/{id}, + /pages, /pages/{id}, /search +""" + +from __future__ import annotations + +ENDPOINTS = [ + # People + {"method": "GET", "path": "/people", "name": "get_people", "section": "People", + "doc": "List all people", "paginated": True}, + {"method": "GET", "path": "/people/{person_id}", "name": "get_person", "section": "People", + "doc": "Get a specific person by ID", "path_params": ["person_id"]}, + # Teams + {"method": "GET", "path": "/teams", "name": "get_teams", "section": "Teams", + "doc": "List all teams", "paginated": True}, + {"method": "GET", "path": "/teams/{team_id}", "name": "get_team", "section": "Teams", + "doc": "Get a specific team by ID", "path_params": ["team_id"]}, + # Locations + {"method": "GET", "path": "/locations", "name": "get_locations", "section": "Locations", + "doc": "List all locations", "paginated": True}, + {"method": "GET", "path": "/locations/{location_id}", "name": "get_location", "section": "Locations", + "doc": "Get a specific location by ID", "path_params": ["location_id"]}, + # Departments + {"method": "GET", "path": "/departments", "name": "get_departments", "section": "Departments", + "doc": "List all departments", "paginated": True}, + {"method": "GET", "path": "/departments/{department_id}", "name": "get_department", "section": "Departments", + "doc": "Get a specific department by ID", "path_params": ["department_id"]}, + # Announcements + {"method": "GET", "path": "/announcements", "name": "get_announcements", "section": "Announcements", + "doc": "List all announcements", "paginated": True}, + {"method": "GET", "path": "/announcements/{announcement_id}", "name": "get_announcement", "section": "Announcements", + "doc": "Get a specific announcement by ID", "path_params": ["announcement_id"]}, + # Pages + {"method": "GET", "path": "/pages", "name": "get_pages", "section": "Pages", + "doc": "List all pages", "paginated": True}, + {"method": "GET", "path": "/pages/{page_id}", "name": "get_page", "section": "Pages", + "doc": "Get a specific page by ID", "path_params": ["page_id"]}, + # Search + {"method": "GET", "path": "/search", "name": "search", "section": "Search", + "doc": "Search across Haystack content", + "query_params": [("q", "q", "str", "Search query string")], + "extra_query": [("type", "type", "str | None", "Filter by content type", True), + ("limit", "limit", "int | None", "Maximum number of results to return", True)]}, +] + + +def _gen_method(ep: dict) -> str: + """Generate a single async method from an endpoint spec.""" + name = ep["name"] + method = ep["method"] + path = ep["path"] + doc = ep["doc"] + path_params = ep.get("path_params", []) + paginated = ep.get("paginated", False) + body_params = ep.get("body_params", []) + query_params = ep.get("query_params", []) + extra_query = ep.get("extra_query", []) + has_query = paginated or query_params or extra_query + + sig_parts = ["self"] + for p in path_params: + sig_parts.append(f"{p}: str") + for qp in query_params: + sig_parts.append(f"{qp[0]}: {qp[2]}") + for bp in body_params: + sig_parts.append(f"{bp[0]}: {bp[2]}") + if paginated or extra_query: + sig_parts.append("*") + if paginated: + sig_parts.append("limit: int | None = None") + sig_parts.append("offset: int | None = None") + for eq in extra_query: + sig_parts.append(f"{eq[0]}: {eq[2]} = None") + + sig = ",\n ".join(sig_parts) + + doc_args = "" + if path_params or paginated or body_params or query_params or extra_query: + doc_args = "\n Args:\n" + for p in path_params: + doc_args += f" {p}: The {p.replace('_', ' ')}\n" + for qp in query_params: + doc_args += f" {qp[0]}: {qp[3]}\n" + for bp in body_params: + doc_args += f" {bp[0]}: {bp[3]}\n" + if paginated: + doc_args += " limit: Maximum number of results to return\n" + doc_args += " offset: Number of results to skip\n" + for eq in extra_query: + doc_args += f" {eq[0]}: {eq[3]}\n" + + query_block = "" + if has_query: + lines = ["", " query_params: dict[str, Any] = {}"] + for qp in query_params: + lines.append(f" query_params['{qp[1]}'] = {qp[0]}") + if paginated: + lines.append(" if limit is not None:") + lines.append(" query_params['limit'] = str(limit)") + lines.append(" if offset is not None:") + lines.append(" query_params['offset'] = str(offset)") + for eq in extra_query: + lines.append(f" if {eq[0]} is not None:") + if "int" in eq[2]: + lines.append(f" query_params['{eq[1]}'] = str({eq[0]})") + else: + lines.append(f" query_params['{eq[1]}'] = {eq[0]}") + query_block = "\n".join(lines) + "\n" + + if path_params: + fmt_args = ", ".join(f"{p}={p}" for p in path_params) + url_line = f' url = self.base_url + "{path}".format({fmt_args})' + else: + url_line = f' url = self.base_url + "{path}"' + + body_block = "" + if body_params: + lines = ["\n body: dict[str, Any] = {"] + for bp in body_params: + lines.append(f' "{bp[1]}": {bp[0]},') + lines.append(" }") + body_block = "\n".join(lines) + + req_extra = "" + if has_query: + req_extra += "\n query=query_params," + if body_params: + req_extra += "\n body=body," + + return f''' + async def {name}( + {sig}, + ) -> HaystackResponse: + """{doc} + + HTTP {method} {path} +{doc_args} + Returns: + HaystackResponse with operation result + """ +{query_block} +{url_line} + + try: + request = HTTPRequest( + method="{method}", + url=url, + headers={{"Content-Type": "application/json"}},{req_extra} + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HaystackResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed {name}" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {{response.status}}" + ) + except Exception as e: + return HaystackResponse(success=False, error=str(e), message="Failed to execute {name}") +''' + + +def generate_datasource() -> str: + """Generate the full Haystack DataSource module code.""" + header = '''# ruff: noqa +""" +Haystack REST API DataSource - Auto-generated API wrapper + +Generated from Haystack REST API v1 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.haystack.haystack import HaystackClient, HaystackResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class HaystackDataSource: + """Haystack REST API DataSource + + Provides async wrapper methods for Haystack REST API operations: + - People management + - Teams management + - Locations management + - Departments management + - Announcements management + - Pages management + - Search + + The base URL is https://api.haystackapp.io/v1. + + All methods return HaystackResponse objects. + """ + + def __init__(self, client: HaystackClient) -> None: + """Initialize with HaystackClient. + + Args: + client: HaystackClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'HaystackDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> HaystackClient: + """Return the underlying HaystackClient.""" + return self._client +''' + + methods = [] + current_section = None + for ep in ENDPOINTS: + section = ep.get("section", "") + if section and section != current_section: + current_section = section + methods.append(f"\n # {'-' * 71}\n # {section}\n # {'-' * 71}") + methods.append(_gen_method(ep)) + + return header + "\n".join(methods) + "\n" + + +if __name__ == "__main__": + print(generate_datasource()) diff --git a/backend/python/app/sources/external/haystack/example.py b/backend/python/app/sources/external/haystack/example.py new file mode 100644 index 000000000..b5a60c566 --- /dev/null +++ b/backend/python/app/sources/external/haystack/example.py @@ -0,0 +1,143 @@ +# ruff: noqa + +""" +Haystack API Usage Examples + +This example demonstrates how to use the Haystack DataSource to interact with +the Haystack API v1, covering: +- Authentication (API Key / Bearer Token) +- Initializing the Client and DataSource +- Listing People, Teams, Locations, Departments +- Fetching Announcements and Pages +- Searching content + +Prerequisites: +1. Obtain an API key from Haystack admin settings +2. Set HAYSTACK_API_KEY environment variable +""" + +import asyncio +import json +import os + +from app.sources.client.haystack.haystack import ( + HaystackClient, + HaystackTokenConfig, + HaystackResponse, +) +from app.sources.external.haystack.haystack import HaystackDataSource + +# --- Configuration --- +API_KEY = os.getenv("HAYSTACK_API_KEY") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: HaystackResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + if isinstance(data, list): + print(f" Found {len(data)} items.") + if data: + print(f" Sample: {json.dumps(data[0], indent=2)[:400]}...") + elif isinstance(data, dict): + for key in ("people", "teams", "locations", "departments", + "announcements", "pages", "results"): + if key in data: + items = data[key] + print(f" Found {len(items)} {key}.") + if items: + print(f" Sample: {json.dumps(items[0], indent=2)[:400]}...") + return + print(f" Data: {json.dumps(data, indent=2)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + # 1. Initialize Client + print_section("Initializing Haystack Client") + + if not API_KEY: + print(" No valid authentication method found.") + print(" Please set HAYSTACK_API_KEY environment variable.") + return + + print(" Using API Key authentication") + config = HaystackTokenConfig(token=API_KEY) + client = HaystackClient.build_with_config(config) + data_source = HaystackDataSource(client) + print("Client initialized successfully.") + + try: + # 2. Get People + print_section("People") + people_resp = await data_source.get_people(limit=10) + print_result("Get People", people_resp) + + person_id = None + if people_resp.success and people_resp.data: + data = people_resp.data + items = data if isinstance(data, list) else data.get("people", []) if isinstance(data, dict) else [] + if items: + person_id = str(items[0].get("id")) + print(f" Using Person ID: {person_id}") + + if person_id: + print_section("Person Details") + person_resp = await data_source.get_person(person_id) + print_result("Get Person", person_resp) + + # 3. Get Teams + print_section("Teams") + teams_resp = await data_source.get_teams(limit=10) + print_result("Get Teams", teams_resp) + + # 4. Get Locations + print_section("Locations") + locations_resp = await data_source.get_locations(limit=10) + print_result("Get Locations", locations_resp) + + # 5. Get Departments + print_section("Departments") + departments_resp = await data_source.get_departments(limit=10) + print_result("Get Departments", departments_resp) + + # 6. Get Announcements + print_section("Announcements") + announcements_resp = await data_source.get_announcements(limit=10) + print_result("Get Announcements", announcements_resp) + + # 7. Get Pages + print_section("Pages") + pages_resp = await data_source.get_pages(limit=10) + print_result("Get Pages", pages_resp) + + # 8. Search + print_section("Search") + search_resp = await data_source.search(q="team", limit=10) + print_result("Search", search_resp) + + finally: + # Cleanup: Close the HTTP client session + print("\nClosing client connection...") + inner_client = client.get_client() + if hasattr(inner_client, "close"): + await inner_client.close() + + print("\n" + "=" * 80) + print(" All Haystack API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/haystack/haystack.py b/backend/python/app/sources/external/haystack/haystack.py new file mode 100644 index 000000000..f9897fa75 --- /dev/null +++ b/backend/python/app/sources/external/haystack/haystack.py @@ -0,0 +1,547 @@ +# ruff: noqa +""" +Haystack REST API DataSource - Auto-generated API wrapper + +Generated from Haystack REST API v1 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.haystack.haystack import HaystackClient, HaystackResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class HaystackDataSource: + """Haystack REST API DataSource + + Provides async wrapper methods for Haystack REST API operations: + - People management + - Teams management + - Locations management + - Departments management + - Announcements management + - Pages management + - Search + + The base URL is https://api.haystackapp.io/v1. + + All methods return HaystackResponse objects. + """ + + def __init__(self, client: HaystackClient) -> None: + """Initialize with HaystackClient. + + Args: + client: HaystackClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'HaystackDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> HaystackClient: + """Return the underlying HaystackClient.""" + return self._client + + # ----------------------------------------------------------------------- + # People + # ----------------------------------------------------------------------- + + async def get_people( + self, + *, + limit: int | None = None, + offset: int | None = None, + ) -> HaystackResponse: + """List all people. + + Args: + limit: Maximum number of results to return + offset: Number of results to skip + + Returns: + HaystackResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/people" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HaystackResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_people" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return HaystackResponse(success=False, error=str(e), message="Failed to execute get_people") + + async def get_person( + self, + person_id: str, + ) -> HaystackResponse: + """Get a specific person by ID. + + Args: + person_id: The person ID + + Returns: + HaystackResponse with operation result + """ + url = self.base_url + "/people/{person_id}".format(person_id=person_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HaystackResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_person" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return HaystackResponse(success=False, error=str(e), message="Failed to execute get_person") + + # ----------------------------------------------------------------------- + # Teams + # ----------------------------------------------------------------------- + + async def get_teams( + self, + *, + limit: int | None = None, + offset: int | None = None, + ) -> HaystackResponse: + """List all teams. + + Args: + limit: Maximum number of results to return + offset: Number of results to skip + + Returns: + HaystackResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/teams" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HaystackResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_teams" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return HaystackResponse(success=False, error=str(e), message="Failed to execute get_teams") + + async def get_team( + self, + team_id: str, + ) -> HaystackResponse: + """Get a specific team by ID. + + Args: + team_id: The team ID + + Returns: + HaystackResponse with operation result + """ + url = self.base_url + "/teams/{team_id}".format(team_id=team_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HaystackResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_team" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return HaystackResponse(success=False, error=str(e), message="Failed to execute get_team") + + # ----------------------------------------------------------------------- + # Locations + # ----------------------------------------------------------------------- + + async def get_locations( + self, + *, + limit: int | None = None, + offset: int | None = None, + ) -> HaystackResponse: + """List all locations. + + Args: + limit: Maximum number of results to return + offset: Number of results to skip + + Returns: + HaystackResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/locations" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HaystackResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_locations" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return HaystackResponse(success=False, error=str(e), message="Failed to execute get_locations") + + async def get_location( + self, + location_id: str, + ) -> HaystackResponse: + """Get a specific location by ID. + + Args: + location_id: The location ID + + Returns: + HaystackResponse with operation result + """ + url = self.base_url + "/locations/{location_id}".format(location_id=location_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HaystackResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_location" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return HaystackResponse(success=False, error=str(e), message="Failed to execute get_location") + + # ----------------------------------------------------------------------- + # Departments + # ----------------------------------------------------------------------- + + async def get_departments( + self, + *, + limit: int | None = None, + offset: int | None = None, + ) -> HaystackResponse: + """List all departments. + + Args: + limit: Maximum number of results to return + offset: Number of results to skip + + Returns: + HaystackResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/departments" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HaystackResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_departments" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return HaystackResponse(success=False, error=str(e), message="Failed to execute get_departments") + + async def get_department( + self, + department_id: str, + ) -> HaystackResponse: + """Get a specific department by ID. + + Args: + department_id: The department ID + + Returns: + HaystackResponse with operation result + """ + url = self.base_url + "/departments/{department_id}".format(department_id=department_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HaystackResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_department" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return HaystackResponse(success=False, error=str(e), message="Failed to execute get_department") + + # ----------------------------------------------------------------------- + # Announcements + # ----------------------------------------------------------------------- + + async def get_announcements( + self, + *, + limit: int | None = None, + offset: int | None = None, + ) -> HaystackResponse: + """List all announcements. + + Args: + limit: Maximum number of results to return + offset: Number of results to skip + + Returns: + HaystackResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/announcements" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HaystackResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_announcements" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return HaystackResponse(success=False, error=str(e), message="Failed to execute get_announcements") + + async def get_announcement( + self, + announcement_id: str, + ) -> HaystackResponse: + """Get a specific announcement by ID. + + Args: + announcement_id: The announcement ID + + Returns: + HaystackResponse with operation result + """ + url = self.base_url + "/announcements/{announcement_id}".format(announcement_id=announcement_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HaystackResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_announcement" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return HaystackResponse(success=False, error=str(e), message="Failed to execute get_announcement") + + # ----------------------------------------------------------------------- + # Pages + # ----------------------------------------------------------------------- + + async def get_pages( + self, + *, + limit: int | None = None, + offset: int | None = None, + ) -> HaystackResponse: + """List all pages. + + Args: + limit: Maximum number of results to return + offset: Number of results to skip + + Returns: + HaystackResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/pages" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HaystackResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_pages" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return HaystackResponse(success=False, error=str(e), message="Failed to execute get_pages") + + async def get_page( + self, + page_id: str, + ) -> HaystackResponse: + """Get a specific page by ID. + + Args: + page_id: The page ID + + Returns: + HaystackResponse with operation result + """ + url = self.base_url + "/pages/{page_id}".format(page_id=page_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HaystackResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_page" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return HaystackResponse(success=False, error=str(e), message="Failed to execute get_page") + + # ----------------------------------------------------------------------- + # Search + # ----------------------------------------------------------------------- + + async def search( + self, + q: str, + *, + type: str | None = None, + limit: int | None = None, + ) -> HaystackResponse: + """Search across Haystack content. + + Args: + q: Search query string + type: Filter by content type + limit: Maximum number of results to return + + Returns: + HaystackResponse with operation result + """ + query_params: dict[str, Any] = {'q': q} + if type is not None: + query_params['type'] = type + if limit is not None: + query_params['limit'] = str(limit) + + url = self.base_url + "/search" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HaystackResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed search" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return HaystackResponse(success=False, error=str(e), message="Failed to execute search") diff --git a/backend/python/app/sources/external/haystack/run_generator.py b/backend/python/app/sources/external/haystack/run_generator.py new file mode 100644 index 000000000..d1ad2bc0f --- /dev/null +++ b/backend/python/app/sources/external/haystack/run_generator.py @@ -0,0 +1,24 @@ +"""Runner script to generate the Haystack DataSource wrapper. + +Execute this script to regenerate haystack.py from the endpoint definitions +in code_generator.py. + +Usage: + python -m app.sources.external.haystack.run_generator +""" + +from app.sources.external.haystack.code_generator import generate_datasource + + +def main() -> None: + """Generate the Haystack DataSource file.""" + code = generate_datasource() + output_path = __file__.replace("run_generator.py", "haystack.py") + with open(output_path, "w") as f: + f.write(code) + print(f"Generated Haystack DataSource -> {output_path}") + print(f" Total characters: {len(code)}") + + +if __name__ == "__main__": + main() diff --git a/backend/python/app/sources/external/highspot/code_generator.py b/backend/python/app/sources/external/highspot/code_generator.py new file mode 100644 index 000000000..bc5d345de --- /dev/null +++ b/backend/python/app/sources/external/highspot/code_generator.py @@ -0,0 +1,203 @@ +# ruff: noqa +""" +Highspot DataSource Code Generator + +Defines Highspot API endpoint specifications and generates the DataSource +wrapper class (highspot.py) from them. + +Endpoints: + /spots, /spots/{id}, /spots/{id}/items, /items, /items/{id}, + /pitches, /pitches/{id}, /groups, /groups/{id}, /users, /users/{id}, + /analytics/content, /analytics/engagement +""" + +from __future__ import annotations + +ENDPOINTS = [ + # Spots + {"method": "GET", "path": "/spots", "name": "list_spots", "section": "Spots", + "doc": "List all spots", "paginated": True}, + {"method": "GET", "path": "/spots/{spot_id}", "name": "get_spot", "section": "Spots", + "doc": "Get a specific spot by ID", "path_params": ["spot_id"]}, + {"method": "GET", "path": "/spots/{spot_id}/items", "name": "get_spot_items", "section": "Spots", + "doc": "Get items in a specific spot", "path_params": ["spot_id"], "paginated": True}, + # Items + {"method": "GET", "path": "/items", "name": "list_items", "section": "Items", + "doc": "List all items", "paginated": True}, + {"method": "GET", "path": "/items/{item_id}", "name": "get_item", "section": "Items", + "doc": "Get a specific item by ID", "path_params": ["item_id"]}, + # Pitches + {"method": "GET", "path": "/pitches", "name": "list_pitches", "section": "Pitches", + "doc": "List all pitches", "paginated": True}, + {"method": "GET", "path": "/pitches/{pitch_id}", "name": "get_pitch", "section": "Pitches", + "doc": "Get a specific pitch by ID", "path_params": ["pitch_id"]}, + # Groups + {"method": "GET", "path": "/groups", "name": "list_groups", "section": "Groups", + "doc": "List all groups", "paginated": True}, + {"method": "GET", "path": "/groups/{group_id}", "name": "get_group", "section": "Groups", + "doc": "Get a specific group by ID", "path_params": ["group_id"]}, + # Users + {"method": "GET", "path": "/users", "name": "list_users", "section": "Users", + "doc": "List all users", "paginated": True}, + {"method": "GET", "path": "/users/{user_id}", "name": "get_user", "section": "Users", + "doc": "Get a specific user by ID", "path_params": ["user_id"]}, + # Analytics + {"method": "GET", "path": "/analytics/content", "name": "get_content_analytics", "section": "Analytics", + "doc": "Get content analytics"}, + {"method": "GET", "path": "/analytics/engagement", "name": "get_engagement_analytics", "section": "Analytics", + "doc": "Get engagement analytics"}, +] + + +def _gen_method(ep: dict) -> str: + """Generate a single async method from an endpoint spec.""" + name = ep["name"] + method = ep["method"] + path = ep["path"] + doc = ep["doc"] + path_params = ep.get("path_params", []) + paginated = ep.get("paginated", False) + + sig_parts = ["self"] + for p in path_params: + sig_parts.append(f"{p}: str") + if paginated: + sig_parts.append("*") + sig_parts.append("page: int | None = None") + sig_parts.append("per_page: int | None = None") + + sig = ",\n ".join(sig_parts) + + doc_args = "" + if path_params or paginated: + doc_args = "\n Args:\n" + for p in path_params: + doc_args += f" {p}: The {p.replace('_', ' ')}\n" + if paginated: + doc_args += " page: Page number for pagination\n" + doc_args += " per_page: Number of items per page\n" + + query_block = "" + if paginated: + query_block = """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) +""" + + if path_params: + fmt_args = ", ".join(f"{p}={p}" for p in path_params) + url_line = f' url = self.base_url + "{path}".format({fmt_args})' + else: + url_line = f' url = self.base_url + "{path}"' + + req_extra = "" + if paginated: + req_extra = "\n query=query_params," + + return f''' + async def {name}( + {sig} + ) -> HighspotResponse: + """{doc} + + HTTP {method} {path} +{doc_args} + Returns: + HighspotResponse with operation result + """ +{query_block} +{url_line} + + try: + request = HTTPRequest( + method="{method}", + url=url, + headers={{"Content-Type": "application/json"}},{req_extra} + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HighspotResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed {name}" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {{response.status}}" + ) + except Exception as e: + return HighspotResponse(success=False, error=str(e), message="Failed to execute {name}") +''' + + +def generate_datasource() -> str: + """Generate the full Highspot DataSource module code.""" + header = '''# ruff: noqa +""" +Highspot REST API DataSource - Auto-generated API wrapper + +Generated from Highspot REST API v1 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.highspot.highspot import HighspotClient, HighspotResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class HighspotDataSource: + """Highspot REST API DataSource + + Provides async wrapper methods for Highspot REST API operations: + - Spots management + - Items management + - Pitches management + - Groups management + - Users management + - Analytics (content and engagement) + + All methods return HighspotResponse objects. + """ + + def __init__(self, client: HighspotClient) -> None: + """Initialize with HighspotClient. + + Args: + client: HighspotClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'HighspotDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> HighspotClient: + """Return the underlying HighspotClient.""" + return self._client +''' + + methods = [] + current_section = None + for ep in ENDPOINTS: + section = ep.get("section", "") + if section and section != current_section: + current_section = section + methods.append(f"\n # {'-' * 71}\n # {section}\n # {'-' * 71}") + methods.append(_gen_method(ep)) + + return header + "\n".join(methods) + "\n" + + +if __name__ == "__main__": + print(generate_datasource()) diff --git a/backend/python/app/sources/external/highspot/example.py b/backend/python/app/sources/external/highspot/example.py new file mode 100644 index 000000000..181470b8c --- /dev/null +++ b/backend/python/app/sources/external/highspot/example.py @@ -0,0 +1,171 @@ +# ruff: noqa + +""" +Highspot API Usage Examples + +This example demonstrates how to use the Highspot DataSource to interact with +the Highspot API, covering: +- Authentication (OAuth2 or Bearer Token) +- Initializing the Client and DataSource +- Listing Spots, Items, Pitches, Groups, Users +- Fetching analytics + +Prerequisites: +For OAuth2: +1. Register an OAuth app with Highspot +2. Set HIGHSPOT_CLIENT_ID and HIGHSPOT_CLIENT_SECRET environment variables + +For Bearer Token: +1. Set HIGHSPOT_ACCESS_TOKEN environment variable +""" + +import asyncio +import json +import os + +from app.sources.client.highspot.highspot import ( + HighspotClient, + HighspotOAuthConfig, + HighspotTokenConfig, + HighspotResponse, +) +from app.sources.external.highspot.highspot import HighspotDataSource +from app.sources.external.utils.oauth import perform_oauth_flow + +# --- Configuration --- +CLIENT_ID = os.getenv("HIGHSPOT_CLIENT_ID") +CLIENT_SECRET = os.getenv("HIGHSPOT_CLIENT_SECRET") +ACCESS_TOKEN = os.getenv("HIGHSPOT_ACCESS_TOKEN") +REDIRECT_URI = os.getenv("HIGHSPOT_REDIRECT_URI", "http://localhost:8080/callback") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: HighspotResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + for key in ("spots", "items", "pitches", "groups", "users", "results"): + if isinstance(data, dict) and key in data: + items = data[key] + print(f" Found {len(items)} {key}.") + if items: + print(f" Sample: {json.dumps(items[0], indent=2)[:400]}...") + return + if isinstance(data, list): + print(f" Found {len(data)} items.") + if data: + print(f" Sample: {json.dumps(data[0], indent=2)[:400]}...") + return + print(f" Data: {json.dumps(data, indent=2)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + # 1. Initialize Client + print_section("Initializing Highspot Client") + + config = None + + # Priority 1: OAuth2 + if CLIENT_ID and CLIENT_SECRET: + print(" Using OAuth2 authentication") + try: + token_response = perform_oauth_flow( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + auth_endpoint="https://app.highspot.com/oauth2/authorize", + token_endpoint="https://app.highspot.com/oauth2/token", + redirect_uri=REDIRECT_URI, + scopes=[], + scope_delimiter=" ", + auth_method="header", + ) + access_token = token_response.get("access_token") + if not access_token: + raise Exception("No access_token found in OAuth response") + + config = HighspotOAuthConfig( + access_token=access_token, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + ) + print(" OAuth authentication successful") + except Exception as e: + print(f" OAuth flow failed: {e}") + print(" Falling back to other authentication methods...") + + # Priority 2: Bearer Token + if config is None and ACCESS_TOKEN: + print(" Using Bearer Token authentication") + config = HighspotTokenConfig(token=ACCESS_TOKEN) + + if config is None: + print(" No valid authentication method found.") + print(" Please set one of the following:") + print(" - HIGHSPOT_CLIENT_ID and HIGHSPOT_CLIENT_SECRET (for OAuth2)") + print(" - HIGHSPOT_ACCESS_TOKEN (for Bearer Token)") + return + + client = HighspotClient.build_with_config(config) + data_source = HighspotDataSource(client) + print("Client initialized successfully.") + + try: + # 2. List Spots + print_section("Spots") + spots_resp = await data_source.list_spots(page=1, per_page=10) + print_result("List Spots", spots_resp) + + # 3. List Items + print_section("Items") + items_resp = await data_source.list_items(page=1, per_page=10) + print_result("List Items", items_resp) + + # 4. List Pitches + print_section("Pitches") + pitches_resp = await data_source.list_pitches(page=1, per_page=10) + print_result("List Pitches", pitches_resp) + + # 5. List Groups + print_section("Groups") + groups_resp = await data_source.list_groups(page=1, per_page=10) + print_result("List Groups", groups_resp) + + # 6. List Users + print_section("Users") + users_resp = await data_source.list_users(page=1, per_page=10) + print_result("List Users", users_resp) + + # 7. Content Analytics + print_section("Content Analytics") + content_analytics_resp = await data_source.get_content_analytics() + print_result("Content Analytics", content_analytics_resp) + + # 8. Engagement Analytics + print_section("Engagement Analytics") + engagement_resp = await data_source.get_engagement_analytics() + print_result("Engagement Analytics", engagement_resp) + + finally: + print("\nClosing client connection...") + inner_client = client.get_client() + if hasattr(inner_client, "close"): + await inner_client.close() + + print("\n" + "=" * 80) + print(" All Highspot API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/highspot/highspot.py b/backend/python/app/sources/external/highspot/highspot.py new file mode 100644 index 000000000..978d64031 --- /dev/null +++ b/backend/python/app/sources/external/highspot/highspot.py @@ -0,0 +1,548 @@ +# ruff: noqa +""" +Highspot REST API DataSource - Auto-generated API wrapper + +Generated from Highspot REST API v1 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.highspot.highspot import HighspotClient, HighspotResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class HighspotDataSource: + """Highspot REST API DataSource + + Provides async wrapper methods for Highspot REST API operations: + - Spots management + - Items management + - Pitches management + - Groups management + - Users management + - Analytics (content and engagement) + + All methods return HighspotResponse objects. + """ + + def __init__(self, client: HighspotClient) -> None: + """Initialize with HighspotClient. + + Args: + client: HighspotClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'HighspotDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> HighspotClient: + """Return the underlying HighspotClient.""" + return self._client + + # ----------------------------------------------------------------------- + # Spots + # ----------------------------------------------------------------------- + + async def list_spots( + self, + *, + page: int | None = None, + per_page: int | None = None + ) -> HighspotResponse: + """List all spots + + HTTP GET /spots + + Args: + page: Page number for pagination + per_page: Number of items per page + + Returns: + HighspotResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/spots" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HighspotResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_spots" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return HighspotResponse(success=False, error=str(e), message="Failed to execute list_spots") + + async def get_spot( + self, + spot_id: str + ) -> HighspotResponse: + """Get a specific spot by ID + + HTTP GET /spots/{spot_id} + + Args: + spot_id: The spot ID + + Returns: + HighspotResponse with operation result + """ + url = self.base_url + "/spots/{spot_id}".format(spot_id=spot_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HighspotResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_spot" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return HighspotResponse(success=False, error=str(e), message="Failed to execute get_spot") + + async def get_spot_items( + self, + spot_id: str, + *, + page: int | None = None, + per_page: int | None = None + ) -> HighspotResponse: + """Get items in a specific spot + + HTTP GET /spots/{spot_id}/items + + Args: + spot_id: The spot ID + page: Page number for pagination + per_page: Number of items per page + + Returns: + HighspotResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/spots/{spot_id}/items".format(spot_id=spot_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HighspotResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_spot_items" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return HighspotResponse(success=False, error=str(e), message="Failed to execute get_spot_items") + + # ----------------------------------------------------------------------- + # Items + # ----------------------------------------------------------------------- + + async def list_items( + self, + *, + page: int | None = None, + per_page: int | None = None + ) -> HighspotResponse: + """List all items + + HTTP GET /items + + Args: + page: Page number for pagination + per_page: Number of items per page + + Returns: + HighspotResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/items" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HighspotResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_items" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return HighspotResponse(success=False, error=str(e), message="Failed to execute list_items") + + async def get_item( + self, + item_id: str + ) -> HighspotResponse: + """Get a specific item by ID + + HTTP GET /items/{item_id} + + Args: + item_id: The item ID + + Returns: + HighspotResponse with operation result + """ + url = self.base_url + "/items/{item_id}".format(item_id=item_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HighspotResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_item" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return HighspotResponse(success=False, error=str(e), message="Failed to execute get_item") + + # ----------------------------------------------------------------------- + # Pitches + # ----------------------------------------------------------------------- + + async def list_pitches( + self, + *, + page: int | None = None, + per_page: int | None = None + ) -> HighspotResponse: + """List all pitches + + HTTP GET /pitches + + Args: + page: Page number for pagination + per_page: Number of items per page + + Returns: + HighspotResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/pitches" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HighspotResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_pitches" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return HighspotResponse(success=False, error=str(e), message="Failed to execute list_pitches") + + async def get_pitch( + self, + pitch_id: str + ) -> HighspotResponse: + """Get a specific pitch by ID + + HTTP GET /pitches/{pitch_id} + + Args: + pitch_id: The pitch ID + + Returns: + HighspotResponse with operation result + """ + url = self.base_url + "/pitches/{pitch_id}".format(pitch_id=pitch_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HighspotResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_pitch" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return HighspotResponse(success=False, error=str(e), message="Failed to execute get_pitch") + + # ----------------------------------------------------------------------- + # Groups + # ----------------------------------------------------------------------- + + async def list_groups( + self, + *, + page: int | None = None, + per_page: int | None = None + ) -> HighspotResponse: + """List all groups + + HTTP GET /groups + + Args: + page: Page number for pagination + per_page: Number of items per page + + Returns: + HighspotResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/groups" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HighspotResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_groups" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return HighspotResponse(success=False, error=str(e), message="Failed to execute list_groups") + + async def get_group( + self, + group_id: str + ) -> HighspotResponse: + """Get a specific group by ID + + HTTP GET /groups/{group_id} + + Args: + group_id: The group ID + + Returns: + HighspotResponse with operation result + """ + url = self.base_url + "/groups/{group_id}".format(group_id=group_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HighspotResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_group" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return HighspotResponse(success=False, error=str(e), message="Failed to execute get_group") + + # ----------------------------------------------------------------------- + # Users + # ----------------------------------------------------------------------- + + async def list_users( + self, + *, + page: int | None = None, + per_page: int | None = None + ) -> HighspotResponse: + """List all users + + HTTP GET /users + + Args: + page: Page number for pagination + per_page: Number of items per page + + Returns: + HighspotResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/users" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HighspotResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_users" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return HighspotResponse(success=False, error=str(e), message="Failed to execute list_users") + + async def get_user( + self, + user_id: str + ) -> HighspotResponse: + """Get a specific user by ID + + HTTP GET /users/{user_id} + + Args: + user_id: The user ID + + Returns: + HighspotResponse with operation result + """ + url = self.base_url + "/users/{user_id}".format(user_id=user_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HighspotResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_user" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return HighspotResponse(success=False, error=str(e), message="Failed to execute get_user") + + # ----------------------------------------------------------------------- + # Analytics + # ----------------------------------------------------------------------- + + async def get_content_analytics( + self + ) -> HighspotResponse: + """Get content analytics + + HTTP GET /analytics/content + + Returns: + HighspotResponse with operation result + """ + url = self.base_url + "/analytics/content" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HighspotResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_content_analytics" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return HighspotResponse(success=False, error=str(e), message="Failed to execute get_content_analytics") + + async def get_engagement_analytics( + self + ) -> HighspotResponse: + """Get engagement analytics + + HTTP GET /analytics/engagement + + Returns: + HighspotResponse with operation result + """ + url = self.base_url + "/analytics/engagement" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return HighspotResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_engagement_analytics" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return HighspotResponse(success=False, error=str(e), message="Failed to execute get_engagement_analytics") diff --git a/backend/python/app/sources/external/highspot/run_generator.py b/backend/python/app/sources/external/highspot/run_generator.py new file mode 100644 index 000000000..4bf405e64 --- /dev/null +++ b/backend/python/app/sources/external/highspot/run_generator.py @@ -0,0 +1,24 @@ +"""Runner script to generate the Highspot DataSource wrapper. + +Execute this script to regenerate highspot.py from the endpoint definitions +in code_generator.py. + +Usage: + python -m app.sources.external.highspot.run_generator +""" + +from app.sources.external.highspot.code_generator import generate_datasource + + +def main() -> None: + """Generate the Highspot DataSource file.""" + code = generate_datasource() + output_path = __file__.replace("run_generator.py", "highspot.py") + with open(output_path, "w") as f: + f.write(code) + print(f"Generated Highspot DataSource -> {output_path}") + print(f" Total characters: {len(code)}") + + +if __name__ == "__main__": + main() diff --git a/backend/python/app/sources/external/hubspot/example.py b/backend/python/app/sources/external/hubspot/example.py new file mode 100644 index 000000000..0bd255abe --- /dev/null +++ b/backend/python/app/sources/external/hubspot/example.py @@ -0,0 +1,197 @@ +# ruff: noqa + +""" +HubSpot CRM SDK Usage Examples + +This example demonstrates how to use the HubSpot DataSource (backed by the +official ``hubspot-api-client`` SDK) covering: +- Authentication (OAuth2, Private App Token) +- Initializing the Client and DataSource +- Listing Contacts, Companies, Deals +- Listing Owners + +Prerequisites: +For OAuth2: +1. Create a HubSpot app at https://developers.hubspot.com/ +2. Set HUBSPOT_CLIENT_ID and HUBSPOT_CLIENT_SECRET environment variables +3. The OAuth flow will automatically open a browser for authorization + +For Private App Token: +1. In HubSpot, go to Settings > Integrations > Private Apps +2. Create a Private App with appropriate scopes +3. Set HUBSPOT_ACCESS_TOKEN environment variable + +OAuth Scopes: +- crm.objects.contacts.read +- crm.objects.companies.read +- crm.objects.deals.read +""" + +import asyncio +import json +import os +from typing import Any + +from app.sources.client.hubspot.hubspot import ( + HubSpotClient, + HubSpotOAuthConfig, + HubSpotResponse, + HubSpotTokenConfig, +) +from app.sources.external.hubspot.hubspot import HubSpotDataSource +from app.sources.external.utils.oauth import perform_oauth_flow + +# --- Configuration --- +# OAuth2 credentials (highest priority) +CLIENT_ID = os.getenv("HUBSPOT_CLIENT_ID") +CLIENT_SECRET = os.getenv("HUBSPOT_CLIENT_SECRET") + +# Private App Token (second priority) +ACCESS_TOKEN = os.getenv("HUBSPOT_ACCESS_TOKEN") + +# OAuth redirect URI +REDIRECT_URI = os.getenv("HUBSPOT_REDIRECT_URI", "http://localhost:8080/callback") + + +def print_section(title: str) -> None: + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: HubSpotResponse, show_data: bool = True) -> None: + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data: Any = response.data + # Handle paginated list responses (SDK returns dicts with 'results' key) + if isinstance(data, dict) and "results" in data: + results = data["results"] + print(f" Found {len(results)} results.") + if results: + print(f" Sample: {json.dumps(results[0], indent=2, default=str)[:400]}...") + paging = data.get("paging") + if paging: + print(f" Paging: {json.dumps(paging, indent=2, default=str)[:200]}") + else: + # Generic response + print(f" Data: {json.dumps(data, indent=2, default=str)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + # 1. Initialize Client + print_section("Initializing HubSpot Client") + + config = None + + # Priority 1: OAuth2 + if CLIENT_ID and CLIENT_SECRET: + print(" Using OAuth2 authentication") + try: + print("Starting OAuth flow...") + # HubSpot OAuth authorization URL: + # https://app.hubspot.com/oauth/authorize + # HubSpot token endpoint: + # https://api.hubapi.com/oauth/v1/token + # HubSpot uses "body" auth method (client_id/secret in POST body) + token_response = perform_oauth_flow( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + auth_endpoint="https://app.hubspot.com/oauth/authorize", + token_endpoint="https://api.hubapi.com/oauth/v1/token", + redirect_uri=REDIRECT_URI, + scopes=[ + "crm.objects.contacts.read", + "crm.objects.companies.read", + "crm.objects.deals.read", + ], + scope_delimiter=" ", + auth_method="body", # HubSpot sends credentials in POST body + ) + + access_token = token_response.get("access_token") + if not access_token: + raise Exception("No access_token found in OAuth response") + + config = HubSpotOAuthConfig( + access_token=access_token, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + ) + print(" OAuth authentication successful") + except Exception as e: + print(f" OAuth flow failed: {e}") + print(" Falling back to other authentication methods...") + + # Priority 2: Private App Token + if config is None and ACCESS_TOKEN: + print(" Using Private App Token authentication") + config = HubSpotTokenConfig( + token=ACCESS_TOKEN, + ) + + if config is None: + print(" No valid authentication method found.") + print(" Please set one of the following:") + print(" - HUBSPOT_CLIENT_ID and HUBSPOT_CLIENT_SECRET (for OAuth2)") + print(" - HUBSPOT_ACCESS_TOKEN (for Private App Token)") + return + + client = HubSpotClient.build_with_config(config) + data_source = HubSpotDataSource(client) + print("Client initialized successfully.") + + # 2. List Contacts + print_section("Contacts") + contacts_resp = data_source.list_contacts(limit=10) + print_result("List Contacts", contacts_resp) + + # 3. List Companies + print_section("Companies") + companies_resp = data_source.list_companies(limit=10) + print_result("List Companies", companies_resp) + + # 4. List Deals + print_section("Deals") + deals_resp = data_source.list_deals(limit=10) + print_result("List Deals", deals_resp) + + # 5. List Owners + print_section("Owners") + owners_resp = data_source.list_owners(limit=10) + print_result("List Owners", owners_resp) + + # 6. Get a specific contact if available + if contacts_resp.success and contacts_resp.data: + results = contacts_resp.data.get("results", []) + if results: + contact_id = str(results[0].get("id")) + print_section(f"Contact Details: {contact_id}") + contact_resp = data_source.get_contact( + contact_id=contact_id, + properties=["email", "firstname", "lastname", "phone", "company"], + ) + print_result("Get Contact", contact_resp) + + # 7. List Deal Pipelines + print_section("Deal Pipelines") + pipelines_resp = data_source.list_pipelines(object_type="deals") + print_result("List Deal Pipelines", pipelines_resp) + + # 8. List Contact Properties + print_section("Contact Properties") + props_resp = data_source.list_properties(object_type="contacts") + print_result("List Contact Properties", props_resp) + + print("\n" + "=" * 80) + print(" All HubSpot API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/hubspot/hubspot.py b/backend/python/app/sources/external/hubspot/hubspot.py new file mode 100644 index 000000000..366a68edf --- /dev/null +++ b/backend/python/app/sources/external/hubspot/hubspot.py @@ -0,0 +1,339 @@ +# ruff: noqa +""" +HubSpot CRM SDK DataSource - Auto-generated wrapper + +Generated from hubspot-api-client Python SDK. +All methods call the SDK directly and wrap results in HubSpotResponse. +""" +from __future__ import annotations + +from typing import Any, Optional + +from hubspot.crm.contacts import SimplePublicObjectInputForCreate as ContactCreateInput # type: ignore[import-untyped] +from hubspot.crm.contacts import SimplePublicObjectInput as ContactUpdateInput # type: ignore[import-untyped] +from hubspot.crm.companies import SimplePublicObjectInputForCreate as CompanyCreateInput # type: ignore[import-untyped] +from hubspot.crm.companies import SimplePublicObjectInput as CompanyUpdateInput # type: ignore[import-untyped] +from hubspot.crm.deals import SimplePublicObjectInputForCreate as DealCreateInput # type: ignore[import-untyped] +from hubspot.crm.deals import SimplePublicObjectInput as DealUpdateInput # type: ignore[import-untyped] +from hubspot.crm.tickets import SimplePublicObjectInputForCreate as TicketCreateInput # type: ignore[import-untyped] +from hubspot.crm.objects.notes import SimplePublicObjectInputForCreate as NoteCreateInput # type: ignore[import-untyped] +from hubspot.crm.contacts import PublicObjectSearchRequest as ContactSearchRequest # type: ignore[import-untyped] +from hubspot.crm.companies import PublicObjectSearchRequest as CompanySearchRequest # type: ignore[import-untyped] +from hubspot.crm.deals import PublicObjectSearchRequest as DealSearchRequest # type: ignore[import-untyped] + +from app.sources.client.hubspot.hubspot import HubSpotResponse + + +def _to_dict(obj: object) -> Any: + """Convert an SDK response object to a plain dict/list.""" + if hasattr(obj, "to_dict"): + return obj.to_dict() # type: ignore[reportUnknownMemberType] + return obj + + +class HubSpotDataSource: + """HubSpot CRM SDK DataSource + + Typed wrapper around the official ``hubspot-api-client`` SDK for common + CRM operations: + - Contacts CRUD and search + - Companies CRUD and search + - Deals CRUD and search + - Tickets CRUD + - Notes/Engagements CRUD + - Pipelines and pipeline stages + - Properties management + - Owners management + + Accepts a ``HubSpotClient`` (which exposes ``.get_sdk() -> HubSpot``) or + a raw ``HubSpot`` SDK instance. + + All methods return ``HubSpotResponse`` objects. + """ + + def __init__(self, client_or_sdk: object) -> None: + """Initialize with a HubSpotClient or raw HubSpot SDK instance. + + Args: + client_or_sdk: A ``HubSpotClient`` with ``.get_sdk()`` or a + ``HubSpot`` instance directly. + """ + if hasattr(client_or_sdk, "get_sdk"): + self._sdk: Any = client_or_sdk.get_sdk() # type: ignore[reportUnknownMemberType] + else: + self._sdk = client_or_sdk + + def get_data_source(self) -> "HubSpotDataSource": + """Return the data source instance.""" + return self + + def list_contacts(self, limit: int = 10, after: Optional[str] = None, properties: Optional[list[str]] = None, archived: bool = False) -> HubSpotResponse: + """List contacts with pagination and optional property selection.""" + try: + kwargs: dict[str, Any] = {'limit': limit, 'archived': archived} + if after is not None: + kwargs['after'] = after + if properties is not None: + kwargs['properties'] = properties + result = self._sdk.crm.contacts.basic_api.get_page(**kwargs) + return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully listed contacts') + except Exception as e: + return HubSpotResponse(success=False, error=str(e), message='Failed to execute list_contacts') + def get_contact(self, contact_id: str, properties: Optional[list[str]] = None, archived: bool = False) -> HubSpotResponse: + """Get a single contact by ID.""" + try: + kwargs: dict[str, Any] = {'contact_id': contact_id, 'archived': archived} + if properties is not None: + kwargs['properties'] = properties + result = self._sdk.crm.contacts.basic_api.get_by_id(**kwargs) + return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully retrieved contact') + except Exception as e: + return HubSpotResponse(success=False, error=str(e), message='Failed to execute get_contact') + def create_contact(self, properties: dict[str, str], associations: Optional[list[dict[str, Any]]] = None) -> HubSpotResponse: + """Create a new contact with the given properties.""" + try: + body = ContactCreateInput(properties=properties, associations=associations or []) # type: ignore[reportUnknownVariableType] + result = self._sdk.crm.contacts.basic_api.create(simple_public_object_input_for_create=body) + return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully created contact') + except Exception as e: + return HubSpotResponse(success=False, error=str(e), message='Failed to execute create_contact') + def update_contact(self, contact_id: str, properties: dict[str, str]) -> HubSpotResponse: + """Update an existing contact's properties.""" + try: + body = ContactUpdateInput(properties=properties) # type: ignore[reportUnknownVariableType] + result = self._sdk.crm.contacts.basic_api.update(contact_id=contact_id, simple_public_object_input=body) + return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully updated contact') + except Exception as e: + return HubSpotResponse(success=False, error=str(e), message='Failed to execute update_contact') + def delete_contact(self, contact_id: str) -> HubSpotResponse: + """Archive (soft-delete) a contact by ID.""" + try: + self._sdk.crm.contacts.basic_api.archive(contact_id=contact_id) + return HubSpotResponse(success=True, data={'archived': True}, message='Successfully archived contact') + except Exception as e: + return HubSpotResponse(success=False, error=str(e), message='Failed to execute delete_contact') + def search_contacts(self, filter_groups: Optional[list[dict[str, Any]]] = None, query: Optional[str] = None, properties: Optional[list[str]] = None, sorts: Optional[list[dict[str, Any]]] = None, limit: int = 10, after: int = 0) -> HubSpotResponse: + """Search contacts using filter groups, query string, and sorting.""" + try: + request_body: dict[str, Any] = {'limit': limit, 'after': after} + if filter_groups is not None: + request_body['filter_groups'] = filter_groups + if query is not None: + request_body['query'] = query + if properties is not None: + request_body['properties'] = properties + if sorts is not None: + request_body['sorts'] = sorts + search_request = ContactSearchRequest(**request_body) # type: ignore[reportUnknownVariableType] + result = self._sdk.crm.contacts.search_api.do_search(public_object_search_request=search_request) + return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully searched contacts') + except Exception as e: + return HubSpotResponse(success=False, error=str(e), message='Failed to execute search_contacts') + def list_companies(self, limit: int = 10, after: Optional[str] = None, properties: Optional[list[str]] = None, archived: bool = False) -> HubSpotResponse: + """List companies with pagination and optional property selection.""" + try: + kwargs: dict[str, Any] = {'limit': limit, 'archived': archived} + if after is not None: + kwargs['after'] = after + if properties is not None: + kwargs['properties'] = properties + result = self._sdk.crm.companies.basic_api.get_page(**kwargs) + return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully listed companies') + except Exception as e: + return HubSpotResponse(success=False, error=str(e), message='Failed to execute list_companies') + def get_company(self, company_id: str, properties: Optional[list[str]] = None, archived: bool = False) -> HubSpotResponse: + """Get a single company by ID.""" + try: + kwargs: dict[str, Any] = {'company_id': company_id, 'archived': archived} + if properties is not None: + kwargs['properties'] = properties + result = self._sdk.crm.companies.basic_api.get_by_id(**kwargs) + return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully retrieved company') + except Exception as e: + return HubSpotResponse(success=False, error=str(e), message='Failed to execute get_company') + def create_company(self, properties: dict[str, str], associations: Optional[list[dict[str, Any]]] = None) -> HubSpotResponse: + """Create a new company with the given properties.""" + try: + body = CompanyCreateInput(properties=properties, associations=associations or []) # type: ignore[reportUnknownVariableType] + result = self._sdk.crm.companies.basic_api.create(simple_public_object_input_for_create=body) + return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully created company') + except Exception as e: + return HubSpotResponse(success=False, error=str(e), message='Failed to execute create_company') + def update_company(self, company_id: str, properties: dict[str, str]) -> HubSpotResponse: + """Update an existing company's properties.""" + try: + body = CompanyUpdateInput(properties=properties) # type: ignore[reportUnknownVariableType] + result = self._sdk.crm.companies.basic_api.update(company_id=company_id, simple_public_object_input=body) + return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully updated company') + except Exception as e: + return HubSpotResponse(success=False, error=str(e), message='Failed to execute update_company') + def search_companies(self, filter_groups: Optional[list[dict[str, Any]]] = None, query: Optional[str] = None, properties: Optional[list[str]] = None, sorts: Optional[list[dict[str, Any]]] = None, limit: int = 10, after: int = 0) -> HubSpotResponse: + """Search companies using filter groups, query string, and sorting.""" + try: + request_body: dict[str, Any] = {'limit': limit, 'after': after} + if filter_groups is not None: + request_body['filter_groups'] = filter_groups + if query is not None: + request_body['query'] = query + if properties is not None: + request_body['properties'] = properties + if sorts is not None: + request_body['sorts'] = sorts + search_request = CompanySearchRequest(**request_body) # type: ignore[reportUnknownVariableType] + result = self._sdk.crm.companies.search_api.do_search(public_object_search_request=search_request) + return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully searched companies') + except Exception as e: + return HubSpotResponse(success=False, error=str(e), message='Failed to execute search_companies') + def list_deals(self, limit: int = 10, after: Optional[str] = None, properties: Optional[list[str]] = None, archived: bool = False) -> HubSpotResponse: + """List deals with pagination and optional property selection.""" + try: + kwargs: dict[str, Any] = {'limit': limit, 'archived': archived} + if after is not None: + kwargs['after'] = after + if properties is not None: + kwargs['properties'] = properties + result = self._sdk.crm.deals.basic_api.get_page(**kwargs) + return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully listed deals') + except Exception as e: + return HubSpotResponse(success=False, error=str(e), message='Failed to execute list_deals') + def get_deal(self, deal_id: str, properties: Optional[list[str]] = None, archived: bool = False) -> HubSpotResponse: + """Get a single deal by ID.""" + try: + kwargs: dict[str, Any] = {'deal_id': deal_id, 'archived': archived} + if properties is not None: + kwargs['properties'] = properties + result = self._sdk.crm.deals.basic_api.get_by_id(**kwargs) + return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully retrieved deal') + except Exception as e: + return HubSpotResponse(success=False, error=str(e), message='Failed to execute get_deal') + def create_deal(self, properties: dict[str, str], associations: Optional[list[dict[str, Any]]] = None) -> HubSpotResponse: + """Create a new deal with the given properties.""" + try: + body = DealCreateInput(properties=properties, associations=associations or []) # type: ignore[reportUnknownVariableType] + result = self._sdk.crm.deals.basic_api.create(simple_public_object_input_for_create=body) + return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully created deal') + except Exception as e: + return HubSpotResponse(success=False, error=str(e), message='Failed to execute create_deal') + def update_deal(self, deal_id: str, properties: dict[str, str]) -> HubSpotResponse: + """Update an existing deal's properties.""" + try: + body = DealUpdateInput(properties=properties) # type: ignore[reportUnknownVariableType] + result = self._sdk.crm.deals.basic_api.update(deal_id=deal_id, simple_public_object_input=body) + return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully updated deal') + except Exception as e: + return HubSpotResponse(success=False, error=str(e), message='Failed to execute update_deal') + def search_deals(self, filter_groups: Optional[list[dict[str, Any]]] = None, query: Optional[str] = None, properties: Optional[list[str]] = None, sorts: Optional[list[dict[str, Any]]] = None, limit: int = 10, after: int = 0) -> HubSpotResponse: + """Search deals using filter groups, query string, and sorting.""" + try: + request_body: dict[str, Any] = {'limit': limit, 'after': after} + if filter_groups is not None: + request_body['filter_groups'] = filter_groups + if query is not None: + request_body['query'] = query + if properties is not None: + request_body['properties'] = properties + if sorts is not None: + request_body['sorts'] = sorts + search_request = DealSearchRequest(**request_body) # type: ignore[reportUnknownVariableType] + result = self._sdk.crm.deals.search_api.do_search(public_object_search_request=search_request) + return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully searched deals') + except Exception as e: + return HubSpotResponse(success=False, error=str(e), message='Failed to execute search_deals') + def list_tickets(self, limit: int = 10, after: Optional[str] = None, properties: Optional[list[str]] = None, archived: bool = False) -> HubSpotResponse: + """List tickets with pagination and optional property selection.""" + try: + kwargs: dict[str, Any] = {'limit': limit, 'archived': archived} + if after is not None: + kwargs['after'] = after + if properties is not None: + kwargs['properties'] = properties + result = self._sdk.crm.tickets.basic_api.get_page(**kwargs) + return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully listed tickets') + except Exception as e: + return HubSpotResponse(success=False, error=str(e), message='Failed to execute list_tickets') + def get_ticket(self, ticket_id: str, properties: Optional[list[str]] = None, archived: bool = False) -> HubSpotResponse: + """Get a single ticket by ID.""" + try: + kwargs: dict[str, Any] = {'ticket_id': ticket_id, 'archived': archived} + if properties is not None: + kwargs['properties'] = properties + result = self._sdk.crm.tickets.basic_api.get_by_id(**kwargs) + return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully retrieved ticket') + except Exception as e: + return HubSpotResponse(success=False, error=str(e), message='Failed to execute get_ticket') + def create_ticket(self, properties: dict[str, str], associations: Optional[list[dict[str, Any]]] = None) -> HubSpotResponse: + """Create a new ticket with the given properties.""" + try: + body = TicketCreateInput(properties=properties, associations=associations or []) # type: ignore[reportUnknownVariableType] + result = self._sdk.crm.tickets.basic_api.create(simple_public_object_input_for_create=body) + return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully created ticket') + except Exception as e: + return HubSpotResponse(success=False, error=str(e), message='Failed to execute create_ticket') + def list_notes(self, limit: int = 10, after: Optional[str] = None, properties: Optional[list[str]] = None, archived: bool = False) -> HubSpotResponse: + """List notes/engagements with pagination.""" + try: + kwargs: dict[str, Any] = {'limit': limit, 'archived': archived} + if after is not None: + kwargs['after'] = after + if properties is not None: + kwargs['properties'] = properties + result = self._sdk.crm.objects.notes.basic_api.get_page(**kwargs) + return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully listed notes') + except Exception as e: + return HubSpotResponse(success=False, error=str(e), message='Failed to execute list_notes') + def get_note(self, note_id: str, properties: Optional[list[str]] = None, archived: bool = False) -> HubSpotResponse: + """Get a single note/engagement by ID.""" + try: + kwargs: dict[str, Any] = {'note_id': note_id, 'archived': archived} + if properties is not None: + kwargs['properties'] = properties + result = self._sdk.crm.objects.notes.basic_api.get_by_id(**kwargs) + return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully retrieved note') + except Exception as e: + return HubSpotResponse(success=False, error=str(e), message='Failed to execute get_note') + def create_note(self, properties: dict[str, str], associations: Optional[list[dict[str, Any]]] = None) -> HubSpotResponse: + """Create a new note/engagement with the given properties.""" + try: + body = NoteCreateInput(properties=properties, associations=associations or []) # type: ignore[reportUnknownVariableType] + result = self._sdk.crm.objects.notes.basic_api.create(simple_public_object_input_for_create=body) + return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully created note') + except Exception as e: + return HubSpotResponse(success=False, error=str(e), message='Failed to execute create_note') + def list_pipelines(self, object_type: str) -> HubSpotResponse: + """List all pipelines for an object type (e.g. 'deals', 'tickets').""" + try: + result = self._sdk.crm.pipelines.pipelines_api.get_all(object_type=object_type) + return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully listed pipelines') + except Exception as e: + return HubSpotResponse(success=False, error=str(e), message='Failed to execute list_pipelines') + def get_pipeline(self, object_type: str, pipeline_id: str) -> HubSpotResponse: + """Get a specific pipeline by ID for an object type.""" + try: + result = self._sdk.crm.pipelines.pipelines_api.get_by_id(object_type=object_type, pipeline_id=pipeline_id) + return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully retrieved pipeline') + except Exception as e: + return HubSpotResponse(success=False, error=str(e), message='Failed to execute get_pipeline') + def list_properties(self, object_type: str, archived: bool = False) -> HubSpotResponse: + """List all properties for an object type (e.g. 'contacts', 'companies').""" + try: + result = self._sdk.crm.properties.core_api.get_all(object_type=object_type, archived=archived) + return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully listed properties') + except Exception as e: + return HubSpotResponse(success=False, error=str(e), message='Failed to execute list_properties') + def list_owners(self, limit: int = 100, after: Optional[str] = None, archived: bool = False) -> HubSpotResponse: + """List all owners (users who can be assigned to CRM records).""" + try: + kwargs: dict[str, Any] = {'limit': limit, 'archived': archived} + if after is not None: + kwargs['after'] = after + result = self._sdk.crm.owners.owners_api.get_page(**kwargs) + return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully listed owners') + except Exception as e: + return HubSpotResponse(success=False, error=str(e), message='Failed to execute list_owners') + def get_owner(self, owner_id: str, archived: bool = False) -> HubSpotResponse: + """Get a specific owner by ID.""" + try: + result = self._sdk.crm.owners.owners_api.get_by_id(owner_id=int(owner_id), archived=archived) + return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully retrieved owner') + except Exception as e: + return HubSpotResponse(success=False, error=str(e), message='Failed to execute get_owner') diff --git a/backend/python/app/sources/external/iapsso/code_generator.py b/backend/python/app/sources/external/iapsso/code_generator.py new file mode 100644 index 000000000..f37f78555 --- /dev/null +++ b/backend/python/app/sources/external/iapsso/code_generator.py @@ -0,0 +1,222 @@ +# ruff: noqa +""" +IAP SSO (Google Cloud Identity-Aware Proxy) DataSource Code Generator + +Defines IAP API endpoint specifications and generates the DataSource +wrapper class (iapsso.py) from them. + +Endpoints: + /{resource}:getIamPolicy (POST), /{resource}:setIamPolicy (POST), + /{resource}:testIamPermissions (POST), + /projects/{project}/iap_tunnel/locations/{location}/destGroups, + /projects/{project}/iap_tunnel/locations/{location}/destGroups/{destGroupId}, + /projects/{project}/brands, + /projects/{project}/brands/{brandId}/identityAwareProxyClients +""" + +from __future__ import annotations + +ENDPOINTS = [ + # IAM Policy + {"method": "POST", "path": "/{resource}:getIamPolicy", "name": "get_iam_policy", + "section": "IAM Policy", + "doc": "Get the IAM policy for an IAP-protected resource", + "path_params": ["resource"]}, + {"method": "POST", "path": "/{resource}:setIamPolicy", "name": "set_iam_policy", + "section": "IAM Policy", + "doc": "Set the IAM policy for an IAP-protected resource", + "path_params": ["resource"], + "body_params": [("policy", "policy", "dict[str, Any]", "The IAM policy to set")]}, + {"method": "POST", "path": "/{resource}:testIamPermissions", "name": "test_iam_permissions", + "section": "IAM Policy", + "doc": "Test IAM permissions for an IAP-protected resource", + "path_params": ["resource"], + "body_params": [("permissions", "permissions", "list[str]", "List of permissions to test")]}, + # Tunnel Dest Groups + {"method": "GET", "path": "/projects/{project}/iap_tunnel/locations/{location}/destGroups", + "name": "list_tunnel_dest_groups", "section": "Tunnel Dest Groups", + "doc": "List tunnel destination groups", "path_params": ["project", "location"], + "query_params": [("pageSize", "int", "Maximum number of results per page"), + ("pageToken", "str", "Token for pagination")]}, + {"method": "GET", + "path": "/projects/{project}/iap_tunnel/locations/{location}/destGroups/{dest_group_id}", + "name": "get_tunnel_dest_group", "section": "Tunnel Dest Groups", + "doc": "Get a specific tunnel destination group", + "path_params": ["project", "location", "dest_group_id"]}, + # Brands + {"method": "GET", "path": "/projects/{project}/brands", "name": "list_brands", + "section": "Brands", + "doc": "List OAuth brands for a project", "path_params": ["project"]}, + # Identity-Aware Proxy Clients + {"method": "GET", "path": "/projects/{project}/brands/{brand_id}/identityAwareProxyClients", + "name": "list_iap_clients", "section": "IAP Clients", + "doc": "List Identity-Aware Proxy clients for a brand", + "path_params": ["project", "brand_id"], + "query_params": [("pageSize", "int", "Maximum number of results per page"), + ("pageToken", "str", "Token for pagination")]}, +] + + +def _gen_method(ep: dict) -> str: + """Generate a single async method from an endpoint spec.""" + name = ep["name"] + method = ep["method"] + path = ep["path"] + doc = ep["doc"] + path_params = ep.get("path_params", []) + query_params = ep.get("query_params", []) + body_params = ep.get("body_params", []) + + sig_parts = ["self"] + for p in path_params: + sig_parts.append(f"{p}: str") + for bp in body_params: + sig_parts.append(f"{bp[0]}: {bp[2]}") + if query_params: + sig_parts.append("*") + for qp in query_params: + sig_parts.append(f"{qp[0]}: {qp[1]} | None = None") + + sig = ",\n ".join(sig_parts) + + doc_args = "" + if path_params or query_params or body_params: + doc_args = "\n Args:\n" + for p in path_params: + doc_args += f" {p}: The {p.replace('_', ' ')}\n" + for bp in body_params: + doc_args += f" {bp[0]}: {bp[3]}\n" + for qp in query_params: + doc_args += f" {qp[0]}: {qp[2]}\n" + + query_block = "" + if query_params: + lines = ["\n query_params: dict[str, Any] = {}"] + for qp in query_params: + lines.append(f" if {qp[0]} is not None:") + lines.append(f" query_params['{qp[0]}'] = str({qp[0]})") + query_block = "\n".join(lines) + "\n" + + if path_params: + fmt_args = ", ".join(f"{p}={p}" for p in path_params) + url_line = f' url = self.base_url + "{path}".format({fmt_args})' + else: + url_line = f' url = self.base_url + "{path}"' + + body_block = "" + if body_params: + lines = ["\n body: dict[str, Any] = {}"] + for bp in body_params: + lines.append(f' if {bp[0]} is not None:') + lines.append(f' body["{bp[1]}"] = {bp[0]}') + body_block = "\n".join(lines) + + req_extra = "" + if query_params: + req_extra += "\n query=query_params," + if body_params: + req_extra += "\n body=body," + + return f''' + async def {name}( + {sig} + ) -> IAPSSOResponse: + """{doc} + + HTTP {method} {path} +{doc_args} + Returns: + IAPSSOResponse with operation result + """ +{query_block} +{url_line} +{body_block} + + try: + request = HTTPRequest( + method="{method}", + url=url, + headers={{"Content-Type": "application/json"}},{req_extra} + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return IAPSSOResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed {name}" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {{response.status}}" + ) + except Exception as e: + return IAPSSOResponse(success=False, error=str(e), message="Failed to execute {name}") +''' + + +def generate_datasource() -> str: + """Generate the full IAP SSO DataSource module code.""" + header = '''# ruff: noqa +""" +IAP SSO (Google Cloud Identity-Aware Proxy) DataSource - Auto-generated API wrapper + +Generated from Google Cloud IAP API v1 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.iapsso.iapsso import IAPSSOClient, IAPSSOResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class IAPSSODataSource: + """IAP SSO (Google Cloud Identity-Aware Proxy) DataSource + + Provides async wrapper methods for Google Cloud IAP API operations: + - IAM Policy management (get, set, test permissions) + - Tunnel Destination Groups management + - OAuth Brands management + - Identity-Aware Proxy Clients management + + All methods return IAPSSOResponse objects. + """ + + def __init__(self, client: IAPSSOClient) -> None: + """Initialize with IAPSSOClient. + + Args: + client: IAPSSOClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'IAPSSODataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> IAPSSOClient: + """Return the underlying IAPSSOClient.""" + return self._client +''' + + methods = [] + current_section = None + for ep in ENDPOINTS: + section = ep.get("section", "") + if section and section != current_section: + current_section = section + methods.append(f"\n # {'-' * 71}\n # {section}\n # {'-' * 71}") + methods.append(_gen_method(ep)) + + return header + "\n".join(methods) + "\n" + + +if __name__ == "__main__": + print(generate_datasource()) diff --git a/backend/python/app/sources/external/iapsso/iapsso.py b/backend/python/app/sources/external/iapsso/iapsso.py new file mode 100644 index 000000000..d1263a2ea --- /dev/null +++ b/backend/python/app/sources/external/iapsso/iapsso.py @@ -0,0 +1,358 @@ +# ruff: noqa +""" +IAP SSO (Google Cloud Identity-Aware Proxy) DataSource - Auto-generated API wrapper + +Generated from Google Cloud IAP API v1 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.iapsso.iapsso import IAPSSOClient, IAPSSOResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class IAPSSODataSource: + """IAP SSO (Google Cloud Identity-Aware Proxy) DataSource + + Provides async wrapper methods for Google Cloud IAP API operations: + - IAM Policy management (get, set, test permissions) + - Tunnel Destination Groups management + - OAuth Brands management + - Identity-Aware Proxy Clients management + + All methods return IAPSSOResponse objects. + """ + + def __init__(self, client: IAPSSOClient) -> None: + """Initialize with IAPSSOClient. + + Args: + client: IAPSSOClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'IAPSSODataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> IAPSSOClient: + """Return the underlying IAPSSOClient.""" + return self._client + + # ----------------------------------------------------------------------- + # IAM Policy + # ----------------------------------------------------------------------- + + async def get_iam_policy( + self, + resource: str + ) -> IAPSSOResponse: + """Get the IAM policy for an IAP-protected resource + + HTTP POST /{resource}:getIamPolicy + + Args: + resource: The resource + + Returns: + IAPSSOResponse with operation result + """ + + url = self.base_url + "/{resource}:getIamPolicy".format(resource=resource) + + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return IAPSSOResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_iam_policy" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return IAPSSOResponse(success=False, error=str(e), message="Failed to execute get_iam_policy") + + + async def set_iam_policy( + self, + resource: str, + policy: dict[str, Any] + ) -> IAPSSOResponse: + """Set the IAM policy for an IAP-protected resource + + HTTP POST /{resource}:setIamPolicy + + Args: + resource: The resource + policy: The IAM policy to set + + Returns: + IAPSSOResponse with operation result + """ + + url = self.base_url + "/{resource}:setIamPolicy".format(resource=resource) + + body: dict[str, Any] = {} + if policy is not None: + body["policy"] = policy + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return IAPSSOResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed set_iam_policy" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return IAPSSOResponse(success=False, error=str(e), message="Failed to execute set_iam_policy") + + + async def test_iam_permissions( + self, + resource: str, + permissions: list[str] + ) -> IAPSSOResponse: + """Test IAM permissions for an IAP-protected resource + + HTTP POST /{resource}:testIamPermissions + + Args: + resource: The resource + permissions: List of permissions to test + + Returns: + IAPSSOResponse with operation result + """ + + url = self.base_url + "/{resource}:testIamPermissions".format(resource=resource) + + body: dict[str, Any] = {} + if permissions is not None: + body["permissions"] = permissions + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return IAPSSOResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed test_iam_permissions" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return IAPSSOResponse(success=False, error=str(e), message="Failed to execute test_iam_permissions") + + + # ----------------------------------------------------------------------- + # Tunnel Dest Groups + # ----------------------------------------------------------------------- + + async def list_tunnel_dest_groups( + self, + project: str, + location: str, + *, + pageSize: int | None = None, + pageToken: str | None = None + ) -> IAPSSOResponse: + """List tunnel destination groups + + HTTP GET /projects/{project}/iap_tunnel/locations/{location}/destGroups + + Args: + project: The project + location: The location + pageSize: Maximum number of results per page + pageToken: Token for pagination + + Returns: + IAPSSOResponse with operation result + """ + + query_params: dict[str, Any] = {} + if pageSize is not None: + query_params['pageSize'] = str(pageSize) + if pageToken is not None: + query_params['pageToken'] = str(pageToken) + + url = self.base_url + "/projects/{project}/iap_tunnel/locations/{location}/destGroups".format(project=project, location=location) + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return IAPSSOResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_tunnel_dest_groups" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return IAPSSOResponse(success=False, error=str(e), message="Failed to execute list_tunnel_dest_groups") + + + async def get_tunnel_dest_group( + self, + project: str, + location: str, + dest_group_id: str + ) -> IAPSSOResponse: + """Get a specific tunnel destination group + + HTTP GET /projects/{project}/iap_tunnel/locations/{location}/destGroups/{dest_group_id} + + Args: + project: The project + location: The location + dest_group_id: The dest group id + + Returns: + IAPSSOResponse with operation result + """ + + url = self.base_url + "/projects/{project}/iap_tunnel/locations/{location}/destGroups/{dest_group_id}".format(project=project, location=location, dest_group_id=dest_group_id) + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return IAPSSOResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_tunnel_dest_group" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return IAPSSOResponse(success=False, error=str(e), message="Failed to execute get_tunnel_dest_group") + + + # ----------------------------------------------------------------------- + # Brands + # ----------------------------------------------------------------------- + + async def list_brands( + self, + project: str + ) -> IAPSSOResponse: + """List OAuth brands for a project + + HTTP GET /projects/{project}/brands + + Args: + project: The project + + Returns: + IAPSSOResponse with operation result + """ + + url = self.base_url + "/projects/{project}/brands".format(project=project) + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return IAPSSOResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_brands" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return IAPSSOResponse(success=False, error=str(e), message="Failed to execute list_brands") + + + # ----------------------------------------------------------------------- + # IAP Clients + # ----------------------------------------------------------------------- + + async def list_iap_clients( + self, + project: str, + brand_id: str, + *, + pageSize: int | None = None, + pageToken: str | None = None + ) -> IAPSSOResponse: + """List Identity-Aware Proxy clients for a brand + + HTTP GET /projects/{project}/brands/{brand_id}/identityAwareProxyClients + + Args: + project: The project + brand_id: The brand id + pageSize: Maximum number of results per page + pageToken: Token for pagination + + Returns: + IAPSSOResponse with operation result + """ + + query_params: dict[str, Any] = {} + if pageSize is not None: + query_params['pageSize'] = str(pageSize) + if pageToken is not None: + query_params['pageToken'] = str(pageToken) + + url = self.base_url + "/projects/{project}/brands/{brand_id}/identityAwareProxyClients".format(project=project, brand_id=brand_id) + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return IAPSSOResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_iap_clients" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return IAPSSOResponse(success=False, error=str(e), message="Failed to execute list_iap_clients") + + diff --git a/backend/python/app/sources/external/insided/code_generator.py b/backend/python/app/sources/external/insided/code_generator.py new file mode 100644 index 000000000..715effe3c --- /dev/null +++ b/backend/python/app/sources/external/insided/code_generator.py @@ -0,0 +1,232 @@ +# ruff: noqa +""" +InSided DataSource Code Generator + +Defines InSided API endpoint specifications and generates the DataSource +wrapper class (insided.py) from them. + +Endpoints: + /communities, /communities/{id}, /categories, /categories/{id}, + /topics, /topics/{id}, /posts, /posts/{id}, /users, /users/{id}, + /groups, /groups/{id}, /search +""" + +from __future__ import annotations + +ENDPOINTS = [ + # Communities + {"method": "GET", "path": "/communities", "name": "get_communities", "section": "Communities", + "doc": "List all communities", "paginated": True}, + {"method": "GET", "path": "/communities/{community_id}", "name": "get_community", "section": "Communities", + "doc": "Get a specific community by ID", "path_params": ["community_id"]}, + # Categories + {"method": "GET", "path": "/categories", "name": "get_categories", "section": "Categories", + "doc": "List all categories", "paginated": True}, + {"method": "GET", "path": "/categories/{category_id}", "name": "get_category", "section": "Categories", + "doc": "Get a specific category by ID", "path_params": ["category_id"]}, + # Topics + {"method": "GET", "path": "/topics", "name": "get_topics", "section": "Topics", + "doc": "List all topics", "paginated": True}, + {"method": "GET", "path": "/topics/{topic_id}", "name": "get_topic", "section": "Topics", + "doc": "Get a specific topic by ID", "path_params": ["topic_id"]}, + # Posts + {"method": "GET", "path": "/posts", "name": "get_posts", "section": "Posts", + "doc": "List all posts", "paginated": True}, + {"method": "GET", "path": "/posts/{post_id}", "name": "get_post", "section": "Posts", + "doc": "Get a specific post by ID", "path_params": ["post_id"]}, + # Users + {"method": "GET", "path": "/users", "name": "get_users", "section": "Users", + "doc": "List all users", "paginated": True}, + {"method": "GET", "path": "/users/{user_id}", "name": "get_user", "section": "Users", + "doc": "Get a specific user by ID", "path_params": ["user_id"]}, + # Groups + {"method": "GET", "path": "/groups", "name": "get_groups", "section": "Groups", + "doc": "List all groups", "paginated": True}, + {"method": "GET", "path": "/groups/{group_id}", "name": "get_group", "section": "Groups", + "doc": "Get a specific group by ID", "path_params": ["group_id"]}, + # Search + {"method": "GET", "path": "/search", "name": "search", "section": "Search", + "doc": "Search across communities content", + "query_params": [("q", "q", "str", "Search query string")], "paginated": True}, +] + + +def _gen_method(ep: dict) -> str: + """Generate a single async method from an endpoint spec.""" + name = ep["name"] + method = ep["method"] + path = ep["path"] + doc = ep["doc"] + path_params = ep.get("path_params", []) + paginated = ep.get("paginated", False) + body_params = ep.get("body_params", []) + query_extra = ep.get("query_params", []) + + sig_parts = ["self"] + for p in path_params: + sig_parts.append(f"{p}: str") + for qp in query_extra: + sig_parts.append(f"{qp[0]}: {qp[2]}") + for bp in body_params: + sig_parts.append(f"{bp[0]}: {bp[2]}") + if paginated or query_extra: + if "*" not in sig_parts: + sig_parts.append("*") + if paginated: + sig_parts.append("limit: int | None = None") + sig_parts.append("offset: int | None = None") + + sig = ",\n ".join(sig_parts) + + doc_args = "" + if path_params or paginated or body_params or query_extra: + doc_args = "\n Args:\n" + for p in path_params: + doc_args += f" {p}: The {p.replace('_', ' ')}\n" + for qp in query_extra: + doc_args += f" {qp[0]}: {qp[3]}\n" + for bp in body_params: + doc_args += f" {bp[0]}: {bp[3]}\n" + if paginated: + doc_args += " limit: Maximum number of results to return\n" + doc_args += " offset: Number of results to skip\n" + + query_block = "" + if paginated or query_extra: + lines = ["", " query_params: dict[str, Any] = {}"] + for qp in query_extra: + lines.append(f" query_params['{qp[1]}'] = {qp[0]}") + if paginated: + lines.append(" if limit is not None:") + lines.append(" query_params['limit'] = str(limit)") + lines.append(" if offset is not None:") + lines.append(" query_params['offset'] = str(offset)") + query_block = "\n".join(lines) + "\n" + + if path_params: + fmt_args = ", ".join(f"{p}={p}" for p in path_params) + url_line = f' url = self.base_url + "{path}".format({fmt_args})' + else: + url_line = f' url = self.base_url + "{path}"' + + body_block = "" + if body_params: + lines = ["\n body: dict[str, Any] = {"] + for bp in body_params: + lines.append(f' "{bp[1]}": {bp[0]},') + lines.append(" }") + body_block = "\n".join(lines) + + req_extra = "" + if paginated or query_extra: + req_extra += "\n query=query_params," + if body_params: + req_extra += "\n body=body," + + return f''' + async def {name}( + {sig}, + ) -> InSidedResponse: + """{doc} + + HTTP {method} {path} +{doc_args} + Returns: + InSidedResponse with operation result + """ +{query_block} +{url_line} + + try: + request = HTTPRequest( + method="{method}", + url=url, + headers={{"Content-Type": "application/json"}},{req_extra} + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return InSidedResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed {name}" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {{response.status}}" + ) + except Exception as e: + return InSidedResponse(success=False, error=str(e), message="Failed to execute {name}") +''' + + +def generate_datasource() -> str: + """Generate the full InSided DataSource module code.""" + header = '''# ruff: noqa +""" +InSided (Gainsight Customer Communities) REST API DataSource - Auto-generated API wrapper + +Generated from InSided REST API v2 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.insided.insided import InSidedClient, InSidedResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class InSidedDataSource: + """InSided REST API DataSource + + Provides async wrapper methods for InSided REST API operations: + - Communities management + - Categories management + - Topics management + - Posts management + - Users management + - Groups management + - Search + + The base URL is https://api.insided.com/v2. + + All methods return InSidedResponse objects. + """ + + def __init__(self, client: InSidedClient) -> None: + """Initialize with InSidedClient. + + Args: + client: InSidedClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'InSidedDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> InSidedClient: + """Return the underlying InSidedClient.""" + return self._client +''' + + methods = [] + current_section = None + for ep in ENDPOINTS: + section = ep.get("section", "") + if section and section != current_section: + current_section = section + methods.append(f"\n # {'-' * 71}\n # {section}\n # {'-' * 71}") + methods.append(_gen_method(ep)) + + return header + "\n".join(methods) + "\n" + + +if __name__ == "__main__": + print(generate_datasource()) diff --git a/backend/python/app/sources/external/insided/example.py b/backend/python/app/sources/external/insided/example.py new file mode 100644 index 000000000..f53e147b1 --- /dev/null +++ b/backend/python/app/sources/external/insided/example.py @@ -0,0 +1,166 @@ +# ruff: noqa + +""" +InSided (Gainsight Customer Communities) API Usage Examples + +This example demonstrates how to use the InSided DataSource to interact with +the InSided API v2, covering: +- Authentication (OAuth2 client_credentials, Bearer Token) +- Initializing the Client and DataSource +- Listing Communities, Categories, Topics, Posts +- Fetching Users and Groups +- Searching content + +Prerequisites: +For OAuth2 client_credentials: +1. Obtain client_id and client_secret from InSided admin panel +2. Set INSIDED_CLIENT_ID and INSIDED_CLIENT_SECRET environment variables + +For Bearer Token: +1. Obtain a valid API token from InSided +2. Set INSIDED_TOKEN environment variable +""" + +import asyncio +import json +import os + +from app.sources.client.insided.insided import ( + InSidedClient, + InSidedClientCredentialsConfig, + InSidedTokenConfig, + InSidedResponse, +) +from app.sources.external.insided.insided import InSidedDataSource + +# --- Configuration --- +CLIENT_ID = os.getenv("INSIDED_CLIENT_ID") +CLIENT_SECRET = os.getenv("INSIDED_CLIENT_SECRET") +TOKEN = os.getenv("INSIDED_TOKEN") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: InSidedResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + if isinstance(data, list): + print(f" Found {len(data)} items.") + if data: + print(f" Sample: {json.dumps(data[0], indent=2)[:400]}...") + elif isinstance(data, dict): + for key in ("communities", "categories", "topics", "posts", + "users", "groups", "results"): + if key in data: + items = data[key] + print(f" Found {len(items)} {key}.") + if items: + print(f" Sample: {json.dumps(items[0], indent=2)[:400]}...") + return + print(f" Data: {json.dumps(data, indent=2)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + # 1. Initialize Client + print_section("Initializing InSided Client") + + config = None + + # Priority 1: OAuth2 client_credentials + if CLIENT_ID and CLIENT_SECRET: + print(" Using OAuth2 client_credentials authentication") + config = InSidedClientCredentialsConfig( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + ) + + # Priority 2: Bearer Token + if config is None and TOKEN: + print(" Using Bearer Token authentication") + config = InSidedTokenConfig(token=TOKEN) + + if config is None: + print(" No valid authentication method found.") + print(" Please set one of the following:") + print(" - INSIDED_CLIENT_ID and INSIDED_CLIENT_SECRET (for OAuth2)") + print(" - INSIDED_TOKEN (for Bearer Token)") + return + + client = InSidedClient.build_with_config(config) + data_source = InSidedDataSource(client) + print("Client initialized successfully.") + + try: + # 2. Get Communities + print_section("Communities") + communities_resp = await data_source.get_communities(limit=10) + print_result("Get Communities", communities_resp) + + community_id = None + if communities_resp.success and communities_resp.data: + data = communities_resp.data + items = data if isinstance(data, list) else data.get("communities", []) if isinstance(data, dict) else [] + if items: + community_id = str(items[0].get("id")) + print(f" Using Community ID: {community_id}") + + if community_id: + print_section("Community Details") + community_resp = await data_source.get_community(community_id) + print_result("Get Community", community_resp) + + # 3. Get Categories + print_section("Categories") + categories_resp = await data_source.get_categories(limit=10) + print_result("Get Categories", categories_resp) + + # 4. Get Topics + print_section("Topics") + topics_resp = await data_source.get_topics(limit=10) + print_result("Get Topics", topics_resp) + + # 5. Get Posts + print_section("Posts") + posts_resp = await data_source.get_posts(limit=10) + print_result("Get Posts", posts_resp) + + # 6. Get Users + print_section("Users") + users_resp = await data_source.get_users(limit=10) + print_result("Get Users", users_resp) + + # 7. Get Groups + print_section("Groups") + groups_resp = await data_source.get_groups(limit=10) + print_result("Get Groups", groups_resp) + + # 8. Search + print_section("Search") + search_resp = await data_source.search(q="help", limit=10) + print_result("Search", search_resp) + + finally: + # Cleanup: Close the HTTP client session + print("\nClosing client connection...") + inner_client = client.get_client() + if hasattr(inner_client, "close"): + await inner_client.close() + + print("\n" + "=" * 80) + print(" All InSided API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/insided/insided.py b/backend/python/app/sources/external/insided/insided.py new file mode 100644 index 000000000..00059f2ab --- /dev/null +++ b/backend/python/app/sources/external/insided/insided.py @@ -0,0 +1,547 @@ +# ruff: noqa +""" +InSided (Gainsight Customer Communities) REST API DataSource - Auto-generated API wrapper + +Generated from InSided REST API v2 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.insided.insided import InSidedClient, InSidedResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class InSidedDataSource: + """InSided REST API DataSource + + Provides async wrapper methods for InSided REST API operations: + - Communities management + - Categories management + - Topics management + - Posts management + - Users management + - Groups management + - Search + + The base URL is https://api.insided.com/v2. + + All methods return InSidedResponse objects. + """ + + def __init__(self, client: InSidedClient) -> None: + """Initialize with InSidedClient. + + Args: + client: InSidedClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'InSidedDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> InSidedClient: + """Return the underlying InSidedClient.""" + return self._client + + # ----------------------------------------------------------------------- + # Communities + # ----------------------------------------------------------------------- + + async def get_communities( + self, + *, + limit: int | None = None, + offset: int | None = None, + ) -> InSidedResponse: + """List all communities. + + Args: + limit: Maximum number of results to return + offset: Number of results to skip + + Returns: + InSidedResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/communities" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return InSidedResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_communities" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return InSidedResponse(success=False, error=str(e), message="Failed to execute get_communities") + + async def get_community( + self, + community_id: str, + ) -> InSidedResponse: + """Get a specific community by ID. + + Args: + community_id: The community ID + + Returns: + InSidedResponse with operation result + """ + url = self.base_url + "/communities/{community_id}".format(community_id=community_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return InSidedResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_community" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return InSidedResponse(success=False, error=str(e), message="Failed to execute get_community") + + # ----------------------------------------------------------------------- + # Categories + # ----------------------------------------------------------------------- + + async def get_categories( + self, + *, + limit: int | None = None, + offset: int | None = None, + ) -> InSidedResponse: + """List all categories. + + Args: + limit: Maximum number of results to return + offset: Number of results to skip + + Returns: + InSidedResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/categories" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return InSidedResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_categories" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return InSidedResponse(success=False, error=str(e), message="Failed to execute get_categories") + + async def get_category( + self, + category_id: str, + ) -> InSidedResponse: + """Get a specific category by ID. + + Args: + category_id: The category ID + + Returns: + InSidedResponse with operation result + """ + url = self.base_url + "/categories/{category_id}".format(category_id=category_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return InSidedResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_category" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return InSidedResponse(success=False, error=str(e), message="Failed to execute get_category") + + # ----------------------------------------------------------------------- + # Topics + # ----------------------------------------------------------------------- + + async def get_topics( + self, + *, + limit: int | None = None, + offset: int | None = None, + ) -> InSidedResponse: + """List all topics. + + Args: + limit: Maximum number of results to return + offset: Number of results to skip + + Returns: + InSidedResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/topics" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return InSidedResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_topics" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return InSidedResponse(success=False, error=str(e), message="Failed to execute get_topics") + + async def get_topic( + self, + topic_id: str, + ) -> InSidedResponse: + """Get a specific topic by ID. + + Args: + topic_id: The topic ID + + Returns: + InSidedResponse with operation result + """ + url = self.base_url + "/topics/{topic_id}".format(topic_id=topic_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return InSidedResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_topic" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return InSidedResponse(success=False, error=str(e), message="Failed to execute get_topic") + + # ----------------------------------------------------------------------- + # Posts + # ----------------------------------------------------------------------- + + async def get_posts( + self, + *, + limit: int | None = None, + offset: int | None = None, + ) -> InSidedResponse: + """List all posts. + + Args: + limit: Maximum number of results to return + offset: Number of results to skip + + Returns: + InSidedResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/posts" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return InSidedResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_posts" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return InSidedResponse(success=False, error=str(e), message="Failed to execute get_posts") + + async def get_post( + self, + post_id: str, + ) -> InSidedResponse: + """Get a specific post by ID. + + Args: + post_id: The post ID + + Returns: + InSidedResponse with operation result + """ + url = self.base_url + "/posts/{post_id}".format(post_id=post_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return InSidedResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_post" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return InSidedResponse(success=False, error=str(e), message="Failed to execute get_post") + + # ----------------------------------------------------------------------- + # Users + # ----------------------------------------------------------------------- + + async def get_users( + self, + *, + limit: int | None = None, + offset: int | None = None, + ) -> InSidedResponse: + """List all users. + + Args: + limit: Maximum number of results to return + offset: Number of results to skip + + Returns: + InSidedResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/users" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return InSidedResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_users" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return InSidedResponse(success=False, error=str(e), message="Failed to execute get_users") + + async def get_user( + self, + user_id: str, + ) -> InSidedResponse: + """Get a specific user by ID. + + Args: + user_id: The user ID + + Returns: + InSidedResponse with operation result + """ + url = self.base_url + "/users/{user_id}".format(user_id=user_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return InSidedResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_user" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return InSidedResponse(success=False, error=str(e), message="Failed to execute get_user") + + # ----------------------------------------------------------------------- + # Groups + # ----------------------------------------------------------------------- + + async def get_groups( + self, + *, + limit: int | None = None, + offset: int | None = None, + ) -> InSidedResponse: + """List all groups. + + Args: + limit: Maximum number of results to return + offset: Number of results to skip + + Returns: + InSidedResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/groups" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return InSidedResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_groups" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return InSidedResponse(success=False, error=str(e), message="Failed to execute get_groups") + + async def get_group( + self, + group_id: str, + ) -> InSidedResponse: + """Get a specific group by ID. + + Args: + group_id: The group ID + + Returns: + InSidedResponse with operation result + """ + url = self.base_url + "/groups/{group_id}".format(group_id=group_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return InSidedResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_group" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return InSidedResponse(success=False, error=str(e), message="Failed to execute get_group") + + # ----------------------------------------------------------------------- + # Search + # ----------------------------------------------------------------------- + + async def search( + self, + q: str, + *, + limit: int | None = None, + offset: int | None = None, + ) -> InSidedResponse: + """Search across communities content. + + Args: + q: Search query string + limit: Maximum number of results to return + offset: Number of results to skip + + Returns: + InSidedResponse with operation result + """ + query_params: dict[str, Any] = {'q': q} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/search" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return InSidedResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed search" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return InSidedResponse(success=False, error=str(e), message="Failed to execute search") diff --git a/backend/python/app/sources/external/insided/run_generator.py b/backend/python/app/sources/external/insided/run_generator.py new file mode 100644 index 000000000..99a16d177 --- /dev/null +++ b/backend/python/app/sources/external/insided/run_generator.py @@ -0,0 +1,24 @@ +"""Runner script to generate the InSided DataSource wrapper. + +Execute this script to regenerate insided.py from the endpoint definitions +in code_generator.py. + +Usage: + python -m app.sources.external.insided.run_generator +""" + +from app.sources.external.insided.code_generator import generate_datasource + + +def main() -> None: + """Generate the InSided DataSource file.""" + code = generate_datasource() + output_path = __file__.replace("run_generator.py", "insided.py") + with open(output_path, "w") as f: + f.write(code) + print(f"Generated InSided DataSource -> {output_path}") + print(f" Total characters: {len(code)}") + + +if __name__ == "__main__": + main() diff --git a/backend/python/app/sources/external/interact/code_generator.py b/backend/python/app/sources/external/interact/code_generator.py new file mode 100644 index 000000000..f42fb53fc --- /dev/null +++ b/backend/python/app/sources/external/interact/code_generator.py @@ -0,0 +1,246 @@ +# ruff: noqa +""" +Interact (Interact Intranet) DataSource Code Generator + +Defines Interact API endpoint specifications and generates the DataSource +wrapper class (interact.py) from them. + +Endpoints: + /users, /users/{id}, /content, /content/{id}, /pages, /pages/{id}, + /news, /news/{id}, /communities, /communities/{id}, /events, /events/{id}, + /search +""" + +from __future__ import annotations + +ENDPOINTS = [ + # Users + {"method": "GET", "path": "/users", "name": "get_users", "section": "Users", + "doc": "List all users", "paginated": True}, + {"method": "GET", "path": "/users/{user_id}", "name": "get_user", "section": "Users", + "doc": "Get a specific user by ID", "path_params": ["user_id"]}, + # Content + {"method": "GET", "path": "/content", "name": "get_content_list", "section": "Content", + "doc": "List all content items", "paginated": True}, + {"method": "GET", "path": "/content/{content_id}", "name": "get_content", "section": "Content", + "doc": "Get a specific content item by ID", "path_params": ["content_id"]}, + # Pages + {"method": "GET", "path": "/pages", "name": "get_pages", "section": "Pages", + "doc": "List all pages", "paginated": True}, + {"method": "GET", "path": "/pages/{page_id}", "name": "get_page", "section": "Pages", + "doc": "Get a specific page by ID", "path_params": ["page_id"]}, + # News + {"method": "GET", "path": "/news", "name": "get_news_list", "section": "News", + "doc": "List all news items", "paginated": True}, + {"method": "GET", "path": "/news/{news_id}", "name": "get_news", "section": "News", + "doc": "Get a specific news item by ID", "path_params": ["news_id"]}, + # Communities + {"method": "GET", "path": "/communities", "name": "get_communities", "section": "Communities", + "doc": "List all communities", "paginated": True}, + {"method": "GET", "path": "/communities/{community_id}", "name": "get_community", "section": "Communities", + "doc": "Get a specific community by ID", "path_params": ["community_id"]}, + # Events + {"method": "GET", "path": "/events", "name": "get_events", "section": "Events", + "doc": "List all events", "paginated": True}, + {"method": "GET", "path": "/events/{event_id}", "name": "get_event", "section": "Events", + "doc": "Get a specific event by ID", "path_params": ["event_id"]}, + # Search + {"method": "GET", "path": "/search", "name": "search", "section": "Search", + "doc": "Search across Interact intranet content", + "query_params": [("q", "q", "str", "Search query string")], + "extra_query": [("type", "type", "str | None", "Filter by content type", True), + ("limit", "limit", "int | None", "Maximum number of results to return", True), + ("offset", "offset", "int | None", "Number of results to skip", True)]}, +] + + +def _gen_method(ep: dict) -> str: + """Generate a single async method from an endpoint spec.""" + name = ep["name"] + method = ep["method"] + path = ep["path"] + doc = ep["doc"] + path_params = ep.get("path_params", []) + paginated = ep.get("paginated", False) + body_params = ep.get("body_params", []) + query_params = ep.get("query_params", []) + extra_query = ep.get("extra_query", []) + has_query = paginated or query_params or extra_query + + sig_parts = ["self"] + for p in path_params: + sig_parts.append(f"{p}: str") + for qp in query_params: + sig_parts.append(f"{qp[0]}: {qp[2]}") + for bp in body_params: + sig_parts.append(f"{bp[0]}: {bp[2]}") + if paginated or extra_query: + sig_parts.append("*") + if paginated: + sig_parts.append("limit: int | None = None") + sig_parts.append("offset: int | None = None") + for eq in extra_query: + sig_parts.append(f"{eq[0]}: {eq[2]} = None") + + sig = ",\n ".join(sig_parts) + + doc_args = "" + if path_params or paginated or body_params or query_params or extra_query: + doc_args = "\n Args:\n" + for p in path_params: + doc_args += f" {p}: The {p.replace('_', ' ')}\n" + for qp in query_params: + doc_args += f" {qp[0]}: {qp[3]}\n" + for bp in body_params: + doc_args += f" {bp[0]}: {bp[3]}\n" + if paginated: + doc_args += " limit: Maximum number of results to return\n" + doc_args += " offset: Number of results to skip\n" + for eq in extra_query: + doc_args += f" {eq[0]}: {eq[3]}\n" + + query_block = "" + if has_query: + lines = ["", " query_params: dict[str, Any] = {}"] + for qp in query_params: + lines.append(f" query_params['{qp[1]}'] = {qp[0]}") + if paginated: + lines.append(" if limit is not None:") + lines.append(" query_params['limit'] = str(limit)") + lines.append(" if offset is not None:") + lines.append(" query_params['offset'] = str(offset)") + for eq in extra_query: + lines.append(f" if {eq[0]} is not None:") + if "int" in eq[2]: + lines.append(f" query_params['{eq[1]}'] = str({eq[0]})") + else: + lines.append(f" query_params['{eq[1]}'] = {eq[0]}") + query_block = "\n".join(lines) + "\n" + + if path_params: + fmt_args = ", ".join(f"{p}={p}" for p in path_params) + url_line = f' url = self.base_url + "{path}".format({fmt_args})' + else: + url_line = f' url = self.base_url + "{path}"' + + body_block = "" + if body_params: + lines = ["\n body: dict[str, Any] = {"] + for bp in body_params: + lines.append(f' "{bp[1]}": {bp[0]},') + lines.append(" }") + body_block = "\n".join(lines) + + req_extra = "" + if has_query: + req_extra += "\n query=query_params," + if body_params: + req_extra += "\n body=body," + + return f''' + async def {name}( + {sig}, + ) -> InteractResponse: + """{doc} + + HTTP {method} {path} +{doc_args} + Returns: + InteractResponse with operation result + """ +{query_block} +{url_line} + + try: + request = HTTPRequest( + method="{method}", + url=url, + headers={{"Content-Type": "application/json"}},{req_extra} + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return InteractResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed {name}" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {{response.status}}" + ) + except Exception as e: + return InteractResponse(success=False, error=str(e), message="Failed to execute {name}") +''' + + +def generate_datasource() -> str: + """Generate the full Interact DataSource module code.""" + header = '''# ruff: noqa +""" +Interact (Interact Intranet) REST API DataSource - Auto-generated API wrapper + +Generated from Interact REST API v1 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.interact.interact import InteractClient, InteractResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class InteractDataSource: + """Interact REST API DataSource + + Provides async wrapper methods for Interact REST API operations: + - Users management + - Content management + - Pages management + - News management + - Communities management + - Events management + - Search + + The base URL is https://api.interact-intranet.com/v1. + + All methods return InteractResponse objects. + """ + + def __init__(self, client: InteractClient) -> None: + """Initialize with InteractClient. + + Args: + client: InteractClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'InteractDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> InteractClient: + """Return the underlying InteractClient.""" + return self._client +''' + + methods = [] + current_section = None + for ep in ENDPOINTS: + section = ep.get("section", "") + if section and section != current_section: + current_section = section + methods.append(f"\n # {'-' * 71}\n # {section}\n # {'-' * 71}") + methods.append(_gen_method(ep)) + + return header + "\n".join(methods) + "\n" + + +if __name__ == "__main__": + print(generate_datasource()) diff --git a/backend/python/app/sources/external/interact/example.py b/backend/python/app/sources/external/interact/example.py new file mode 100644 index 000000000..e3893c039 --- /dev/null +++ b/backend/python/app/sources/external/interact/example.py @@ -0,0 +1,190 @@ +# ruff: noqa + +""" +Interact (Interact Intranet) API Usage Examples + +This example demonstrates how to use the Interact DataSource to interact with +the Interact API v1, covering: +- Authentication (OAuth2 authorization code, API Key / Bearer Token) +- Initializing the Client and DataSource +- Listing Users, Content, Pages, News +- Fetching Communities and Events +- Searching content + +Prerequisites: +For OAuth2: +1. Register an OAuth2 application in Interact admin settings +2. Set INTERACT_CLIENT_ID and INTERACT_CLIENT_SECRET environment variables +3. The OAuth flow will automatically open a browser for authorization + +For API Key: +1. Obtain an API key from Interact admin settings +2. Set INTERACT_API_KEY environment variable +""" + +import asyncio +import json +import os + +from app.sources.client.interact.interact import ( + InteractClient, + InteractOAuthConfig, + InteractTokenConfig, + InteractResponse, +) +from app.sources.external.interact.interact import InteractDataSource +from app.sources.external.utils.oauth import perform_oauth_flow + +# --- Configuration --- +CLIENT_ID = os.getenv("INTERACT_CLIENT_ID") +CLIENT_SECRET = os.getenv("INTERACT_CLIENT_SECRET") +API_KEY = os.getenv("INTERACT_API_KEY") +REDIRECT_URI = os.getenv("INTERACT_REDIRECT_URI", "http://localhost:8080/callback") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: InteractResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + if isinstance(data, list): + print(f" Found {len(data)} items.") + if data: + print(f" Sample: {json.dumps(data[0], indent=2)[:400]}...") + elif isinstance(data, dict): + for key in ("users", "content", "pages", "news", + "communities", "events", "results"): + if key in data: + items = data[key] + print(f" Found {len(items)} {key}.") + if items: + print(f" Sample: {json.dumps(items[0], indent=2)[:400]}...") + return + print(f" Data: {json.dumps(data, indent=2)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + # 1. Initialize Client + print_section("Initializing Interact Client") + + config = None + + # Priority 1: OAuth2 + if CLIENT_ID and CLIENT_SECRET: + print(" Using OAuth2 authentication") + try: + token_response = perform_oauth_flow( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + auth_endpoint="https://app.interact-intranet.com/oauth/authorize", + token_endpoint="https://app.interact-intranet.com/oauth/token", + redirect_uri=REDIRECT_URI, + scopes=[], + scope_delimiter=" ", + auth_method="body", + ) + + access_token = token_response.get("access_token") + if not access_token: + raise Exception("No access_token found in OAuth response") + + config = InteractOAuthConfig( + access_token=access_token, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + ) + print(" OAuth authentication successful") + except Exception as e: + print(f" OAuth flow failed: {e}") + print(" Falling back to other authentication methods...") + + # Priority 2: API Key + if config is None and API_KEY: + print(" Using API Key authentication") + config = InteractTokenConfig(token=API_KEY) + + if config is None: + print(" No valid authentication method found.") + print(" Please set one of the following:") + print(" - INTERACT_CLIENT_ID and INTERACT_CLIENT_SECRET (for OAuth2)") + print(" - INTERACT_API_KEY (for API Key)") + return + + client = InteractClient.build_with_config(config) + data_source = InteractDataSource(client) + print("Client initialized successfully.") + + try: + # 2. Get Users + print_section("Users") + users_resp = await data_source.get_users(limit=10) + print_result("Get Users", users_resp) + + user_id = None + if users_resp.success and users_resp.data: + data = users_resp.data + items = data if isinstance(data, list) else data.get("users", []) if isinstance(data, dict) else [] + if items: + user_id = str(items[0].get("id")) + print(f" Using User ID: {user_id}") + + if user_id: + print_section("User Details") + user_resp = await data_source.get_user(user_id) + print_result("Get User", user_resp) + + # 3. Get Content + print_section("Content") + content_resp = await data_source.get_content_list(limit=10) + print_result("Get Content", content_resp) + + # 4. Get Pages + print_section("Pages") + pages_resp = await data_source.get_pages(limit=10) + print_result("Get Pages", pages_resp) + + # 5. Get News + print_section("News") + news_resp = await data_source.get_news_list(limit=10) + print_result("Get News", news_resp) + + # 6. Get Communities + print_section("Communities") + communities_resp = await data_source.get_communities(limit=10) + print_result("Get Communities", communities_resp) + + # 7. Get Events + print_section("Events") + events_resp = await data_source.get_events(limit=10) + print_result("Get Events", events_resp) + + # 8. Search + print_section("Search") + search_resp = await data_source.search(q="welcome", limit=10) + print_result("Search", search_resp) + + finally: + # Cleanup: Close the HTTP client session + print("\nClosing client connection...") + inner_client = client.get_client() + if hasattr(inner_client, "close"): + await inner_client.close() + + print("\n" + "=" * 80) + print(" All Interact API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/interact/interact.py b/backend/python/app/sources/external/interact/interact.py new file mode 100644 index 000000000..b8796cac7 --- /dev/null +++ b/backend/python/app/sources/external/interact/interact.py @@ -0,0 +1,551 @@ +# ruff: noqa +""" +Interact (Interact Intranet) REST API DataSource - Auto-generated API wrapper + +Generated from Interact REST API v1 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.interact.interact import InteractClient, InteractResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class InteractDataSource: + """Interact REST API DataSource + + Provides async wrapper methods for Interact REST API operations: + - Users management + - Content management + - Pages management + - News management + - Communities management + - Events management + - Search + + The base URL is https://api.interact-intranet.com/v1. + + All methods return InteractResponse objects. + """ + + def __init__(self, client: InteractClient) -> None: + """Initialize with InteractClient. + + Args: + client: InteractClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'InteractDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> InteractClient: + """Return the underlying InteractClient.""" + return self._client + + # ----------------------------------------------------------------------- + # Users + # ----------------------------------------------------------------------- + + async def get_users( + self, + *, + limit: int | None = None, + offset: int | None = None, + ) -> InteractResponse: + """List all users. + + Args: + limit: Maximum number of results to return + offset: Number of results to skip + + Returns: + InteractResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/users" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return InteractResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_users" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return InteractResponse(success=False, error=str(e), message="Failed to execute get_users") + + async def get_user( + self, + user_id: str, + ) -> InteractResponse: + """Get a specific user by ID. + + Args: + user_id: The user ID + + Returns: + InteractResponse with operation result + """ + url = self.base_url + "/users/{user_id}".format(user_id=user_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return InteractResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_user" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return InteractResponse(success=False, error=str(e), message="Failed to execute get_user") + + # ----------------------------------------------------------------------- + # Content + # ----------------------------------------------------------------------- + + async def get_content_list( + self, + *, + limit: int | None = None, + offset: int | None = None, + ) -> InteractResponse: + """List all content items. + + Args: + limit: Maximum number of results to return + offset: Number of results to skip + + Returns: + InteractResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/content" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return InteractResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_content_list" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return InteractResponse(success=False, error=str(e), message="Failed to execute get_content_list") + + async def get_content( + self, + content_id: str, + ) -> InteractResponse: + """Get a specific content item by ID. + + Args: + content_id: The content ID + + Returns: + InteractResponse with operation result + """ + url = self.base_url + "/content/{content_id}".format(content_id=content_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return InteractResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_content" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return InteractResponse(success=False, error=str(e), message="Failed to execute get_content") + + # ----------------------------------------------------------------------- + # Pages + # ----------------------------------------------------------------------- + + async def get_pages( + self, + *, + limit: int | None = None, + offset: int | None = None, + ) -> InteractResponse: + """List all pages. + + Args: + limit: Maximum number of results to return + offset: Number of results to skip + + Returns: + InteractResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/pages" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return InteractResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_pages" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return InteractResponse(success=False, error=str(e), message="Failed to execute get_pages") + + async def get_page( + self, + page_id: str, + ) -> InteractResponse: + """Get a specific page by ID. + + Args: + page_id: The page ID + + Returns: + InteractResponse with operation result + """ + url = self.base_url + "/pages/{page_id}".format(page_id=page_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return InteractResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_page" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return InteractResponse(success=False, error=str(e), message="Failed to execute get_page") + + # ----------------------------------------------------------------------- + # News + # ----------------------------------------------------------------------- + + async def get_news_list( + self, + *, + limit: int | None = None, + offset: int | None = None, + ) -> InteractResponse: + """List all news items. + + Args: + limit: Maximum number of results to return + offset: Number of results to skip + + Returns: + InteractResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/news" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return InteractResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_news_list" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return InteractResponse(success=False, error=str(e), message="Failed to execute get_news_list") + + async def get_news( + self, + news_id: str, + ) -> InteractResponse: + """Get a specific news item by ID. + + Args: + news_id: The news ID + + Returns: + InteractResponse with operation result + """ + url = self.base_url + "/news/{news_id}".format(news_id=news_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return InteractResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_news" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return InteractResponse(success=False, error=str(e), message="Failed to execute get_news") + + # ----------------------------------------------------------------------- + # Communities + # ----------------------------------------------------------------------- + + async def get_communities( + self, + *, + limit: int | None = None, + offset: int | None = None, + ) -> InteractResponse: + """List all communities. + + Args: + limit: Maximum number of results to return + offset: Number of results to skip + + Returns: + InteractResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/communities" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return InteractResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_communities" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return InteractResponse(success=False, error=str(e), message="Failed to execute get_communities") + + async def get_community( + self, + community_id: str, + ) -> InteractResponse: + """Get a specific community by ID. + + Args: + community_id: The community ID + + Returns: + InteractResponse with operation result + """ + url = self.base_url + "/communities/{community_id}".format(community_id=community_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return InteractResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_community" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return InteractResponse(success=False, error=str(e), message="Failed to execute get_community") + + # ----------------------------------------------------------------------- + # Events + # ----------------------------------------------------------------------- + + async def get_events( + self, + *, + limit: int | None = None, + offset: int | None = None, + ) -> InteractResponse: + """List all events. + + Args: + limit: Maximum number of results to return + offset: Number of results to skip + + Returns: + InteractResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/events" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return InteractResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_events" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return InteractResponse(success=False, error=str(e), message="Failed to execute get_events") + + async def get_event( + self, + event_id: str, + ) -> InteractResponse: + """Get a specific event by ID. + + Args: + event_id: The event ID + + Returns: + InteractResponse with operation result + """ + url = self.base_url + "/events/{event_id}".format(event_id=event_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return InteractResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_event" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return InteractResponse(success=False, error=str(e), message="Failed to execute get_event") + + # ----------------------------------------------------------------------- + # Search + # ----------------------------------------------------------------------- + + async def search( + self, + q: str, + *, + type: str | None = None, + limit: int | None = None, + offset: int | None = None, + ) -> InteractResponse: + """Search across Interact intranet content. + + Args: + q: Search query string + type: Filter by content type + limit: Maximum number of results to return + offset: Number of results to skip + + Returns: + InteractResponse with operation result + """ + query_params: dict[str, Any] = {'q': q} + if type is not None: + query_params['type'] = type + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/search" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return InteractResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed search" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return InteractResponse(success=False, error=str(e), message="Failed to execute search") diff --git a/backend/python/app/sources/external/interact/run_generator.py b/backend/python/app/sources/external/interact/run_generator.py new file mode 100644 index 000000000..f4a0dc14a --- /dev/null +++ b/backend/python/app/sources/external/interact/run_generator.py @@ -0,0 +1,24 @@ +"""Runner script to generate the Interact DataSource wrapper. + +Execute this script to regenerate interact.py from the endpoint definitions +in code_generator.py. + +Usage: + python -m app.sources.external.interact.run_generator +""" + +from app.sources.external.interact.code_generator import generate_datasource + + +def main() -> None: + """Generate the Interact DataSource file.""" + code = generate_datasource() + output_path = __file__.replace("run_generator.py", "interact.py") + with open(output_path, "w") as f: + f.write(code) + print(f"Generated Interact DataSource -> {output_path}") + print(f" Total characters: {len(code)}") + + +if __name__ == "__main__": + main() diff --git a/backend/python/app/sources/external/intercom/example.py b/backend/python/app/sources/external/intercom/example.py new file mode 100644 index 000000000..3d37e7693 --- /dev/null +++ b/backend/python/app/sources/external/intercom/example.py @@ -0,0 +1,216 @@ +# ruff: noqa + +""" +Intercom API Usage Examples + +This example demonstrates how to use the Intercom DataSource to interact with +the Intercom API, covering: +- Authentication (OAuth2, Access Token) +- Initializing the Client and DataSource +- Listing admins, contacts, conversations +- Contact CRUD and search +- Companies, articles, teams, tags, segments + +Prerequisites: +For OAuth2: +1. Create an Intercom app at https://developers.intercom.com/ +2. Set INTERCOM_CLIENT_ID and INTERCOM_CLIENT_SECRET environment variables +3. The OAuth flow will automatically open a browser for authorization + +For Access Token: +1. Go to your Intercom Developer Hub +2. Copy the access token from your app settings +3. Set INTERCOM_ACCESS_TOKEN environment variable + +OAuth Endpoints: +- Auth: https://app.intercom.com/oauth +- Token: https://api.intercom.io/auth/eagle/token +- Auth Method: body +""" + +import asyncio +import json +import os + +from app.sources.client.intercom.intercom import ( + IntercomClient, + IntercomOAuthConfig, + IntercomResponse, + IntercomTokenConfig, +) +from app.sources.external.intercom.intercom import IntercomDataSource +from app.sources.external.utils.oauth import perform_oauth_flow + +# --- Configuration --- +CLIENT_ID = os.getenv("INTERCOM_CLIENT_ID") +CLIENT_SECRET = os.getenv("INTERCOM_CLIENT_SECRET") +ACCESS_TOKEN = os.getenv("INTERCOM_ACCESS_TOKEN") +REDIRECT_URI = os.getenv("INTERCOM_REDIRECT_URI", "http://localhost:8080/callback") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: IntercomResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + if isinstance(data, dict): + for key in ("admins", "data", "contacts", "conversations", + "companies", "articles", "teams", "tags", + "segments", "data_attributes"): + if key in data: + items = data[key] + if isinstance(items, list): + print(f" Found {len(items)} {key}.") + if items: + print(f" Sample: {json.dumps(items[0], indent=2, default=str)[:400]}...") + return + print(f" Data: {json.dumps(data, indent=2, default=str)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + # 1. Initialize Client + print_section("Initializing Intercom Client") + + config = None + + # Priority 1: OAuth2 + if CLIENT_ID and CLIENT_SECRET: + print(" Using OAuth2 authentication") + try: + print(" Starting OAuth flow...") + token_response = perform_oauth_flow( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + auth_endpoint="https://app.intercom.com/oauth", + token_endpoint="https://api.intercom.io/auth/eagle/token", + redirect_uri=REDIRECT_URI, + scopes=[], + scope_delimiter=" ", + auth_method="body", + ) + + access_token = token_response.get("access_token") + if not access_token: + raise Exception("No access_token found in OAuth response") + + config = IntercomOAuthConfig( + access_token=access_token, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + ) + print(" OAuth authentication successful") + except Exception as e: + print(f" OAuth flow failed: {e}") + print(" Falling back to other authentication methods...") + + # Priority 2: Access Token + if config is None and ACCESS_TOKEN: + print(" Using Access Token authentication") + config = IntercomTokenConfig(access_token=ACCESS_TOKEN) + + if config is None: + print(" No valid authentication method found.") + print(" Please set one of the following:") + print(" - INTERCOM_CLIENT_ID and INTERCOM_CLIENT_SECRET (for OAuth2)") + print(" - INTERCOM_ACCESS_TOKEN (for Access Token)") + return + + client = IntercomClient.build_with_config(config) + data_source = IntercomDataSource(client) + print(" Client initialized successfully.") + + try: + # 2. Get Current Admin + print_section("Current Admin (Me)") + me_resp = await data_source.get_me() + print_result("Get Me", me_resp) + + # 3. List Admins + print_section("Admins") + admins_resp = await data_source.list_admins() + print_result("List Admins", admins_resp) + + # 4. List Contacts + print_section("Contacts") + contacts_resp = await data_source.list_contacts(per_page=5) + print_result("List Contacts", contacts_resp) + + # Get first contact details + if contacts_resp.success and contacts_resp.data: + contacts_data = contacts_resp.data.get("data", []) + if contacts_data: + contact_id = contacts_data[0].get("id") + if contact_id: + print_section(f"Contact Details (ID: {contact_id})") + contact_resp = await data_source.get_contact(id=contact_id) + print_result("Get Contact", contact_resp) + + # 5. List Conversations + print_section("Conversations") + convs_resp = await data_source.list_conversations(per_page=5) + print_result("List Conversations", convs_resp) + + # Get first conversation details + if convs_resp.success and convs_resp.data: + convs_data = convs_resp.data.get("conversations", []) + if convs_data: + conv_id = convs_data[0].get("id") + if conv_id: + print_section(f"Conversation Details (ID: {conv_id})") + conv_resp = await data_source.get_conversation(id=conv_id) + print_result("Get Conversation", conv_resp) + + # 6. List Companies + print_section("Companies") + companies_resp = await data_source.list_companies(per_page=5) + print_result("List Companies", companies_resp) + + # 7. List Articles + print_section("Articles") + articles_resp = await data_source.list_articles(per_page=5) + print_result("List Articles", articles_resp) + + # 8. List Teams + print_section("Teams") + teams_resp = await data_source.list_teams() + print_result("List Teams", teams_resp) + + # 9. List Tags + print_section("Tags") + tags_resp = await data_source.list_tags() + print_result("List Tags", tags_resp) + + # 10. List Segments + print_section("Segments") + segments_resp = await data_source.list_segments() + print_result("List Segments", segments_resp) + + # 11. List Data Attributes + print_section("Data Attributes") + attrs_resp = await data_source.list_data_attributes() + print_result("List Data Attributes", attrs_resp) + + finally: + print("\nClosing client connection...") + inner_client = client.get_client() + if hasattr(inner_client, "close"): + await inner_client.close() + + print("\n" + "=" * 80) + print(" All Intercom API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/intercom/intercom.py b/backend/python/app/sources/external/intercom/intercom.py new file mode 100644 index 000000000..804c79126 --- /dev/null +++ b/backend/python/app/sources/external/intercom/intercom.py @@ -0,0 +1,838 @@ +# ruff: noqa: A002, FBT001 +""" +Intercom REST API DataSource - Auto-generated API wrapper + +Generated from Intercom REST API documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.http.http_request import HTTPRequest +from app.sources.client.intercom.intercom import IntercomClient, IntercomResponse + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class IntercomDataSource: + """Intercom REST API DataSource + + Provides async wrapper methods for Intercom REST API operations: + - Admin management + - Contact CRUD and search + - Conversation management + - Company management + - Article management + - Teams, tags, segments, data attributes + + All methods return IntercomResponse objects. + """ + + def __init__(self, client: IntercomClient) -> None: + """Initialize with IntercomClient. + + Args: + client: IntercomClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'IntercomDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> IntercomClient: + """Return the underlying IntercomClient.""" + return self._client + + async def get_me( + self + ) -> IntercomResponse: + """Get the current admin + + Returns: + IntercomResponse with operation result + """ + url = self.base_url + "/me" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return IntercomResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_me" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return IntercomResponse(success=False, error=str(e), message="Failed to execute get_me") + + async def list_admins( + self + ) -> IntercomResponse: + """List all admins + + Returns: + IntercomResponse with operation result + """ + url = self.base_url + "/admins" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return IntercomResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_admins" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return IntercomResponse(success=False, error=str(e), message="Failed to execute list_admins") + + async def get_admin( + self, + id: str + ) -> IntercomResponse: + """Get a specific admin by ID + + Args: + id: Admin ID + + Returns: + IntercomResponse with operation result + """ + url = self.base_url + "/admins/{id}".format(id=id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return IntercomResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_admin" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return IntercomResponse(success=False, error=str(e), message="Failed to execute get_admin") + + async def list_contacts( + self, + per_page: int | None = None, + starting_after: str | None = None + ) -> IntercomResponse: + """List all contacts with optional pagination + + Args: + per_page: Number of contacts per page + starting_after: Cursor for pagination + + Returns: + IntercomResponse with operation result + """ + query_params: dict[str, Any] = {} + if per_page is not None: + query_params['per_page'] = str(per_page) + if starting_after is not None: + query_params['starting_after'] = starting_after + + url = self.base_url + "/contacts" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return IntercomResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_contacts" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return IntercomResponse(success=False, error=str(e), message="Failed to execute list_contacts") + + async def get_contact( + self, + id: str + ) -> IntercomResponse: + """Get a specific contact by ID + + Args: + id: Contact ID + + Returns: + IntercomResponse with operation result + """ + url = self.base_url + "/contacts/{id}".format(id=id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return IntercomResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_contact" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return IntercomResponse(success=False, error=str(e), message="Failed to execute get_contact") + + async def create_contact( + self, + role: str | None = None, + external_id: str | None = None, + email: str | None = None, + phone: str | None = None, + name: str | None = None, + avatar: str | None = None, + signed_up_at: int | None = None, + last_seen_at: int | None = None, + owner_id: int | None = None, + unsubscribed_from_emails: bool | None = None, + custom_attributes: dict[str, Any] | None = None + ) -> IntercomResponse: + """Create a new contact + + Args: + role: Role: lead or user + external_id: External ID for the contact + email: Email address + phone: Phone number + name: Full name + avatar: Avatar URL + signed_up_at: Signup timestamp (Unix) + last_seen_at: Last seen timestamp (Unix) + owner_id: Owner admin ID + unsubscribed_from_emails: Unsubscribed from emails + custom_attributes: Custom attributes + + Returns: + IntercomResponse with operation result + """ + url = self.base_url + "/contacts" + + request_body: dict[str, Any] = {} + if role is not None: + request_body['role'] = role + if external_id is not None: + request_body['external_id'] = external_id + if email is not None: + request_body['email'] = email + if phone is not None: + request_body['phone'] = phone + if name is not None: + request_body['name'] = name + if avatar is not None: + request_body['avatar'] = avatar + if signed_up_at is not None: + request_body['signed_up_at'] = signed_up_at + if last_seen_at is not None: + request_body['last_seen_at'] = last_seen_at + if owner_id is not None: + request_body['owner_id'] = owner_id + if unsubscribed_from_emails is not None: + request_body['unsubscribed_from_emails'] = unsubscribed_from_emails + if custom_attributes is not None: + request_body['custom_attributes'] = custom_attributes + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + body=request_body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return IntercomResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed create_contact" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return IntercomResponse(success=False, error=str(e), message="Failed to execute create_contact") + + async def update_contact( + self, + id: str, + role: str | None = None, + external_id: str | None = None, + email: str | None = None, + phone: str | None = None, + name: str | None = None, + avatar: str | None = None, + signed_up_at: int | None = None, + last_seen_at: int | None = None, + owner_id: int | None = None, + unsubscribed_from_emails: bool | None = None, + custom_attributes: dict[str, Any] | None = None + ) -> IntercomResponse: + """Update an existing contact + + Args: + id: Contact ID + role: Role: lead or user + external_id: External ID + email: Email address + phone: Phone number + name: Full name + avatar: Avatar URL + signed_up_at: Signup timestamp (Unix) + last_seen_at: Last seen timestamp (Unix) + owner_id: Owner admin ID + unsubscribed_from_emails: Unsubscribed from emails + custom_attributes: Custom attributes + + Returns: + IntercomResponse with operation result + """ + url = self.base_url + "/contacts/{id}".format(id=id) + + request_body: dict[str, Any] = {} + if role is not None: + request_body['role'] = role + if external_id is not None: + request_body['external_id'] = external_id + if email is not None: + request_body['email'] = email + if phone is not None: + request_body['phone'] = phone + if name is not None: + request_body['name'] = name + if avatar is not None: + request_body['avatar'] = avatar + if signed_up_at is not None: + request_body['signed_up_at'] = signed_up_at + if last_seen_at is not None: + request_body['last_seen_at'] = last_seen_at + if owner_id is not None: + request_body['owner_id'] = owner_id + if unsubscribed_from_emails is not None: + request_body['unsubscribed_from_emails'] = unsubscribed_from_emails + if custom_attributes is not None: + request_body['custom_attributes'] = custom_attributes + + try: + request = HTTPRequest( + method="PUT", + url=url, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + body=request_body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return IntercomResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed update_contact" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return IntercomResponse(success=False, error=str(e), message="Failed to execute update_contact") + + async def search_contacts( + self, + query_: dict[str, Any], + pagination: dict[str, Any] | None = None, + sort: dict[str, Any] | None = None + ) -> IntercomResponse: + """Search contacts with query filters + + Args: + query_: Search query object with field, operator, and value + pagination: Pagination options + sort: Sort options + + Returns: + IntercomResponse with operation result + """ + url = self.base_url + "/contacts/search" + + request_body: dict[str, Any] = {} + request_body['query'] = query_ + if pagination is not None: + request_body['pagination'] = pagination + if sort is not None: + request_body['sort'] = sort + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + body=request_body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return IntercomResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed search_contacts" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return IntercomResponse(success=False, error=str(e), message="Failed to execute search_contacts") + + async def list_conversations( + self, + per_page: int | None = None, + starting_after: str | None = None + ) -> IntercomResponse: + """List all conversations with optional pagination + + Args: + per_page: Number of conversations per page + starting_after: Cursor for pagination + + Returns: + IntercomResponse with operation result + """ + query_params: dict[str, Any] = {} + if per_page is not None: + query_params['per_page'] = str(per_page) + if starting_after is not None: + query_params['starting_after'] = starting_after + + url = self.base_url + "/conversations" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return IntercomResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_conversations" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return IntercomResponse(success=False, error=str(e), message="Failed to execute list_conversations") + + async def get_conversation( + self, + id: str + ) -> IntercomResponse: + """Get a specific conversation by ID + + Args: + id: Conversation ID + + Returns: + IntercomResponse with operation result + """ + url = self.base_url + "/conversations/{id}".format(id=id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return IntercomResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_conversation" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return IntercomResponse(success=False, error=str(e), message="Failed to execute get_conversation") + + async def list_companies( + self, + per_page: int | None = None, + page: int | None = None + ) -> IntercomResponse: + """List all companies + + Args: + per_page: Number of companies per page + page: Page number + + Returns: + IntercomResponse with operation result + """ + query_params: dict[str, Any] = {} + if per_page is not None: + query_params['per_page'] = str(per_page) + if page is not None: + query_params['page'] = str(page) + + url = self.base_url + "/companies" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return IntercomResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_companies" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return IntercomResponse(success=False, error=str(e), message="Failed to execute list_companies") + + async def get_company( + self, + id: str + ) -> IntercomResponse: + """Get a specific company by ID + + Args: + id: Company ID + + Returns: + IntercomResponse with operation result + """ + url = self.base_url + "/companies/{id}".format(id=id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return IntercomResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_company" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return IntercomResponse(success=False, error=str(e), message="Failed to execute get_company") + + async def create_company( + self, + company_id: str | None = None, + name: str | None = None, + plan: str | None = None, + monthly_spend: float | None = None, + size: int | None = None, + website: str | None = None, + industry: str | None = None, + remote_created_at: int | None = None, + custom_attributes: dict[str, Any] | None = None + ) -> IntercomResponse: + """Create or update a company + + Args: + company_id: External company ID + name: Company name + plan: Plan name + monthly_spend: Monthly spend + size: Number of employees + website: Website URL + industry: Industry + remote_created_at: Creation timestamp (Unix) + custom_attributes: Custom attributes + + Returns: + IntercomResponse with operation result + """ + url = self.base_url + "/companies" + + request_body: dict[str, Any] = {} + if company_id is not None: + request_body['company_id'] = company_id + if name is not None: + request_body['name'] = name + if plan is not None: + request_body['plan'] = plan + if monthly_spend is not None: + request_body['monthly_spend'] = monthly_spend + if size is not None: + request_body['size'] = size + if website is not None: + request_body['website'] = website + if industry is not None: + request_body['industry'] = industry + if remote_created_at is not None: + request_body['remote_created_at'] = remote_created_at + if custom_attributes is not None: + request_body['custom_attributes'] = custom_attributes + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + body=request_body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return IntercomResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed create_company" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return IntercomResponse(success=False, error=str(e), message="Failed to execute create_company") + + async def list_articles( + self, + per_page: int | None = None, + page: int | None = None + ) -> IntercomResponse: + """List all articles + + Args: + per_page: Number of articles per page + page: Page number + + Returns: + IntercomResponse with operation result + """ + query_params: dict[str, Any] = {} + if per_page is not None: + query_params['per_page'] = str(per_page) + if page is not None: + query_params['page'] = str(page) + + url = self.base_url + "/articles" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return IntercomResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_articles" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return IntercomResponse(success=False, error=str(e), message="Failed to execute list_articles") + + async def get_article( + self, + id: str + ) -> IntercomResponse: + """Get a specific article by ID + + Args: + id: Article ID + + Returns: + IntercomResponse with operation result + """ + url = self.base_url + "/articles/{id}".format(id=id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return IntercomResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_article" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return IntercomResponse(success=False, error=str(e), message="Failed to execute get_article") + + async def create_article( + self, + title: str, + author_id: int, + description: str | None = None, + body: str | None = None, + state: str | None = None, + parent_id: int | None = None, + parent_type: str | None = None, + translated_content: dict[str, Any] | None = None + ) -> IntercomResponse: + """Create a new article + + Args: + title: Article title + author_id: Author admin ID + description: Article description + body: Article body (HTML) + state: State: published or draft + parent_id: Parent collection/section ID + parent_type: Parent type: collection or section + translated_content: Translated content by locale + + Returns: + IntercomResponse with operation result + """ + url = self.base_url + "/articles" + + request_body: dict[str, Any] = {} + request_body['title'] = title + request_body['author_id'] = author_id + if description is not None: + request_body['description'] = description + if body is not None: + request_body['body'] = body + if state is not None: + request_body['state'] = state + if parent_id is not None: + request_body['parent_id'] = parent_id + if parent_type is not None: + request_body['parent_type'] = parent_type + if translated_content is not None: + request_body['translated_content'] = translated_content + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + body=request_body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return IntercomResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed create_article" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return IntercomResponse(success=False, error=str(e), message="Failed to execute create_article") + + async def list_teams( + self + ) -> IntercomResponse: + """List all teams + + Returns: + IntercomResponse with operation result + """ + url = self.base_url + "/teams" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return IntercomResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_teams" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return IntercomResponse(success=False, error=str(e), message="Failed to execute list_teams") + + async def list_tags( + self + ) -> IntercomResponse: + """List all tags + + Returns: + IntercomResponse with operation result + """ + url = self.base_url + "/tags" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return IntercomResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_tags" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return IntercomResponse(success=False, error=str(e), message="Failed to execute list_tags") + + async def list_segments( + self + ) -> IntercomResponse: + """List all segments + + Returns: + IntercomResponse with operation result + """ + url = self.base_url + "/segments" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return IntercomResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_segments" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return IntercomResponse(success=False, error=str(e), message="Failed to execute list_segments") + + async def list_data_attributes( + self + ) -> IntercomResponse: + """List all data attributes + + Returns: + IntercomResponse with operation result + """ + url = self.base_url + "/data_attributes" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return IntercomResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_data_attributes" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return IntercomResponse(success=False, error=str(e), message="Failed to execute list_data_attributes") diff --git a/backend/python/app/sources/external/invision/example.py b/backend/python/app/sources/external/invision/example.py new file mode 100644 index 000000000..71e4ab010 --- /dev/null +++ b/backend/python/app/sources/external/invision/example.py @@ -0,0 +1,174 @@ +# ruff: noqa + +""" +InVision API Usage Examples + +This example demonstrates how to use the InVision DataSource to interact with +the InVision API (v2), covering: +- Authentication (API Key as Bearer Token) +- Initializing the Client and DataSource +- Fetching User Details +- Listing and Managing Projects +- Working with Screens and Comments +- Team and Space Operations + +Prerequisites: +1. Obtain an InVision API key from the InVision developer portal +2. Set INVISION_API_KEY environment variable + +API Reference: https://developers.invisionapp.com/ +""" + +import asyncio +import json +import os + +from app.sources.client.invision.invision import ( + InVisionClient, + InVisionTokenConfig, + InVisionResponse, +) +from app.sources.external.invision.invision import InVisionDataSource + +# --- Configuration --- +API_KEY = os.getenv("INVISION_API_KEY") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: InVisionResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + # Handle list-type responses + for key in ("projects", "screens", "comments", "teams", "members", "spaces"): + if isinstance(data, dict) and key in data: + items = data[key] + print(f" Found {len(items)} {key}.") + if items: + print(f" Sample: {json.dumps(items[0], indent=2)[:400]}...") + return + # Generic response + print(f" Data: {json.dumps(data, indent=2)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + # 1. Initialize Client + print_section("Initializing InVision Client") + + if not API_KEY: + print(" No valid authentication method found.") + print(" Please set INVISION_API_KEY environment variable.") + return + + print(" Using API Key authentication") + config = InVisionTokenConfig(api_key=API_KEY) + client = InVisionClient.build_with_config(config) + data_source = InVisionDataSource(client) + print("Client initialized successfully.") + + try: + # 2. Get Current User + print_section("Current User") + user_resp = await data_source.get_current_user() + print_result("Get Current User", user_resp) + + # 3. List Projects + print_section("Projects") + projects_resp = await data_source.list_projects(limit=10) + print_result("List Projects", projects_resp) + + # Extract first project ID for further exploration + project_id = None + if projects_resp.success and projects_resp.data: + projects = projects_resp.data.get("projects", []) + if not projects and isinstance(projects_resp.data, list): + projects = projects_resp.data + if projects: + project_id = str(projects[0].get("id") or projects[0].get("projectId", "")) + print(f" Using Project: {projects[0].get('name', 'Unknown')} (ID: {project_id})") + + if not project_id: + print(" No projects found. Skipping project-specific operations.") + else: + # 4. Get Specific Project + print_section("Project Details") + project_resp = await data_source.get_project(projectId=project_id) + print_result("Get Project", project_resp) + + # 5. List Project Screens + print_section("Project Screens") + screens_resp = await data_source.list_project_screens(projectId=project_id, limit=10) + print_result("List Project Screens", screens_resp) + + # Get a specific screen if available + if screens_resp.success and screens_resp.data: + screens = screens_resp.data.get("screens", []) + if not screens and isinstance(screens_resp.data, list): + screens = screens_resp.data + if screens: + screen_id = str(screens[0].get("id") or screens[0].get("screenId", "")) + print_section("Screen Details") + screen_resp = await data_source.get_screen(screenId=screen_id) + print_result("Get Screen", screen_resp) + + # 6. List Project Comments + print_section("Project Comments") + comments_resp = await data_source.list_project_comments(projectId=project_id, limit=10) + print_result("List Project Comments", comments_resp) + + # 7. List Teams + print_section("Teams") + teams_resp = await data_source.list_teams() + print_result("List Teams", teams_resp) + + # Extract first team ID + team_id = None + if teams_resp.success and teams_resp.data: + teams = teams_resp.data.get("teams", []) + if not teams and isinstance(teams_resp.data, list): + teams = teams_resp.data + if teams: + team_id = str(teams[0].get("id") or teams[0].get("teamId", "")) + print(f" Using Team ID: {team_id}") + + if team_id: + # 8. Get Team Details + print_section("Team Details") + team_resp = await data_source.get_team(teamId=team_id) + print_result("Get Team", team_resp) + + # 9. List Team Members + print_section("Team Members") + members_resp = await data_source.list_team_members(teamId=team_id) + print_result("List Team Members", members_resp) + + # 10. List Spaces + print_section("Spaces") + spaces_resp = await data_source.list_spaces(limit=10) + print_result("List Spaces", spaces_resp) + + finally: + # Cleanup: Close the HTTP client session + print("\nClosing client connection...") + inner_client = client.get_client() + if hasattr(inner_client, "close"): + await inner_client.close() + + print("\n" + "=" * 80) + print(" All InVision API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/invision/invision.py b/backend/python/app/sources/external/invision/invision.py new file mode 100644 index 000000000..d24a23872 --- /dev/null +++ b/backend/python/app/sources/external/invision/invision.py @@ -0,0 +1,472 @@ +""" +InVision REST API DataSource - Auto-generated API wrapper + +Generated from InVision REST API v2 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.http.http_request import HTTPRequest +from app.sources.client.invision.invision import InVisionClient, InVisionResponse + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class InVisionDataSource: + """InVision REST API DataSource + + Provides async wrapper methods for InVision REST API operations: + - User profile + - Project management (list, get, create) + - Screen operations (list, get) + - Comment management + - Team and member management + - Space operations + + The base URL is https://api.invisionapp.com/v2. + + All methods return InVisionResponse objects. + """ + + def __init__(self, client: InVisionClient) -> None: + """Initialize with InVisionClient. + + Args: + client: InVisionClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'InVisionDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> InVisionClient: + """Return the underlying InVisionClient.""" + return self._client + + async def get_current_user( + self + ) -> InVisionResponse: + """Get the current authenticated user details (API v2) + + Returns: + InVisionResponse with operation result + """ + url = self.base_url + "/users/me" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return InVisionResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_current_user" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return InVisionResponse(success=False, error=str(e), message="Failed to execute get_current_user") + + async def list_projects( + self, + *, + limit: int | None = None, + offset: int | None = None, + sortBy: str | None = None, + archived: bool | None = None + ) -> InVisionResponse: + """List all projects accessible to the authenticated user (API v2) + + Args: + limit: Maximum number of results to return + offset: Number of results to skip for pagination + sortBy: Field to sort results by + archived: Filter by archived status + + Returns: + InVisionResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + if sortBy is not None: + query_params['sortBy'] = sortBy + if archived is not None: + query_params['archived'] = str(archived).lower() + + url = self.base_url + "/projects" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return InVisionResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_projects" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return InVisionResponse(success=False, error=str(e), message="Failed to execute list_projects") + + async def get_project( + self, + projectId: str + ) -> InVisionResponse: + """Get a specific project by ID (API v2) + + Args: + projectId: The project ID + + Returns: + InVisionResponse with operation result + """ + url = self.base_url + "/projects/{projectId}".format(projectId=projectId) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return InVisionResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_project" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return InVisionResponse(success=False, error=str(e), message="Failed to execute get_project") + + async def create_project( + self, + name: str, + project_type: str | None = None, + description: str | None = None + ) -> InVisionResponse: + """Create a new project (API v2) + + Args: + name: The name of the project + project_type: The type of the project + description: The project description + + Returns: + InVisionResponse with operation result + """ + url = self.base_url + "/projects" + + body: dict[str, Any] = {} + body['name'] = name + if project_type is not None: + body['project_type'] = project_type + if description is not None: + body['description'] = description + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return InVisionResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed create_project" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return InVisionResponse(success=False, error=str(e), message="Failed to execute create_project") + + async def list_project_screens( + self, + projectId: str, + limit: int | None = None, + offset: int | None = None, + sortBy: str | None = None + ) -> InVisionResponse: + """List all screens in a project (API v2) + + Args: + projectId: The project ID + limit: Maximum number of results to return + offset: Number of results to skip for pagination + sortBy: Field to sort results by + + Returns: + InVisionResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + if sortBy is not None: + query_params['sortBy'] = sortBy + + url = self.base_url + "/projects/{projectId}/screens".format(projectId=projectId) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return InVisionResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_project_screens" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return InVisionResponse(success=False, error=str(e), message="Failed to execute list_project_screens") + + async def get_screen( + self, + screenId: str + ) -> InVisionResponse: + """Get a specific screen by ID (API v2) + + Args: + screenId: The screen ID + + Returns: + InVisionResponse with operation result + """ + url = self.base_url + "/screens/{screenId}".format(screenId=screenId) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return InVisionResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_screen" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return InVisionResponse(success=False, error=str(e), message="Failed to execute get_screen") + + async def list_project_comments( + self, + projectId: str, + limit: int | None = None, + offset: int | None = None + ) -> InVisionResponse: + """List all comments in a project (API v2) + + Args: + projectId: The project ID + limit: Maximum number of results to return + offset: Number of results to skip for pagination + + Returns: + InVisionResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/projects/{projectId}/comments".format(projectId=projectId) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return InVisionResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_project_comments" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return InVisionResponse(success=False, error=str(e), message="Failed to execute list_project_comments") + + async def list_teams( + self + ) -> InVisionResponse: + """List all teams (API v2) + + Returns: + InVisionResponse with operation result + """ + url = self.base_url + "/teams" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return InVisionResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_teams" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return InVisionResponse(success=False, error=str(e), message="Failed to execute list_teams") + + async def get_team( + self, + teamId: str + ) -> InVisionResponse: + """Get a specific team by ID (API v2) + + Args: + teamId: The team ID + + Returns: + InVisionResponse with operation result + """ + url = self.base_url + "/teams/{teamId}".format(teamId=teamId) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return InVisionResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_team" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return InVisionResponse(success=False, error=str(e), message="Failed to execute get_team") + + async def list_team_members( + self, + teamId: str + ) -> InVisionResponse: + """List all members of a team (API v2) + + Args: + teamId: The team ID + + Returns: + InVisionResponse with operation result + """ + url = self.base_url + "/teams/{teamId}/members".format(teamId=teamId) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return InVisionResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_team_members" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return InVisionResponse(success=False, error=str(e), message="Failed to execute list_team_members") + + async def list_spaces( + self, + limit: int | None = None, + offset: int | None = None + ) -> InVisionResponse: + """List all spaces (API v2) + + Args: + limit: Maximum number of results to return + offset: Number of results to skip for pagination + + Returns: + InVisionResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/spaces" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return InVisionResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_spaces" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return InVisionResponse(success=False, error=str(e), message="Failed to execute list_spaces") + + async def get_space( + self, + spaceId: str + ) -> InVisionResponse: + """Get a specific space by ID (API v2) + + Args: + spaceId: The space ID + + Returns: + InVisionResponse with operation result + """ + url = self.base_url + "/spaces/{spaceId}".format(spaceId=spaceId) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return InVisionResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_space" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return InVisionResponse(success=False, error=str(e), message="Failed to execute get_space") diff --git a/backend/python/app/sources/external/ironclad/example.py b/backend/python/app/sources/external/ironclad/example.py new file mode 100644 index 000000000..9854560e7 --- /dev/null +++ b/backend/python/app/sources/external/ironclad/example.py @@ -0,0 +1,234 @@ +# ruff: noqa + +""" +Ironclad API Usage Examples + +This example demonstrates how to use the Ironclad DataSource to interact with +the Ironclad API (v1), covering: +- Authentication (OAuth2, API Key) +- Initializing the Client and DataSource +- Listing workflows, records, templates +- Managing webhooks +- Fetching users and groups + +Prerequisites: +For OAuth2: +1. Create an Ironclad OAuth app in the Developer Portal +2. Set IRONCLAD_CLIENT_ID and IRONCLAD_CLIENT_SECRET environment variables +3. The OAuth flow will automatically open a browser for authorization + +For API Key: +1. Generate an API key in your Ironclad account settings +2. Set IRONCLAD_API_KEY environment variable + +OAuth Endpoints: +- Authorization: https://ironcladapp.com/oauth/authorize +- Token: https://ironcladapp.com/oauth/token +""" + +import asyncio +import json +import os + +from app.sources.client.ironclad.ironclad import ( + IroncladClient, + IroncladOAuthConfig, + IroncladResponse, + IroncladTokenConfig, +) +from app.sources.external.ironclad.ironclad import IroncladDataSource +from app.sources.external.utils.oauth import perform_oauth_flow + +# --- Configuration --- +# OAuth2 credentials (highest priority) +CLIENT_ID = os.getenv("IRONCLAD_CLIENT_ID") +CLIENT_SECRET = os.getenv("IRONCLAD_CLIENT_SECRET") + +# API Key (second priority) +API_KEY = os.getenv("IRONCLAD_API_KEY") + +# OAuth redirect URI +REDIRECT_URI = os.getenv("IRONCLAD_REDIRECT_URI", "http://localhost:8080/callback") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: IroncladResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + # Handle list-type responses + for key in ("workflows", "records", "templates", "webhooks", "users", "groups"): + if isinstance(data, dict) and key in data: + items = data[key] + print(f" Found {len(items)} {key}.") + if items: + print(f" Sample: {json.dumps(items[0], indent=2)[:400]}...") + return + # Handle list responses (some endpoints return arrays directly) + if isinstance(data, list): + print(f" Found {len(data)} items.") + if data: + print(f" Sample: {json.dumps(data[0], indent=2)[:400]}...") + return + # Generic response + print(f" Data: {json.dumps(data, indent=2)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + # 1. Initialize Client + print_section("Initializing Ironclad Client") + + config = None + + # Priority 1: OAuth2 + if CLIENT_ID and CLIENT_SECRET: + print(" Using OAuth2 authentication") + try: + print("Starting OAuth flow...") + token_response = perform_oauth_flow( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + auth_endpoint="https://ironcladapp.com/oauth/authorize", + token_endpoint="https://ironcladapp.com/oauth/token", + redirect_uri=REDIRECT_URI, + scopes=[], + scope_delimiter=" ", + auth_method="body", + ) + + access_token = token_response.get("access_token") + if not access_token: + raise Exception("No access_token found in OAuth response") + + config = IroncladOAuthConfig( + access_token=access_token, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + ) + print(" OAuth authentication successful") + except Exception as e: + print(f" OAuth flow failed: {e}") + print(" Falling back to other authentication methods...") + + # Priority 2: API Key + if config is None and API_KEY: + print(" Using API Key authentication") + config = IroncladTokenConfig(token=API_KEY) + + if config is None: + print(" No valid authentication method found.") + print(" Please set one of the following:") + print(" - IRONCLAD_CLIENT_ID and IRONCLAD_CLIENT_SECRET (for OAuth2)") + print(" - IRONCLAD_API_KEY (for API Key)") + return + + client = IroncladClient.build_with_config(config) + data_source = IroncladDataSource(client) + print("Client initialized successfully.") + + try: + # 2. List Templates + print_section("Templates") + templates_resp = await data_source.list_templates() + print_result("List Templates", templates_resp) + + template_id = None + if templates_resp.success and templates_resp.data: + data = templates_resp.data + templates = data if isinstance(data, list) else data.get("list", []) if isinstance(data, dict) else [] + if templates: + template_id = str(templates[0].get("id")) if isinstance(templates[0], dict) else None + if template_id: + print(f" Using Template ID: {template_id}") + + # 3. Get Specific Template + if template_id: + print_section(f"Template Details: {template_id}") + template_resp = await data_source.get_template(template_id=template_id) + print_result("Get Template", template_resp) + + # 4. List Workflows + print_section("Workflows") + workflows_resp = await data_source.list_workflows(page=0, page_size=10) + print_result("List Workflows", workflows_resp) + + workflow_id = None + if workflows_resp.success and workflows_resp.data: + data = workflows_resp.data + workflows = data if isinstance(data, list) else data.get("list", []) if isinstance(data, dict) else [] + if workflows: + workflow_id = str(workflows[0].get("id")) if isinstance(workflows[0], dict) else None + if workflow_id: + print(f" Using Workflow ID: {workflow_id}") + + # 5. Get Specific Workflow + if workflow_id: + print_section(f"Workflow Details: {workflow_id}") + workflow_resp = await data_source.get_workflow(workflow_id=workflow_id) + print_result("Get Workflow", workflow_resp) + + # 6. List Workflow Approvals + print_section("Workflow Approvals") + approvals_resp = await data_source.list_workflow_approvals(workflow_id=workflow_id) + print_result("List Approvals", approvals_resp) + + # 7. List Records + print_section("Records") + records_resp = await data_source.list_records(page=0, page_size=10) + print_result("List Records", records_resp) + + record_id = None + if records_resp.success and records_resp.data: + data = records_resp.data + records = data if isinstance(data, list) else data.get("list", []) if isinstance(data, dict) else [] + if records: + record_id = str(records[0].get("id")) if isinstance(records[0], dict) else None + if record_id: + print(f" Using Record ID: {record_id}") + + # 8. Get Specific Record + if record_id: + print_section(f"Record Details: {record_id}") + record_resp = await data_source.get_record(record_id=record_id) + print_result("Get Record", record_resp) + + # 9. List Webhooks + print_section("Webhooks") + webhooks_resp = await data_source.list_webhooks() + print_result("List Webhooks", webhooks_resp) + + # 10. List Users + print_section("Users") + users_resp = await data_source.list_users(page=0, page_size=10) + print_result("List Users", users_resp) + + # 11. List Groups + print_section("Groups") + groups_resp = await data_source.list_groups() + print_result("List Groups", groups_resp) + + finally: + # Cleanup: Close the HTTP client session + print("\nClosing client connection...") + inner_client = client.get_client() + if hasattr(inner_client, "close"): + await inner_client.close() + + print("\n" + "=" * 80) + print(" All Ironclad API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/ironclad/ironclad.py b/backend/python/app/sources/external/ironclad/ironclad.py new file mode 100644 index 000000000..a0c512602 --- /dev/null +++ b/backend/python/app/sources/external/ironclad/ironclad.py @@ -0,0 +1,623 @@ +""" +Ironclad REST API DataSource - Auto-generated API wrapper + +Generated from Ironclad REST API v1 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.http.http_request import HTTPRequest +from app.sources.client.ironclad.ironclad import IroncladClient, IroncladResponse + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class IroncladDataSource: + """Ironclad REST API DataSource + + Provides async wrapper methods for Ironclad REST API operations: + - Workflow management (list, get, launch, update) + - Workflow approvals + - Records management + - Templates + - Webhooks + - Users and Groups + + The base URL is determined by the IroncladClient's configuration. + + All methods return IroncladResponse objects. + """ + + def __init__(self, client: IroncladClient) -> None: + """Initialize with IroncladClient. + + Args: + client: IroncladClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'IroncladDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> IroncladClient: + """Return the underlying IroncladClient.""" + return self._client + + async def list_workflows( + self, + page: int | None = None, + page_size: int | None = None, + status: str | None = None, + template_id: str | None = None, + created_after: str | None = None, + created_before: str | None = None + ) -> IroncladResponse: + """List workflows with optional filters + + Args: + page: Page number for pagination + page_size: Number of results per page + status: Filter by workflow status + template_id: Filter by template ID + created_after: Filter workflows created after this ISO 8601 date + created_before: Filter workflows created before this ISO 8601 date + + Returns: + IroncladResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if page_size is not None: + query_params['page_size'] = str(page_size) + if status is not None: + query_params['status'] = status + if template_id is not None: + query_params['template_id'] = template_id + if created_after is not None: + query_params['created_after'] = created_after + if created_before is not None: + query_params['created_before'] = created_before + + url = self.base_url + "/workflows" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return IroncladResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_workflows" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return IroncladResponse(success=False, error=str(e), message="Failed to execute list_workflows") + + async def get_workflow( + self, + workflow_id: str + ) -> IroncladResponse: + """Get a specific workflow by ID + + Args: + workflow_id: The workflow ID + + Returns: + IroncladResponse with operation result + """ + url = self.base_url + "/workflows/{workflow_id}".format(workflow_id=workflow_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return IroncladResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_workflow" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return IroncladResponse(success=False, error=str(e), message="Failed to execute get_workflow") + + async def launch_workflow( + self, + template_id: str, + attributes: dict[str, Any] | None = None, + creator: dict[str, Any] | None = None + ) -> IroncladResponse: + """Launch a new workflow + + Args: + template_id: The template ID to launch the workflow from + attributes: Workflow attribute values + creator: Creator information + + Returns: + IroncladResponse with operation result + """ + url = self.base_url + "/workflows" + + body: dict[str, Any] = {} + body['template_id'] = template_id + if attributes is not None: + body['attributes'] = attributes + if creator is not None: + body['creator'] = creator + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return IroncladResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed launch_workflow" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return IroncladResponse(success=False, error=str(e), message="Failed to execute launch_workflow") + + async def update_workflow( + self, + workflow_id: str, + attributes: dict[str, Any] | None = None + ) -> IroncladResponse: + """Update a workflow + + Args: + workflow_id: The workflow ID + attributes: Workflow attribute values to update + + Returns: + IroncladResponse with operation result + """ + url = self.base_url + "/workflows/{workflow_id}".format(workflow_id=workflow_id) + + body: dict[str, Any] = {} + if attributes is not None: + body['attributes'] = attributes + + try: + request = HTTPRequest( + method="PATCH", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return IroncladResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed update_workflow" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return IroncladResponse(success=False, error=str(e), message="Failed to execute update_workflow") + + async def list_workflow_approvals( + self, + workflow_id: str + ) -> IroncladResponse: + """List approvals for a workflow + + Args: + workflow_id: The workflow ID + + Returns: + IroncladResponse with operation result + """ + url = self.base_url + "/workflows/{workflow_id}/approvals".format(workflow_id=workflow_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return IroncladResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_workflow_approvals" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return IroncladResponse(success=False, error=str(e), message="Failed to execute list_workflow_approvals") + + async def create_workflow_approval( + self, + workflow_id: str, + role_id: str | None = None, + user_id: str | None = None, + status: str | None = None + ) -> IroncladResponse: + """Create an approval for a workflow + + Args: + workflow_id: The workflow ID + role_id: The role ID for the approval + user_id: The user ID for the approval + status: Approval status + + Returns: + IroncladResponse with operation result + """ + url = self.base_url + "/workflows/{workflow_id}/approvals".format(workflow_id=workflow_id) + + body: dict[str, Any] = {} + if role_id is not None: + body['role_id'] = role_id + if user_id is not None: + body['user_id'] = user_id + if status is not None: + body['status'] = status + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return IroncladResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed create_workflow_approval" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return IroncladResponse(success=False, error=str(e), message="Failed to execute create_workflow_approval") + + async def list_records( + self, + page: int | None = None, + page_size: int | None = None, + template_id: str | None = None, + filter_value: str | None = None + ) -> IroncladResponse: + """List records with optional filters + + Args: + page: Page number for pagination + page_size: Number of results per page + template_id: Filter by template ID + filter_value: Filter expression + + Returns: + IroncladResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if page_size is not None: + query_params['page_size'] = str(page_size) + if template_id is not None: + query_params['template_id'] = template_id + if filter_value is not None: + query_params['filter'] = filter_value + + url = self.base_url + "/records" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return IroncladResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_records" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return IroncladResponse(success=False, error=str(e), message="Failed to execute list_records") + + async def get_record( + self, + record_id: str + ) -> IroncladResponse: + """Get a specific record by ID + + Args: + record_id: The record ID + + Returns: + IroncladResponse with operation result + """ + url = self.base_url + "/records/{record_id}".format(record_id=record_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return IroncladResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_record" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return IroncladResponse(success=False, error=str(e), message="Failed to execute get_record") + + async def update_record( + self, + record_id: str, + attributes: dict[str, Any] | None = None + ) -> IroncladResponse: + """Update a record + + Args: + record_id: The record ID + attributes: Record attribute values to update + + Returns: + IroncladResponse with operation result + """ + url = self.base_url + "/records/{record_id}".format(record_id=record_id) + + body: dict[str, Any] = {} + if attributes is not None: + body['attributes'] = attributes + + try: + request = HTTPRequest( + method="PATCH", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return IroncladResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed update_record" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return IroncladResponse(success=False, error=str(e), message="Failed to execute update_record") + + async def list_templates( + self + ) -> IroncladResponse: + """List all templates + + Returns: + IroncladResponse with operation result + """ + url = self.base_url + "/templates" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return IroncladResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_templates" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return IroncladResponse(success=False, error=str(e), message="Failed to execute list_templates") + + async def get_template( + self, + template_id: str + ) -> IroncladResponse: + """Get a specific template by ID + + Args: + template_id: The template ID + + Returns: + IroncladResponse with operation result + """ + url = self.base_url + "/templates/{template_id}".format(template_id=template_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return IroncladResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_template" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return IroncladResponse(success=False, error=str(e), message="Failed to execute get_template") + + async def list_webhooks( + self + ) -> IroncladResponse: + """List all webhooks + + Returns: + IroncladResponse with operation result + """ + url = self.base_url + "/webhooks" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return IroncladResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_webhooks" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return IroncladResponse(success=False, error=str(e), message="Failed to execute list_webhooks") + + async def create_webhook( + self, + target_url: str, + events: list[str] | None = None + ) -> IroncladResponse: + """Create a webhook + + Args: + target_url: The URL to send webhook events to + events: List of event types to subscribe to + + Returns: + IroncladResponse with operation result + """ + url = self.base_url + "/webhooks" + + body: dict[str, Any] = {} + body['target_url'] = target_url + if events is not None: + body['events'] = events + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return IroncladResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed create_webhook" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return IroncladResponse(success=False, error=str(e), message="Failed to execute create_webhook") + + async def delete_webhook( + self, + webhook_id: str + ) -> IroncladResponse: + """Delete a webhook + + Args: + webhook_id: The webhook ID + + Returns: + IroncladResponse with operation result + """ + url = self.base_url + "/webhooks/{webhook_id}".format(webhook_id=webhook_id) + + try: + request = HTTPRequest( + method="DELETE", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return IroncladResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed delete_webhook" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return IroncladResponse(success=False, error=str(e), message="Failed to execute delete_webhook") + + async def list_users( + self, + page: int | None = None, + page_size: int | None = None + ) -> IroncladResponse: + """List users with optional pagination + + Args: + page: Page number for pagination + page_size: Number of results per page + + Returns: + IroncladResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if page_size is not None: + query_params['page_size'] = str(page_size) + + url = self.base_url + "/users" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return IroncladResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_users" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return IroncladResponse(success=False, error=str(e), message="Failed to execute list_users") + + async def list_groups( + self + ) -> IroncladResponse: + """List all groups + + Returns: + IroncladResponse with operation result + """ + url = self.base_url + "/groups" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return IroncladResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_groups" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return IroncladResponse(success=False, error=str(e), message="Failed to execute list_groups") diff --git a/backend/python/app/sources/external/jumpcloud/example.py b/backend/python/app/sources/external/jumpcloud/example.py new file mode 100644 index 000000000..29e919e5e --- /dev/null +++ b/backend/python/app/sources/external/jumpcloud/example.py @@ -0,0 +1,154 @@ +# ruff: noqa + +""" +JumpCloud API Usage Examples + +This example demonstrates how to use the JumpCloud DataSource to interact +with the JumpCloud API, covering: +- Authentication (API Key) +- Initializing the Client and DataSource +- Listing users and user groups +- Listing systems and system groups +- Listing applications and directories +- Listing policies, organizations, and RADIUS servers + +Prerequisites: +1. Log in to the JumpCloud Admin Console +2. Go to your user profile (top right) or API Settings +3. Generate or copy your API Key +4. Set JUMPCLOUD_API_KEY environment variable +""" + +import asyncio +import json +import os + +from app.sources.client.jumpcloud.jumpcloud import ( + JumpCloudApiKeyConfig, + JumpCloudClient, + JumpCloudResponse, +) +from app.sources.external.jumpcloud.jumpcloud import JumpCloudDataSource + +# --- Configuration --- +API_KEY = os.getenv("JUMPCLOUD_API_KEY") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: JumpCloudResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + if isinstance(data, list): + print(f" Found {len(data)} items.") + if data: + print(f" Sample: {json.dumps(data[0], indent=2)[:400]}...") + elif isinstance(data, dict): + if "results" in data: + items = data["results"] + print(f" Found {len(items)} items.") + if items: + print(f" Sample: {json.dumps(items[0], indent=2)[:400]}...") + else: + print(f" Data: {json.dumps(data, indent=2)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + # 1. Initialize Client + print_section("Initializing JumpCloud Client") + + if not API_KEY: + print(" No valid authentication method found.") + print(" Please set the following environment variable:") + print(" - JUMPCLOUD_API_KEY") + return + + print(" Using API Key authentication") + config = JumpCloudApiKeyConfig(api_key=API_KEY) + + client = JumpCloudClient.build_with_config(config) + data_source = JumpCloudDataSource(client) + print(" Client initialized successfully.") + + try: + # 2. List Users + print_section("Users") + users_resp = await data_source.list_users(limit=5) + print_result("List Users", users_resp) + + # Get first user detail + user_id = None + if users_resp.success and users_resp.data: + items = users_resp.data if isinstance(users_resp.data, list) else users_resp.data.get("results", []) if isinstance(users_resp.data, dict) else [] + if items: + user_id = str(items[0].get("id", "") or items[0].get("_id", "")) + if user_id: + print_section(f"User Details: {user_id}") + user_resp = await data_source.get_user(user_id=user_id) + print_result("Get User", user_resp) + + # 3. List User Groups + print_section("User Groups") + ugroups_resp = await data_source.list_user_groups(limit=5) + print_result("List User Groups", ugroups_resp) + + # 4. List System Groups + print_section("System Groups") + sgroups_resp = await data_source.list_system_groups(limit=5) + print_result("List System Groups", sgroups_resp) + + # 5. List Systems + print_section("Systems") + systems_resp = await data_source.list_systems(limit=5) + print_result("List Systems", systems_resp) + + # 6. List Applications + print_section("Applications") + apps_resp = await data_source.list_applications(limit=5) + print_result("List Applications", apps_resp) + + # 7. List Directories + print_section("Directories") + dirs_resp = await data_source.list_directories(limit=5) + print_result("List Directories", dirs_resp) + + # 8. List Policies + print_section("Policies") + policies_resp = await data_source.list_policies(limit=5) + print_result("List Policies", policies_resp) + + # 9. List Organizations + print_section("Organizations") + orgs_resp = await data_source.list_organizations(limit=5) + print_result("List Organizations", orgs_resp) + + # 10. List RADIUS Servers + print_section("RADIUS Servers") + radius_resp = await data_source.list_radius_servers(limit=5) + print_result("List RADIUS Servers", radius_resp) + + finally: + # Cleanup: Close the HTTP client session + print("\nClosing client connection...") + inner_client = client.get_client() + if hasattr(inner_client, "close"): + await inner_client.close() + + print("\n" + "=" * 80) + print(" All JumpCloud API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/jumpcloud/jumpcloud.py b/backend/python/app/sources/external/jumpcloud/jumpcloud.py new file mode 100644 index 000000000..e6dc54f4f --- /dev/null +++ b/backend/python/app/sources/external/jumpcloud/jumpcloud.py @@ -0,0 +1,689 @@ +# ruff: noqa +""" +JumpCloud REST API DataSource - Auto-generated API wrapper + +Generated from JumpCloud API v2 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.jumpcloud.jumpcloud import JumpCloudClient, JumpCloudResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class JumpCloudDataSource: + """JumpCloud REST API DataSource + + Provides async wrapper methods for JumpCloud REST API operations: + - User management + - User group management + - System and system group management + - Application management + - Directory management + - Policy management + - Organization and RADIUS server management + + The base URL is https://console.jumpcloud.com/api/v2 + + All methods return JumpCloudResponse objects. + """ + + def __init__(self, client: JumpCloudClient) -> None: + """Initialize with JumpCloudClient. + + Args: + client: JumpCloudClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'JumpCloudDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> JumpCloudClient: + """Return the underlying JumpCloudClient.""" + return self._client + + # ----------------------------------------------------------------------- + # Users + # ----------------------------------------------------------------------- + + async def list_users( + self, + *, + limit: int | None = None, + skip: int | None = None, + sort: str | None = None, + filter: str | None = None, + ) -> JumpCloudResponse: + """List users (GET /users) + + Args: + limit: Maximum number of results + skip: Number of results to skip + sort: Field to sort by + filter: Filter expression + + Returns: + JumpCloudResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if skip is not None: + query_params['skip'] = str(skip) + if sort is not None: + query_params['sort'] = sort + if filter is not None: + query_params['filter'] = filter + + url = self.base_url + "/users" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return JumpCloudResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_users" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return JumpCloudResponse(success=False, error=str(e), message="Failed to execute list_users") + + async def get_user( + self, + user_id: str, + ) -> JumpCloudResponse: + """Get a specific user (GET /users/{id}) + + Args: + user_id: The user ID + + Returns: + JumpCloudResponse with operation result + """ + url = self.base_url + "/users/{user_id}".format(user_id=user_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return JumpCloudResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_user" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return JumpCloudResponse(success=False, error=str(e), message="Failed to execute get_user") + + # ----------------------------------------------------------------------- + # User Groups + # ----------------------------------------------------------------------- + + async def list_user_groups( + self, + *, + limit: int | None = None, + skip: int | None = None, + sort: str | None = None, + filter: str | None = None, + ) -> JumpCloudResponse: + """List user groups (GET /usergroups) + + Args: + limit: Maximum number of results + skip: Number of results to skip + sort: Field to sort by + filter: Filter expression + + Returns: + JumpCloudResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if skip is not None: + query_params['skip'] = str(skip) + if sort is not None: + query_params['sort'] = sort + if filter is not None: + query_params['filter'] = filter + + url = self.base_url + "/usergroups" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return JumpCloudResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_user_groups" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return JumpCloudResponse(success=False, error=str(e), message="Failed to execute list_user_groups") + + async def get_user_group( + self, + group_id: str, + ) -> JumpCloudResponse: + """Get a specific user group (GET /usergroups/{id}) + + Args: + group_id: The user group ID + + Returns: + JumpCloudResponse with operation result + """ + url = self.base_url + "/usergroups/{group_id}".format(group_id=group_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return JumpCloudResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_user_group" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return JumpCloudResponse(success=False, error=str(e), message="Failed to execute get_user_group") + + # ----------------------------------------------------------------------- + # System Groups + # ----------------------------------------------------------------------- + + async def list_system_groups( + self, + *, + limit: int | None = None, + skip: int | None = None, + sort: str | None = None, + filter: str | None = None, + ) -> JumpCloudResponse: + """List system groups (GET /systemgroups) + + Args: + limit: Maximum number of results + skip: Number of results to skip + sort: Field to sort by + filter: Filter expression + + Returns: + JumpCloudResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if skip is not None: + query_params['skip'] = str(skip) + if sort is not None: + query_params['sort'] = sort + if filter is not None: + query_params['filter'] = filter + + url = self.base_url + "/systemgroups" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return JumpCloudResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_system_groups" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return JumpCloudResponse(success=False, error=str(e), message="Failed to execute list_system_groups") + + async def get_system_group( + self, + group_id: str, + ) -> JumpCloudResponse: + """Get a specific system group (GET /systemgroups/{id}) + + Args: + group_id: The system group ID + + Returns: + JumpCloudResponse with operation result + """ + url = self.base_url + "/systemgroups/{group_id}".format(group_id=group_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return JumpCloudResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_system_group" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return JumpCloudResponse(success=False, error=str(e), message="Failed to execute get_system_group") + + # ----------------------------------------------------------------------- + # Systems + # ----------------------------------------------------------------------- + + async def list_systems( + self, + *, + limit: int | None = None, + skip: int | None = None, + sort: str | None = None, + filter: str | None = None, + ) -> JumpCloudResponse: + """List systems (GET /systems) + + Args: + limit: Maximum number of results + skip: Number of results to skip + sort: Field to sort by + filter: Filter expression + + Returns: + JumpCloudResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if skip is not None: + query_params['skip'] = str(skip) + if sort is not None: + query_params['sort'] = sort + if filter is not None: + query_params['filter'] = filter + + url = self.base_url + "/systems" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return JumpCloudResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_systems" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return JumpCloudResponse(success=False, error=str(e), message="Failed to execute list_systems") + + async def get_system( + self, + system_id: str, + ) -> JumpCloudResponse: + """Get a specific system (GET /systems/{id}) + + Args: + system_id: The system ID + + Returns: + JumpCloudResponse with operation result + """ + url = self.base_url + "/systems/{system_id}".format(system_id=system_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return JumpCloudResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_system" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return JumpCloudResponse(success=False, error=str(e), message="Failed to execute get_system") + + # ----------------------------------------------------------------------- + # Applications + # ----------------------------------------------------------------------- + + async def list_applications( + self, + *, + limit: int | None = None, + skip: int | None = None, + sort: str | None = None, + filter: str | None = None, + ) -> JumpCloudResponse: + """List applications (GET /applications) + + Args: + limit: Maximum number of results + skip: Number of results to skip + sort: Field to sort by + filter: Filter expression + + Returns: + JumpCloudResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if skip is not None: + query_params['skip'] = str(skip) + if sort is not None: + query_params['sort'] = sort + if filter is not None: + query_params['filter'] = filter + + url = self.base_url + "/applications" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return JumpCloudResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_applications" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return JumpCloudResponse(success=False, error=str(e), message="Failed to execute list_applications") + + async def get_application( + self, + app_id: str, + ) -> JumpCloudResponse: + """Get a specific application (GET /applications/{id}) + + Args: + app_id: The application ID + + Returns: + JumpCloudResponse with operation result + """ + url = self.base_url + "/applications/{app_id}".format(app_id=app_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return JumpCloudResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_application" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return JumpCloudResponse(success=False, error=str(e), message="Failed to execute get_application") + + # ----------------------------------------------------------------------- + # Directories + # ----------------------------------------------------------------------- + + async def list_directories( + self, + *, + limit: int | None = None, + skip: int | None = None, + sort: str | None = None, + ) -> JumpCloudResponse: + """List directories (GET /directories) + + Args: + limit: Maximum number of results + skip: Number of results to skip + sort: Field to sort by + + Returns: + JumpCloudResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if skip is not None: + query_params['skip'] = str(skip) + if sort is not None: + query_params['sort'] = sort + + url = self.base_url + "/directories" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return JumpCloudResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_directories" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return JumpCloudResponse(success=False, error=str(e), message="Failed to execute list_directories") + + # ----------------------------------------------------------------------- + # Policies + # ----------------------------------------------------------------------- + + async def list_policies( + self, + *, + limit: int | None = None, + skip: int | None = None, + sort: str | None = None, + filter: str | None = None, + ) -> JumpCloudResponse: + """List policies (GET /policies) + + Args: + limit: Maximum number of results + skip: Number of results to skip + sort: Field to sort by + filter: Filter expression + + Returns: + JumpCloudResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if skip is not None: + query_params['skip'] = str(skip) + if sort is not None: + query_params['sort'] = sort + if filter is not None: + query_params['filter'] = filter + + url = self.base_url + "/policies" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return JumpCloudResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_policies" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return JumpCloudResponse(success=False, error=str(e), message="Failed to execute list_policies") + + async def get_policy( + self, + policy_id: str, + ) -> JumpCloudResponse: + """Get a specific policy (GET /policies/{id}) + + Args: + policy_id: The policy ID + + Returns: + JumpCloudResponse with operation result + """ + url = self.base_url + "/policies/{policy_id}".format(policy_id=policy_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return JumpCloudResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_policy" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return JumpCloudResponse(success=False, error=str(e), message="Failed to execute get_policy") + + # ----------------------------------------------------------------------- + # Organizations & RADIUS Servers + # ----------------------------------------------------------------------- + + async def list_organizations( + self, + *, + limit: int | None = None, + skip: int | None = None, + ) -> JumpCloudResponse: + """List organizations (GET /organizations) + + Args: + limit: Maximum number of results + skip: Number of results to skip + + Returns: + JumpCloudResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if skip is not None: + query_params['skip'] = str(skip) + + url = self.base_url + "/organizations" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return JumpCloudResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_organizations" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return JumpCloudResponse(success=False, error=str(e), message="Failed to execute list_organizations") + + async def list_radius_servers( + self, + *, + limit: int | None = None, + skip: int | None = None, + sort: str | None = None, + filter: str | None = None, + ) -> JumpCloudResponse: + """List RADIUS servers (GET /radiusservers) + + Args: + limit: Maximum number of results + skip: Number of results to skip + sort: Field to sort by + filter: Filter expression + + Returns: + JumpCloudResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if skip is not None: + query_params['skip'] = str(skip) + if sort is not None: + query_params['sort'] = sort + if filter is not None: + query_params['filter'] = filter + + url = self.base_url + "/radiusservers" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return JumpCloudResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_radius_servers" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return JumpCloudResponse(success=False, error=str(e), message="Failed to execute list_radius_servers") diff --git a/backend/python/app/sources/external/keycloak/code_generator.py b/backend/python/app/sources/external/keycloak/code_generator.py new file mode 100644 index 000000000..2d8f92071 --- /dev/null +++ b/backend/python/app/sources/external/keycloak/code_generator.py @@ -0,0 +1,246 @@ +# ruff: noqa +""" +Keycloak DataSource Code Generator + +Defines Keycloak Admin REST API endpoint specifications and generates the +DataSource wrapper class (keycloak.py) from them. + +Endpoints: + /users, /users/{id}, /users/count, /groups, /groups/{id}, + /groups/{id}/members, /clients, /clients/{id}, /roles, /roles/{roleName}, + /roles/{roleName}/users, /events, /admin-events, + /identity-provider/instances, /authentication/flows + +Note: For OAuth clients, ensure_authenticated() is called if available. +""" + +from __future__ import annotations + +ENDPOINTS = [ + # Users + {"method": "GET", "path": "/users", "name": "list_users", "section": "Users", + "doc": "List all users in the realm", + "query_params": [("first", "int", "Pagination offset"), ("max", "int", "Maximum results to return"), + ("search", "str", "Search string for username, first/last name, or email"), + ("username", "str", "Filter by username"), ("email", "str", "Filter by email"), + ("enabled", "str", "Filter by enabled status (true/false)")]}, + {"method": "GET", "path": "/users/{user_id}", "name": "get_user", "section": "Users", + "doc": "Get a specific user by ID", "path_params": ["user_id"]}, + {"method": "GET", "path": "/users/count", "name": "get_users_count", "section": "Users", + "doc": "Get the total number of users in the realm"}, + # Groups + {"method": "GET", "path": "/groups", "name": "list_groups", "section": "Groups", + "doc": "List all groups in the realm", + "query_params": [("first", "int", "Pagination offset"), ("max", "int", "Maximum results to return"), + ("search", "str", "Search string for group name")]}, + {"method": "GET", "path": "/groups/{group_id}", "name": "get_group", "section": "Groups", + "doc": "Get a specific group by ID", "path_params": ["group_id"]}, + {"method": "GET", "path": "/groups/{group_id}/members", "name": "get_group_members", "section": "Groups", + "doc": "Get members of a specific group", "path_params": ["group_id"], + "query_params": [("first", "int", "Pagination offset"), ("max", "int", "Maximum results to return")]}, + # Clients + {"method": "GET", "path": "/clients", "name": "list_clients", "section": "Clients", + "doc": "List all clients in the realm", + "query_params": [("first", "int", "Pagination offset"), ("max", "int", "Maximum results to return"), + ("search", "str", "Filter by client ID or name")]}, + {"method": "GET", "path": "/clients/{client_id}", "name": "get_client", "section": "Clients", + "doc": "Get a specific client by ID", "path_params": ["client_id"]}, + # Roles + {"method": "GET", "path": "/roles", "name": "list_roles", "section": "Roles", + "doc": "List all realm-level roles", + "query_params": [("first", "int", "Pagination offset"), ("max", "int", "Maximum results to return"), + ("search", "str", "Filter by role name")]}, + {"method": "GET", "path": "/roles/{role_name}", "name": "get_role", "section": "Roles", + "doc": "Get a specific role by name", "path_params": ["role_name"]}, + {"method": "GET", "path": "/roles/{role_name}/users", "name": "get_role_users", "section": "Roles", + "doc": "Get users assigned to a specific role", "path_params": ["role_name"], + "query_params": [("first", "int", "Pagination offset"), ("max", "int", "Maximum results to return")]}, + # Events + {"method": "GET", "path": "/events", "name": "list_events", "section": "Events", + "doc": "List login events in the realm", + "query_params": [("type", "str", "Event type filter"), ("dateFrom", "str", "Date range start (yyyy-MM-dd)"), + ("dateTo", "str", "Date range end (yyyy-MM-dd)"), + ("first", "int", "Pagination offset"), ("max", "int", "Maximum results to return")]}, + {"method": "GET", "path": "/admin-events", "name": "list_admin_events", "section": "Events", + "doc": "List admin events in the realm", + "query_params": [("first", "int", "Pagination offset"), ("max", "int", "Maximum results to return")]}, + # Identity Providers + {"method": "GET", "path": "/identity-provider/instances", "name": "list_identity_providers", + "section": "Identity Providers", "doc": "List all identity provider instances"}, + # Authentication Flows + {"method": "GET", "path": "/authentication/flows", "name": "list_authentication_flows", + "section": "Authentication Flows", "doc": "List all authentication flows"}, +] + + +def _gen_method(ep: dict) -> str: + """Generate a single async method from an endpoint spec.""" + name = ep["name"] + method = ep["method"] + path = ep["path"] + doc = ep["doc"] + path_params = ep.get("path_params", []) + query_params = ep.get("query_params", []) + body_params = ep.get("body_params", []) + + sig_parts = ["self"] + for p in path_params: + sig_parts.append(f"{p}: str") + for bp in body_params: + sig_parts.append(f"{bp[0]}: {bp[2]}") + if query_params: + sig_parts.append("*") + for qp in query_params: + sig_parts.append(f"{qp[0]}: {qp[1]} | None = None") + + sig = ",\n ".join(sig_parts) + + doc_args = "" + if path_params or query_params or body_params: + doc_args = "\n Args:\n" + for p in path_params: + doc_args += f" {p}: The {p.replace('_', ' ')}\n" + for bp in body_params: + doc_args += f" {bp[0]}: {bp[3]}\n" + for qp in query_params: + doc_args += f" {qp[0]}: {qp[2]}\n" + + query_block = "" + if query_params: + lines = ["\n query_params: dict[str, Any] = {}"] + for qp in query_params: + lines.append(f" if {qp[0]} is not None:") + lines.append(f" query_params['{qp[0]}'] = str({qp[0]})") + query_block = "\n".join(lines) + "\n" + + if path_params: + fmt_args = ", ".join(f"{p}={p}" for p in path_params) + url_line = f' url = self.base_url + "{path}".format({fmt_args})' + else: + url_line = f' url = self.base_url + "{path}"' + + body_block = "" + if body_params: + lines = ["\n body: dict[str, Any] = {}"] + for bp in body_params: + lines.append(f' if {bp[0]} is not None:') + lines.append(f' body["{bp[1]}"] = {bp[0]}') + body_block = "\n".join(lines) + + req_extra = "" + if query_params: + req_extra += "\n query=query_params," + if body_params: + req_extra += "\n body=body," + + return f''' + async def {name}( + {sig} + ) -> KeycloakResponse: + """{doc} + + HTTP {method} {path} +{doc_args} + Returns: + KeycloakResponse with operation result + """ + if hasattr(self.http, 'ensure_authenticated'): + await self.http.ensure_authenticated() +{query_block} +{url_line} +{body_block} + + try: + request = HTTPRequest( + method="{method}", + url=url, + headers={{"Content-Type": "application/json"}},{req_extra} + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return KeycloakResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed {name}" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {{response.status}}" + ) + except Exception as e: + return KeycloakResponse(success=False, error=str(e), message="Failed to execute {name}") +''' + + +def generate_datasource() -> str: + """Generate the full Keycloak DataSource module code.""" + header = '''# ruff: noqa +""" +Keycloak Admin REST API DataSource - Auto-generated API wrapper + +Generated from Keycloak Admin REST API documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. + +Note: For OAuth clients, ensure_authenticated() is called before each + request to auto-fetch a client_credentials OAuth token. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.keycloak.keycloak import KeycloakClient, KeycloakResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class KeycloakDataSource: + """Keycloak Admin REST API DataSource + + Provides async wrapper methods for Keycloak Admin REST API operations: + - Users management + - Groups management + - Clients management + - Roles management + - Events (login and admin) + - Identity Providers + - Authentication Flows + + All methods return KeycloakResponse objects. + """ + + def __init__(self, client: KeycloakClient) -> None: + """Initialize with KeycloakClient. + + Args: + client: KeycloakClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'KeycloakDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> KeycloakClient: + """Return the underlying KeycloakClient.""" + return self._client +''' + + methods = [] + current_section = None + for ep in ENDPOINTS: + section = ep.get("section", "") + if section and section != current_section: + current_section = section + methods.append(f"\n # {'-' * 71}\n # {section}\n # {'-' * 71}") + methods.append(_gen_method(ep)) + + return header + "\n".join(methods) + "\n" + + +if __name__ == "__main__": + print(generate_datasource()) diff --git a/backend/python/app/sources/external/keycloak/example.py b/backend/python/app/sources/external/keycloak/example.py new file mode 100644 index 000000000..8ff701390 --- /dev/null +++ b/backend/python/app/sources/external/keycloak/example.py @@ -0,0 +1,176 @@ +# ruff: noqa + +""" +Keycloak API Usage Examples + +This example demonstrates how to use the Keycloak DataSource to interact with +the Keycloak Admin REST API, covering: +- Authentication (OAuth2 client_credentials, Bearer Token) +- Initializing the Client and DataSource +- Listing Users, Groups, Clients, Roles +- Fetching Events and Identity Providers + +Prerequisites: +For OAuth2 (client_credentials): +1. Create a client in Keycloak with "Service accounts roles" enabled +2. Assign realm-management roles to the service account +3. Set KEYCLOAK_HOSTNAME, KEYCLOAK_REALM, KEYCLOAK_CLIENT_ID, + and KEYCLOAK_CLIENT_SECRET environment variables + +For Bearer Token: +1. Obtain a token via Keycloak token endpoint +2. Set KEYCLOAK_HOSTNAME, KEYCLOAK_REALM, and KEYCLOAK_TOKEN + environment variables +""" + +import asyncio +import json +import os + +from app.sources.client.keycloak.keycloak import ( + KeycloakClient, + KeycloakOAuthConfig, + KeycloakResponse, + KeycloakTokenConfig, +) +from app.sources.external.keycloak.keycloak import KeycloakDataSource + +# --- Configuration --- +HOSTNAME = os.getenv("KEYCLOAK_HOSTNAME") +REALM = os.getenv("KEYCLOAK_REALM", "master") + +# OAuth2 credentials +CLIENT_ID = os.getenv("KEYCLOAK_CLIENT_ID") +CLIENT_SECRET = os.getenv("KEYCLOAK_CLIENT_SECRET") + +# Bearer Token +TOKEN = os.getenv("KEYCLOAK_TOKEN") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: KeycloakResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + if isinstance(data, list): + print(f" Found {len(data)} items.") + if data: + print(f" Sample: {json.dumps(data[0], indent=2)[:400]}...") + elif isinstance(data, dict): + print(f" Data: {json.dumps(data, indent=2)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + # 1. Initialize Client + print_section("Initializing Keycloak Client") + + if not HOSTNAME: + print(" KEYCLOAK_HOSTNAME is required.") + return + + config = None + + # Priority 1: OAuth2 client_credentials + if CLIENT_ID and CLIENT_SECRET: + print(" Using OAuth2 client_credentials authentication") + config = KeycloakOAuthConfig( + hostname=HOSTNAME, + realm=REALM, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + ) + + # Priority 2: Bearer Token + if config is None and TOKEN: + print(" Using Bearer Token authentication") + config = KeycloakTokenConfig( + token=TOKEN, + hostname=HOSTNAME, + realm=REALM, + ) + + if config is None: + print(" No valid authentication method found.") + print(" Please set one of the following:") + print(" - KEYCLOAK_CLIENT_ID and KEYCLOAK_CLIENT_SECRET (for OAuth2)") + print(" - KEYCLOAK_TOKEN (for Bearer Token)") + return + + client = KeycloakClient.build_with_config(config) + data_source = KeycloakDataSource(client) + print("Client initialized successfully.") + + try: + # 2. List Users + print_section("Users") + users_resp = await data_source.list_users(max=10) + print_result("List Users", users_resp) + + # 3. Get Users Count + print_section("Users Count") + count_resp = await data_source.get_users_count() + print_result("Users Count", count_resp) + + # 4. Get a specific user if available + if users_resp.success and isinstance(users_resp.data, list) and users_resp.data: + user_id = str(users_resp.data[0].get("id", "")) + if user_id: + print_section(f"User Details: {user_id}") + user_resp = await data_source.get_user(user_id=user_id) + print_result("Get User", user_resp) + + # 5. List Groups + print_section("Groups") + groups_resp = await data_source.list_groups(max=10) + print_result("List Groups", groups_resp) + + # 6. List Clients + print_section("Clients") + clients_resp = await data_source.list_clients(max=10) + print_result("List Clients", clients_resp) + + # 7. List Roles + print_section("Roles") + roles_resp = await data_source.list_roles(max=10) + print_result("List Roles", roles_resp) + + # 8. List Events + print_section("Events") + events_resp = await data_source.list_events(max=10) + print_result("List Events", events_resp) + + # 9. List Identity Providers + print_section("Identity Providers") + idp_resp = await data_source.list_identity_providers() + print_result("List Identity Providers", idp_resp) + + # 10. List Authentication Flows + print_section("Authentication Flows") + flows_resp = await data_source.list_authentication_flows() + print_result("List Authentication Flows", flows_resp) + + finally: + # Cleanup: Close the HTTP client session + print("\nClosing client connection...") + inner_client = client.get_client() + if hasattr(inner_client, "close"): + await inner_client.close() + + print("\n" + "=" * 80) + print(" All Keycloak API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/keycloak/keycloak.py b/backend/python/app/sources/external/keycloak/keycloak.py new file mode 100644 index 000000000..2c86d8271 --- /dev/null +++ b/backend/python/app/sources/external/keycloak/keycloak.py @@ -0,0 +1,754 @@ +# ruff: noqa +""" +Keycloak Admin REST API DataSource - Auto-generated API wrapper + +Generated from Keycloak Admin REST API documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. + +Note: For OAuth clients, ensure_authenticated() is called before each + request to auto-fetch a client_credentials OAuth token. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.keycloak.keycloak import KeycloakClient, KeycloakResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class KeycloakDataSource: + """Keycloak Admin REST API DataSource + + Provides async wrapper methods for Keycloak Admin REST API operations: + - Users management + - Groups management + - Clients management + - Roles management + - Events (login and admin) + - Identity Providers + - Authentication Flows + + All methods return KeycloakResponse objects. + """ + + def __init__(self, client: KeycloakClient) -> None: + """Initialize with KeycloakClient. + + Args: + client: KeycloakClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'KeycloakDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> KeycloakClient: + """Return the underlying KeycloakClient.""" + return self._client + + # ----------------------------------------------------------------------- + # Users + # ----------------------------------------------------------------------- + + async def list_users( + self, + *, + first: int | None = None, + max: int | None = None, + search: str | None = None, + username: str | None = None, + email: str | None = None, + enabled: str | None = None + ) -> KeycloakResponse: + """List all users in the realm + + HTTP GET /users + + Args: + first: Pagination offset + max: Maximum results to return + search: Search string for username, first/last name, or email + username: Filter by username + email: Filter by email + enabled: Filter by enabled status (true/false) + + Returns: + KeycloakResponse with operation result + """ + if hasattr(self.http, 'ensure_authenticated'): + await self.http.ensure_authenticated() + + query_params: dict[str, Any] = {} + if first is not None: + query_params['first'] = str(first) + if max is not None: + query_params['max'] = str(max) + if search is not None: + query_params['search'] = str(search) + if username is not None: + query_params['username'] = str(username) + if email is not None: + query_params['email'] = str(email) + if enabled is not None: + query_params['enabled'] = str(enabled) + + url = self.base_url + "/users" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return KeycloakResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_users" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return KeycloakResponse(success=False, error=str(e), message="Failed to execute list_users") + + + async def get_user( + self, + user_id: str + ) -> KeycloakResponse: + """Get a specific user by ID + + HTTP GET /users/{user_id} + + Args: + user_id: The user id + + Returns: + KeycloakResponse with operation result + """ + if hasattr(self.http, 'ensure_authenticated'): + await self.http.ensure_authenticated() + + url = self.base_url + "/users/{user_id}".format(user_id=user_id) + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return KeycloakResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_user" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return KeycloakResponse(success=False, error=str(e), message="Failed to execute get_user") + + + async def get_users_count( + self + ) -> KeycloakResponse: + """Get the total number of users in the realm + + HTTP GET /users/count + + Returns: + KeycloakResponse with operation result + """ + if hasattr(self.http, 'ensure_authenticated'): + await self.http.ensure_authenticated() + + url = self.base_url + "/users/count" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return KeycloakResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_users_count" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return KeycloakResponse(success=False, error=str(e), message="Failed to execute get_users_count") + + + # ----------------------------------------------------------------------- + # Groups + # ----------------------------------------------------------------------- + + async def list_groups( + self, + *, + first: int | None = None, + max: int | None = None, + search: str | None = None + ) -> KeycloakResponse: + """List all groups in the realm + + HTTP GET /groups + + Args: + first: Pagination offset + max: Maximum results to return + search: Search string for group name + + Returns: + KeycloakResponse with operation result + """ + if hasattr(self.http, 'ensure_authenticated'): + await self.http.ensure_authenticated() + + query_params: dict[str, Any] = {} + if first is not None: + query_params['first'] = str(first) + if max is not None: + query_params['max'] = str(max) + if search is not None: + query_params['search'] = str(search) + + url = self.base_url + "/groups" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return KeycloakResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_groups" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return KeycloakResponse(success=False, error=str(e), message="Failed to execute list_groups") + + + async def get_group( + self, + group_id: str + ) -> KeycloakResponse: + """Get a specific group by ID + + HTTP GET /groups/{group_id} + + Args: + group_id: The group id + + Returns: + KeycloakResponse with operation result + """ + if hasattr(self.http, 'ensure_authenticated'): + await self.http.ensure_authenticated() + + url = self.base_url + "/groups/{group_id}".format(group_id=group_id) + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return KeycloakResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_group" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return KeycloakResponse(success=False, error=str(e), message="Failed to execute get_group") + + + async def get_group_members( + self, + group_id: str, + *, + first: int | None = None, + max: int | None = None + ) -> KeycloakResponse: + """Get members of a specific group + + HTTP GET /groups/{group_id}/members + + Args: + group_id: The group id + first: Pagination offset + max: Maximum results to return + + Returns: + KeycloakResponse with operation result + """ + if hasattr(self.http, 'ensure_authenticated'): + await self.http.ensure_authenticated() + + query_params: dict[str, Any] = {} + if first is not None: + query_params['first'] = str(first) + if max is not None: + query_params['max'] = str(max) + + url = self.base_url + "/groups/{group_id}/members".format(group_id=group_id) + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return KeycloakResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_group_members" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return KeycloakResponse(success=False, error=str(e), message="Failed to execute get_group_members") + + + # ----------------------------------------------------------------------- + # Clients + # ----------------------------------------------------------------------- + + async def list_clients( + self, + *, + first: int | None = None, + max: int | None = None, + search: str | None = None + ) -> KeycloakResponse: + """List all clients in the realm + + HTTP GET /clients + + Args: + first: Pagination offset + max: Maximum results to return + search: Filter by client ID or name + + Returns: + KeycloakResponse with operation result + """ + if hasattr(self.http, 'ensure_authenticated'): + await self.http.ensure_authenticated() + + query_params: dict[str, Any] = {} + if first is not None: + query_params['first'] = str(first) + if max is not None: + query_params['max'] = str(max) + if search is not None: + query_params['search'] = str(search) + + url = self.base_url + "/clients" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return KeycloakResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_clients" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return KeycloakResponse(success=False, error=str(e), message="Failed to execute list_clients") + + + async def get_client( + self, + client_id: str + ) -> KeycloakResponse: + """Get a specific client by ID + + HTTP GET /clients/{client_id} + + Args: + client_id: The client id + + Returns: + KeycloakResponse with operation result + """ + if hasattr(self.http, 'ensure_authenticated'): + await self.http.ensure_authenticated() + + url = self.base_url + "/clients/{client_id}".format(client_id=client_id) + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return KeycloakResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_client" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return KeycloakResponse(success=False, error=str(e), message="Failed to execute get_client") + + + # ----------------------------------------------------------------------- + # Roles + # ----------------------------------------------------------------------- + + async def list_roles( + self, + *, + first: int | None = None, + max: int | None = None, + search: str | None = None + ) -> KeycloakResponse: + """List all realm-level roles + + HTTP GET /roles + + Args: + first: Pagination offset + max: Maximum results to return + search: Filter by role name + + Returns: + KeycloakResponse with operation result + """ + if hasattr(self.http, 'ensure_authenticated'): + await self.http.ensure_authenticated() + + query_params: dict[str, Any] = {} + if first is not None: + query_params['first'] = str(first) + if max is not None: + query_params['max'] = str(max) + if search is not None: + query_params['search'] = str(search) + + url = self.base_url + "/roles" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return KeycloakResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_roles" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return KeycloakResponse(success=False, error=str(e), message="Failed to execute list_roles") + + + async def get_role( + self, + role_name: str + ) -> KeycloakResponse: + """Get a specific role by name + + HTTP GET /roles/{role_name} + + Args: + role_name: The role name + + Returns: + KeycloakResponse with operation result + """ + if hasattr(self.http, 'ensure_authenticated'): + await self.http.ensure_authenticated() + + url = self.base_url + "/roles/{role_name}".format(role_name=role_name) + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return KeycloakResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_role" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return KeycloakResponse(success=False, error=str(e), message="Failed to execute get_role") + + + async def get_role_users( + self, + role_name: str, + *, + first: int | None = None, + max: int | None = None + ) -> KeycloakResponse: + """Get users assigned to a specific role + + HTTP GET /roles/{role_name}/users + + Args: + role_name: The role name + first: Pagination offset + max: Maximum results to return + + Returns: + KeycloakResponse with operation result + """ + if hasattr(self.http, 'ensure_authenticated'): + await self.http.ensure_authenticated() + + query_params: dict[str, Any] = {} + if first is not None: + query_params['first'] = str(first) + if max is not None: + query_params['max'] = str(max) + + url = self.base_url + "/roles/{role_name}/users".format(role_name=role_name) + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return KeycloakResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_role_users" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return KeycloakResponse(success=False, error=str(e), message="Failed to execute get_role_users") + + + # ----------------------------------------------------------------------- + # Events + # ----------------------------------------------------------------------- + + async def list_events( + self, + *, + type: str | None = None, + dateFrom: str | None = None, + dateTo: str | None = None, + first: int | None = None, + max: int | None = None + ) -> KeycloakResponse: + """List login events in the realm + + HTTP GET /events + + Args: + type: Event type filter + dateFrom: Date range start (yyyy-MM-dd) + dateTo: Date range end (yyyy-MM-dd) + first: Pagination offset + max: Maximum results to return + + Returns: + KeycloakResponse with operation result + """ + if hasattr(self.http, 'ensure_authenticated'): + await self.http.ensure_authenticated() + + query_params: dict[str, Any] = {} + if type is not None: + query_params['type'] = str(type) + if dateFrom is not None: + query_params['dateFrom'] = str(dateFrom) + if dateTo is not None: + query_params['dateTo'] = str(dateTo) + if first is not None: + query_params['first'] = str(first) + if max is not None: + query_params['max'] = str(max) + + url = self.base_url + "/events" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return KeycloakResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_events" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return KeycloakResponse(success=False, error=str(e), message="Failed to execute list_events") + + + async def list_admin_events( + self, + *, + first: int | None = None, + max: int | None = None + ) -> KeycloakResponse: + """List admin events in the realm + + HTTP GET /admin-events + + Args: + first: Pagination offset + max: Maximum results to return + + Returns: + KeycloakResponse with operation result + """ + if hasattr(self.http, 'ensure_authenticated'): + await self.http.ensure_authenticated() + + query_params: dict[str, Any] = {} + if first is not None: + query_params['first'] = str(first) + if max is not None: + query_params['max'] = str(max) + + url = self.base_url + "/admin-events" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return KeycloakResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_admin_events" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return KeycloakResponse(success=False, error=str(e), message="Failed to execute list_admin_events") + + + # ----------------------------------------------------------------------- + # Identity Providers + # ----------------------------------------------------------------------- + + async def list_identity_providers( + self + ) -> KeycloakResponse: + """List all identity provider instances + + HTTP GET /identity-provider/instances + + Returns: + KeycloakResponse with operation result + """ + if hasattr(self.http, 'ensure_authenticated'): + await self.http.ensure_authenticated() + + url = self.base_url + "/identity-provider/instances" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return KeycloakResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_identity_providers" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return KeycloakResponse(success=False, error=str(e), message="Failed to execute list_identity_providers") + + + # ----------------------------------------------------------------------- + # Authentication Flows + # ----------------------------------------------------------------------- + + async def list_authentication_flows( + self + ) -> KeycloakResponse: + """List all authentication flows + + HTTP GET /authentication/flows + + Returns: + KeycloakResponse with operation result + """ + if hasattr(self.http, 'ensure_authenticated'): + await self.http.ensure_authenticated() + + url = self.base_url + "/authentication/flows" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return KeycloakResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_authentication_flows" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return KeycloakResponse(success=False, error=str(e), message="Failed to execute list_authentication_flows") + + diff --git a/backend/python/app/sources/external/lookerstudio/example.py b/backend/python/app/sources/external/lookerstudio/example.py new file mode 100644 index 000000000..c00ec3873 --- /dev/null +++ b/backend/python/app/sources/external/lookerstudio/example.py @@ -0,0 +1,182 @@ +# ruff: noqa + +""" +Looker Studio API Usage Examples + +This example demonstrates how to use the Looker Studio DataSource to interact +with the Looker Studio (Google Data Studio) API, covering: +- Authentication (OAuth2, Service Account Token) +- Initializing the Client and DataSource +- Searching assets +- Listing reports and data sources +- Getting specific asset details and permissions + +Prerequisites: +For OAuth2: +1. Create a Google Cloud project and enable the Looker Studio API +2. Create OAuth 2.0 credentials +3. Set LOOKERSTUDIO_CLIENT_ID and LOOKERSTUDIO_CLIENT_SECRET env vars + +For Service Account Token: +1. Create a service account in Google Cloud +2. Generate and download a JSON key +3. Exchange for an access token +4. Set LOOKERSTUDIO_TOKEN environment variable + +OAuth Scopes: +- https://www.googleapis.com/auth/datastudio +- https://www.googleapis.com/auth/datastudio.readonly +""" + +import asyncio +import json +import os + +from app.sources.client.lookerstudio.lookerstudio import ( + LookerStudioClient, + LookerStudioOAuthConfig, + LookerStudioResponse, + LookerStudioTokenConfig, +) +from app.sources.external.lookerstudio.lookerstudio import LookerStudioDataSource +from app.sources.external.utils.oauth import perform_oauth_flow + +# --- Configuration --- +# OAuth2 credentials (highest priority) +CLIENT_ID = os.getenv("LOOKERSTUDIO_CLIENT_ID") +CLIENT_SECRET = os.getenv("LOOKERSTUDIO_CLIENT_SECRET") + +# Bearer Token (second priority) +TOKEN = os.getenv("LOOKERSTUDIO_TOKEN") + +# OAuth redirect URI +REDIRECT_URI = os.getenv("LOOKERSTUDIO_REDIRECT_URI", "http://localhost:8080/callback") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: LookerStudioResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + if isinstance(data, dict): + for key in ("assets", "reports", "dataSources"): + if key in data: + items = data[key] + print(f" Found {len(items)} {key}.") + if items: + print(f" Sample: {json.dumps(items[0], indent=2)[:400]}...") + return + print(f" Data: {json.dumps(data, indent=2)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + # 1. Initialize Client + print_section("Initializing Looker Studio Client") + + config = None + + # Priority 1: OAuth2 + if CLIENT_ID and CLIENT_SECRET: + print(" Using OAuth2 authentication") + try: + token_response = perform_oauth_flow( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + auth_endpoint="https://accounts.google.com/o/oauth2/v2/auth", + token_endpoint="https://oauth2.googleapis.com/token", + redirect_uri=REDIRECT_URI, + scopes=[ + "https://www.googleapis.com/auth/datastudio", + "https://www.googleapis.com/auth/datastudio.readonly", + ], + scope_delimiter=" ", + auth_method="body", + ) + + access_token = token_response.get("access_token") + if not access_token: + raise Exception("No access_token found in OAuth response") + + config = LookerStudioOAuthConfig( + access_token=access_token, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + redirect_uri=REDIRECT_URI, + ) + print(" OAuth authentication successful") + except Exception as e: + print(f" OAuth flow failed: {e}") + print(" Falling back to other authentication methods...") + + # Priority 2: Token + if config is None and TOKEN: + print(" Using Bearer Token authentication") + config = LookerStudioTokenConfig(token=TOKEN) + + if config is None: + print(" No valid authentication method found.") + print(" Please set one of the following:") + print(" - LOOKERSTUDIO_CLIENT_ID and LOOKERSTUDIO_CLIENT_SECRET (OAuth2)") + print(" - LOOKERSTUDIO_TOKEN (Bearer Token)") + return + + client = LookerStudioClient.build_with_config(config) + data_source = LookerStudioDataSource(client) + print(" Client initialized successfully.") + + try: + # 2. Search Assets + print_section("Search Assets") + assets_resp = await data_source.search_assets(page_size=10) + print_result("Search Assets", assets_resp) + + # 3. List Reports + print_section("Reports") + reports_resp = await data_source.list_reports() + print_result("List Reports", reports_resp) + + # 4. List Data Sources + print_section("Data Sources") + ds_resp = await data_source.list_data_sources() + print_result("List Data Sources", ds_resp) + + # 5. Get specific asset if available + if assets_resp.success and assets_resp.data: + data = assets_resp.data + assets = data.get("assets", []) if isinstance(data, dict) else [] + if assets: + asset_id = str(assets[0].get("name", "").split("/")[-1] or assets[0].get("assetId", "")) + if asset_id: + print_section(f"Asset Details: {asset_id}") + detail_resp = await data_source.get_asset(asset_id=asset_id) + print_result("Get Asset", detail_resp) + + print_section(f"Asset Permissions: {asset_id}") + perms_resp = await data_source.get_asset_permissions(asset_id=asset_id) + print_result("Get Permissions", perms_resp) + + finally: + # Cleanup: Close the HTTP client session + print("\nClosing client connection...") + inner_client = client.get_client() + if hasattr(inner_client, "close"): + await inner_client.close() + + print("\n" + "=" * 80) + print(" All Looker Studio API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/lookerstudio/lookerstudio.py b/backend/python/app/sources/external/lookerstudio/lookerstudio.py new file mode 100644 index 000000000..18820b234 --- /dev/null +++ b/backend/python/app/sources/external/lookerstudio/lookerstudio.py @@ -0,0 +1,274 @@ +# ruff: noqa +""" +Looker Studio (Google Data Studio) REST API DataSource - Auto-generated API wrapper + +Generated from Looker Studio REST API v1 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.lookerstudio.lookerstudio import LookerStudioClient, LookerStudioResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class LookerStudioDataSource: + """Looker Studio REST API DataSource + + Provides async wrapper methods for Looker Studio REST API operations: + - Asset search and retrieval + - Report management + - Data source management + - Permissions + + The base URL is https://datastudio.googleapis.com/v1 + + All methods return LookerStudioResponse objects. + """ + + def __init__(self, client: LookerStudioClient) -> None: + """Initialize with LookerStudioClient. + + Args: + client: LookerStudioClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'LookerStudioDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> LookerStudioClient: + """Return the underlying LookerStudioClient.""" + return self._client + + async def search_assets( + self, + *, + title: str | None = None, + page_size: int | None = None, + page_token: str | None = None, + asset_types: str | None = None, + ) -> LookerStudioResponse: + """Search assets (GET /assets:search) + + Args: + title: Filter by asset title + page_size: Maximum number of results per page + page_token: Token for pagination + asset_types: Filter by asset types (e.g. REPORT, DATA_SOURCE) + + Returns: + LookerStudioResponse with operation result + """ + query_params: dict[str, Any] = {} + if title is not None: + query_params['title'] = title + if page_size is not None: + query_params['pageSize'] = str(page_size) + if page_token is not None: + query_params['pageToken'] = page_token + if asset_types is not None: + query_params['assetTypes'] = asset_types + + url = self.base_url + "/assets:search" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return LookerStudioResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed search_assets" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return LookerStudioResponse(success=False, error=str(e), message="Failed to execute search_assets") + + async def get_asset( + self, + asset_id: str, + ) -> LookerStudioResponse: + """Get a specific asset (GET /assets/{assetId}) + + Args: + asset_id: The asset ID + + Returns: + LookerStudioResponse with operation result + """ + url = self.base_url + "/assets/{asset_id}".format(asset_id=asset_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return LookerStudioResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_asset" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return LookerStudioResponse(success=False, error=str(e), message="Failed to execute get_asset") + + async def get_asset_permissions( + self, + asset_id: str, + ) -> LookerStudioResponse: + """Get permissions for an asset (GET /assets/{assetId}/permissions) + + Args: + asset_id: The asset ID + + Returns: + LookerStudioResponse with operation result + """ + url = self.base_url + "/assets/{asset_id}/permissions".format(asset_id=asset_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return LookerStudioResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_asset_permissions" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return LookerStudioResponse(success=False, error=str(e), message="Failed to execute get_asset_permissions") + + async def list_reports( + self, + ) -> LookerStudioResponse: + """List all reports (GET /reports) + + Returns: + LookerStudioResponse with operation result + """ + url = self.base_url + "/reports" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return LookerStudioResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_reports" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return LookerStudioResponse(success=False, error=str(e), message="Failed to execute list_reports") + + async def get_report( + self, + report_id: str, + ) -> LookerStudioResponse: + """Get a specific report (GET /reports/{reportId}) + + Args: + report_id: The report ID + + Returns: + LookerStudioResponse with operation result + """ + url = self.base_url + "/reports/{report_id}".format(report_id=report_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return LookerStudioResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_report" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return LookerStudioResponse(success=False, error=str(e), message="Failed to execute get_report") + + async def list_data_sources( + self, + ) -> LookerStudioResponse: + """List all data sources (GET /dataSources) + + Returns: + LookerStudioResponse with operation result + """ + url = self.base_url + "/dataSources" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return LookerStudioResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_data_sources" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return LookerStudioResponse(success=False, error=str(e), message="Failed to execute list_data_sources") + + async def get_data_source_detail( + self, + data_source_id: str, + ) -> LookerStudioResponse: + """Get a specific data source (GET /dataSources/{dataSourceId}) + + Args: + data_source_id: The data source ID + + Returns: + LookerStudioResponse with operation result + """ + url = self.base_url + "/dataSources/{data_source_id}".format(data_source_id=data_source_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return LookerStudioResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_data_source_detail" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return LookerStudioResponse(success=False, error=str(e), message="Failed to execute get_data_source_detail") diff --git a/backend/python/app/sources/external/loopio/code_generator.py b/backend/python/app/sources/external/loopio/code_generator.py new file mode 100644 index 000000000..aa54390b9 --- /dev/null +++ b/backend/python/app/sources/external/loopio/code_generator.py @@ -0,0 +1,224 @@ +# ruff: noqa +""" +Loopio DataSource Code Generator + +Defines Loopio API endpoint specifications and generates the DataSource +wrapper class (loopio.py) from them. + +Endpoints: + /projects, /projects/{id}, /entries, /entries/{id}, /library, /library/{id}, + /categories, /categories/{id}, /users, /users/{id}, /groups, /groups/{id}, + /tags, /tags/{id} +""" + +from __future__ import annotations + +ENDPOINTS = [ + # Projects + {"method": "GET", "path": "/projects", "name": "get_projects", "section": "Projects", + "doc": "List all projects", "paginated": True}, + {"method": "GET", "path": "/projects/{project_id}", "name": "get_project", "section": "Projects", + "doc": "Get a specific project by ID", "path_params": ["project_id"]}, + # Entries + {"method": "GET", "path": "/entries", "name": "get_entries", "section": "Entries", + "doc": "List all entries", "paginated": True}, + {"method": "GET", "path": "/entries/{entry_id}", "name": "get_entry", "section": "Entries", + "doc": "Get a specific entry by ID", "path_params": ["entry_id"]}, + # Library + {"method": "GET", "path": "/library", "name": "get_library_items", "section": "Library", + "doc": "List all library items", "paginated": True}, + {"method": "GET", "path": "/library/{library_id}", "name": "get_library_item", "section": "Library", + "doc": "Get a specific library item by ID", "path_params": ["library_id"]}, + # Categories + {"method": "GET", "path": "/categories", "name": "get_categories", "section": "Categories", + "doc": "List all categories", "paginated": True}, + {"method": "GET", "path": "/categories/{category_id}", "name": "get_category", "section": "Categories", + "doc": "Get a specific category by ID", "path_params": ["category_id"]}, + # Users + {"method": "GET", "path": "/users", "name": "get_users", "section": "Users", + "doc": "List all users", "paginated": True}, + {"method": "GET", "path": "/users/{user_id}", "name": "get_user", "section": "Users", + "doc": "Get a specific user by ID", "path_params": ["user_id"]}, + # Groups + {"method": "GET", "path": "/groups", "name": "get_groups", "section": "Groups", + "doc": "List all groups", "paginated": True}, + {"method": "GET", "path": "/groups/{group_id}", "name": "get_group", "section": "Groups", + "doc": "Get a specific group by ID", "path_params": ["group_id"]}, + # Tags + {"method": "GET", "path": "/tags", "name": "get_tags", "section": "Tags", + "doc": "List all tags", "paginated": True}, + {"method": "GET", "path": "/tags/{tag_id}", "name": "get_tag", "section": "Tags", + "doc": "Get a specific tag by ID", "path_params": ["tag_id"]}, +] + + +def _gen_method(ep: dict) -> str: + """Generate a single async method from an endpoint spec.""" + name = ep["name"] + method = ep["method"] + path = ep["path"] + doc = ep["doc"] + path_params = ep.get("path_params", []) + paginated = ep.get("paginated", False) + body_params = ep.get("body_params", []) + + sig_parts = ["self"] + for p in path_params: + sig_parts.append(f"{p}: str") + for bp in body_params: + sig_parts.append(f"{bp[0]}: {bp[2]}") + if paginated: + sig_parts.append("*") + sig_parts.append("limit: int | None = None") + sig_parts.append("offset: int | None = None") + + sig = ",\n ".join(sig_parts) + + doc_args = "" + if path_params or paginated or body_params: + doc_args = "\n Args:\n" + for p in path_params: + doc_args += f" {p}: The {p.replace('_', ' ')}\n" + for bp in body_params: + doc_args += f" {bp[0]}: {bp[3]}\n" + if paginated: + doc_args += " limit: Maximum number of results to return\n" + doc_args += " offset: Number of results to skip\n" + + query_block = "" + if paginated: + query_block = """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) +""" + + if path_params: + fmt_args = ", ".join(f"{p}={p}" for p in path_params) + url_line = f' url = self.base_url + "{path}".format({fmt_args})' + else: + url_line = f' url = self.base_url + "{path}"' + + body_block = "" + if body_params: + lines = ["\n body: dict[str, Any] = {"] + for bp in body_params: + lines.append(f' "{bp[1]}": {bp[0]},') + lines.append(" }") + body_block = "\n".join(lines) + + req_extra = "" + if paginated: + req_extra += "\n query=query_params," + if body_params: + req_extra += "\n body=body," + + return f''' + async def {name}( + {sig}, + ) -> LoopioResponse: + """{doc} + + HTTP {method} {path} +{doc_args} + Returns: + LoopioResponse with operation result + """ +{query_block} +{url_line} + + try: + request = HTTPRequest( + method="{method}", + url=url, + headers={{"Content-Type": "application/json"}},{req_extra} + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return LoopioResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed {name}" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {{response.status}}" + ) + except Exception as e: + return LoopioResponse(success=False, error=str(e), message="Failed to execute {name}") +''' + + +def generate_datasource() -> str: + """Generate the full Loopio DataSource module code.""" + header = '''# ruff: noqa +""" +Loopio REST API DataSource - Auto-generated API wrapper + +Generated from Loopio REST API v1 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.loopio.loopio import LoopioClient, LoopioResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class LoopioDataSource: + """Loopio REST API DataSource + + Provides async wrapper methods for Loopio REST API operations: + - Projects management + - Entries management + - Library management + - Categories management + - Users management + - Groups management + - Tags management + + The base URL is https://api.loopio.com/v1. + + All methods return LoopioResponse objects. + """ + + def __init__(self, client: LoopioClient) -> None: + """Initialize with LoopioClient. + + Args: + client: LoopioClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'LoopioDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> LoopioClient: + """Return the underlying LoopioClient.""" + return self._client +''' + + methods = [] + current_section = None + for ep in ENDPOINTS: + section = ep.get("section", "") + if section and section != current_section: + current_section = section + methods.append(f"\n # {'-' * 71}\n # {section}\n # {'-' * 71}") + methods.append(_gen_method(ep)) + + return header + "\n".join(methods) + "\n" + + +if __name__ == "__main__": + print(generate_datasource()) diff --git a/backend/python/app/sources/external/loopio/example.py b/backend/python/app/sources/external/loopio/example.py new file mode 100644 index 000000000..474b4baf0 --- /dev/null +++ b/backend/python/app/sources/external/loopio/example.py @@ -0,0 +1,142 @@ +# ruff: noqa + +""" +Loopio API Usage Examples + +This example demonstrates how to use the Loopio DataSource to interact with +the Loopio API v1, covering: +- Authentication (API Key / Bearer Token) +- Initializing the Client and DataSource +- Listing Projects, Entries, Library Items +- Fetching Categories, Users, Groups, Tags + +Prerequisites: +1. Obtain an API key from Loopio admin settings +2. Set LOOPIO_API_KEY environment variable +""" + +import asyncio +import json +import os + +from app.sources.client.loopio.loopio import ( + LoopioClient, + LoopioTokenConfig, + LoopioResponse, +) +from app.sources.external.loopio.loopio import LoopioDataSource + +# --- Configuration --- +API_KEY = os.getenv("LOOPIO_API_KEY") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: LoopioResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + if isinstance(data, list): + print(f" Found {len(data)} items.") + if data: + print(f" Sample: {json.dumps(data[0], indent=2)[:400]}...") + elif isinstance(data, dict): + for key in ("projects", "entries", "library", "categories", + "users", "groups", "tags"): + if key in data: + items = data[key] + print(f" Found {len(items)} {key}.") + if items: + print(f" Sample: {json.dumps(items[0], indent=2)[:400]}...") + return + print(f" Data: {json.dumps(data, indent=2)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + # 1. Initialize Client + print_section("Initializing Loopio Client") + + if not API_KEY: + print(" No valid authentication method found.") + print(" Please set LOOPIO_API_KEY environment variable.") + return + + print(" Using API Key authentication") + config = LoopioTokenConfig(token=API_KEY) + client = LoopioClient.build_with_config(config) + data_source = LoopioDataSource(client) + print("Client initialized successfully.") + + try: + # 2. Get Projects + print_section("Projects") + projects_resp = await data_source.get_projects(limit=10) + print_result("Get Projects", projects_resp) + + project_id = None + if projects_resp.success and projects_resp.data: + data = projects_resp.data + items = data if isinstance(data, list) else data.get("projects", []) if isinstance(data, dict) else [] + if items: + project_id = str(items[0].get("id")) + print(f" Using Project ID: {project_id}") + + if project_id: + print_section("Project Details") + project_resp = await data_source.get_project(project_id) + print_result("Get Project", project_resp) + + # 3. Get Entries + print_section("Entries") + entries_resp = await data_source.get_entries(limit=10) + print_result("Get Entries", entries_resp) + + # 4. Get Library Items + print_section("Library Items") + library_resp = await data_source.get_library_items(limit=10) + print_result("Get Library Items", library_resp) + + # 5. Get Categories + print_section("Categories") + categories_resp = await data_source.get_categories(limit=10) + print_result("Get Categories", categories_resp) + + # 6. Get Users + print_section("Users") + users_resp = await data_source.get_users(limit=10) + print_result("Get Users", users_resp) + + # 7. Get Groups + print_section("Groups") + groups_resp = await data_source.get_groups(limit=10) + print_result("Get Groups", groups_resp) + + # 8. Get Tags + print_section("Tags") + tags_resp = await data_source.get_tags(limit=10) + print_result("Get Tags", tags_resp) + + finally: + # Cleanup: Close the HTTP client session + print("\nClosing client connection...") + inner_client = client.get_client() + if hasattr(inner_client, "close"): + await inner_client.close() + + print("\n" + "=" * 80) + print(" All Loopio API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/loopio/loopio.py b/backend/python/app/sources/external/loopio/loopio.py new file mode 100644 index 000000000..bd5db6f0b --- /dev/null +++ b/backend/python/app/sources/external/loopio/loopio.py @@ -0,0 +1,575 @@ +# ruff: noqa +""" +Loopio REST API DataSource - Auto-generated API wrapper + +Generated from Loopio REST API v1 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.loopio.loopio import LoopioClient, LoopioResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class LoopioDataSource: + """Loopio REST API DataSource + + Provides async wrapper methods for Loopio REST API operations: + - Projects management + - Entries management + - Library management + - Categories management + - Users management + - Groups management + - Tags management + + The base URL is https://api.loopio.com/v1. + + All methods return LoopioResponse objects. + """ + + def __init__(self, client: LoopioClient) -> None: + """Initialize with LoopioClient. + + Args: + client: LoopioClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'LoopioDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> LoopioClient: + """Return the underlying LoopioClient.""" + return self._client + + # ----------------------------------------------------------------------- + # Projects + # ----------------------------------------------------------------------- + + async def get_projects( + self, + *, + limit: int | None = None, + offset: int | None = None, + ) -> LoopioResponse: + """List all projects. + + Args: + limit: Maximum number of results to return + offset: Number of results to skip + + Returns: + LoopioResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/projects" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return LoopioResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_projects" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return LoopioResponse(success=False, error=str(e), message="Failed to execute get_projects") + + async def get_project( + self, + project_id: str, + ) -> LoopioResponse: + """Get a specific project by ID. + + Args: + project_id: The project ID + + Returns: + LoopioResponse with operation result + """ + url = self.base_url + "/projects/{project_id}".format(project_id=project_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return LoopioResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_project" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return LoopioResponse(success=False, error=str(e), message="Failed to execute get_project") + + # ----------------------------------------------------------------------- + # Entries + # ----------------------------------------------------------------------- + + async def get_entries( + self, + *, + limit: int | None = None, + offset: int | None = None, + ) -> LoopioResponse: + """List all entries. + + Args: + limit: Maximum number of results to return + offset: Number of results to skip + + Returns: + LoopioResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/entries" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return LoopioResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_entries" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return LoopioResponse(success=False, error=str(e), message="Failed to execute get_entries") + + async def get_entry( + self, + entry_id: str, + ) -> LoopioResponse: + """Get a specific entry by ID. + + Args: + entry_id: The entry ID + + Returns: + LoopioResponse with operation result + """ + url = self.base_url + "/entries/{entry_id}".format(entry_id=entry_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return LoopioResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_entry" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return LoopioResponse(success=False, error=str(e), message="Failed to execute get_entry") + + # ----------------------------------------------------------------------- + # Library + # ----------------------------------------------------------------------- + + async def get_library_items( + self, + *, + limit: int | None = None, + offset: int | None = None, + ) -> LoopioResponse: + """List all library items. + + Args: + limit: Maximum number of results to return + offset: Number of results to skip + + Returns: + LoopioResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/library" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return LoopioResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_library_items" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return LoopioResponse(success=False, error=str(e), message="Failed to execute get_library_items") + + async def get_library_item( + self, + library_id: str, + ) -> LoopioResponse: + """Get a specific library item by ID. + + Args: + library_id: The library item ID + + Returns: + LoopioResponse with operation result + """ + url = self.base_url + "/library/{library_id}".format(library_id=library_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return LoopioResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_library_item" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return LoopioResponse(success=False, error=str(e), message="Failed to execute get_library_item") + + # ----------------------------------------------------------------------- + # Categories + # ----------------------------------------------------------------------- + + async def get_categories( + self, + *, + limit: int | None = None, + offset: int | None = None, + ) -> LoopioResponse: + """List all categories. + + Args: + limit: Maximum number of results to return + offset: Number of results to skip + + Returns: + LoopioResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/categories" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return LoopioResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_categories" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return LoopioResponse(success=False, error=str(e), message="Failed to execute get_categories") + + async def get_category( + self, + category_id: str, + ) -> LoopioResponse: + """Get a specific category by ID. + + Args: + category_id: The category ID + + Returns: + LoopioResponse with operation result + """ + url = self.base_url + "/categories/{category_id}".format(category_id=category_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return LoopioResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_category" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return LoopioResponse(success=False, error=str(e), message="Failed to execute get_category") + + # ----------------------------------------------------------------------- + # Users + # ----------------------------------------------------------------------- + + async def get_users( + self, + *, + limit: int | None = None, + offset: int | None = None, + ) -> LoopioResponse: + """List all users. + + Args: + limit: Maximum number of results to return + offset: Number of results to skip + + Returns: + LoopioResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/users" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return LoopioResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_users" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return LoopioResponse(success=False, error=str(e), message="Failed to execute get_users") + + async def get_user( + self, + user_id: str, + ) -> LoopioResponse: + """Get a specific user by ID. + + Args: + user_id: The user ID + + Returns: + LoopioResponse with operation result + """ + url = self.base_url + "/users/{user_id}".format(user_id=user_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return LoopioResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_user" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return LoopioResponse(success=False, error=str(e), message="Failed to execute get_user") + + # ----------------------------------------------------------------------- + # Groups + # ----------------------------------------------------------------------- + + async def get_groups( + self, + *, + limit: int | None = None, + offset: int | None = None, + ) -> LoopioResponse: + """List all groups. + + Args: + limit: Maximum number of results to return + offset: Number of results to skip + + Returns: + LoopioResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/groups" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return LoopioResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_groups" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return LoopioResponse(success=False, error=str(e), message="Failed to execute get_groups") + + async def get_group( + self, + group_id: str, + ) -> LoopioResponse: + """Get a specific group by ID. + + Args: + group_id: The group ID + + Returns: + LoopioResponse with operation result + """ + url = self.base_url + "/groups/{group_id}".format(group_id=group_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return LoopioResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_group" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return LoopioResponse(success=False, error=str(e), message="Failed to execute get_group") + + # ----------------------------------------------------------------------- + # Tags + # ----------------------------------------------------------------------- + + async def get_tags( + self, + *, + limit: int | None = None, + offset: int | None = None, + ) -> LoopioResponse: + """List all tags. + + Args: + limit: Maximum number of results to return + offset: Number of results to skip + + Returns: + LoopioResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/tags" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return LoopioResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_tags" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return LoopioResponse(success=False, error=str(e), message="Failed to execute get_tags") + + async def get_tag( + self, + tag_id: str, + ) -> LoopioResponse: + """Get a specific tag by ID. + + Args: + tag_id: The tag ID + + Returns: + LoopioResponse with operation result + """ + url = self.base_url + "/tags/{tag_id}".format(tag_id=tag_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return LoopioResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_tag" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return LoopioResponse(success=False, error=str(e), message="Failed to execute get_tag") diff --git a/backend/python/app/sources/external/loopio/run_generator.py b/backend/python/app/sources/external/loopio/run_generator.py new file mode 100644 index 000000000..8a01acd6b --- /dev/null +++ b/backend/python/app/sources/external/loopio/run_generator.py @@ -0,0 +1,24 @@ +"""Runner script to generate the Loopio DataSource wrapper. + +Execute this script to regenerate loopio.py from the endpoint definitions +in code_generator.py. + +Usage: + python -m app.sources.external.loopio.run_generator +""" + +from app.sources.external.loopio.code_generator import generate_datasource + + +def main() -> None: + """Generate the Loopio DataSource file.""" + code = generate_datasource() + output_path = __file__.replace("run_generator.py", "loopio.py") + with open(output_path, "w") as f: + f.write(code) + print(f"Generated Loopio DataSource -> {output_path}") + print(f" Total characters: {len(code)}") + + +if __name__ == "__main__": + main() diff --git a/backend/python/app/sources/external/lucid/example.py b/backend/python/app/sources/external/lucid/example.py new file mode 100644 index 000000000..8195aad12 --- /dev/null +++ b/backend/python/app/sources/external/lucid/example.py @@ -0,0 +1,221 @@ +# ruff: noqa + +""" +Lucid API Usage Examples + +This example demonstrates how to use the Lucid DataSource to interact with +the Lucid API (v1), covering: +- Authentication (OAuth2, Bearer Token) +- Initializing the Client and DataSource +- Fetching User Details +- Listing Documents and Folders +- Working with Pages and Data Sources + +Prerequisites: +For OAuth2: +1. Create a Lucid OAuth app at https://developer.lucid.co/ +2. Set LUCID_CLIENT_ID and LUCID_CLIENT_SECRET environment variables +3. The OAuth flow will automatically open a browser for authorization + +For Bearer Token: +1. Generate an API token from Lucid developer settings +2. Set LUCID_API_TOKEN environment variable + +OAuth Scopes: +lucidchart.document.app:read, lucidchart.document.app:write, user.profile +""" + +import asyncio +import json +import os + +from app.sources.client.lucid.lucid import ( + LucidClient, + LucidOAuthConfig, + LucidTokenConfig, + LucidResponse, +) +from app.sources.external.lucid.lucid import LucidDataSource +from app.sources.external.utils.oauth import perform_oauth_flow + +# --- Configuration --- +# OAuth2 credentials (highest priority) +CLIENT_ID = os.getenv("LUCID_CLIENT_ID") +CLIENT_SECRET = os.getenv("LUCID_CLIENT_SECRET") + +# Bearer Token (second priority) +API_TOKEN = os.getenv("LUCID_API_TOKEN") + +# OAuth redirect URI +REDIRECT_URI = os.getenv("LUCID_REDIRECT_URI", "http://localhost:8080/callback") + +# OAuth scopes +SCOPES = [ + "lucidchart.document.app:read", + "lucidchart.document.app:write", + "user.profile", +] + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: LucidResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + # Handle list-type responses + for key in ("documents", "folders", "pages", "users", "dataSources"): + if isinstance(data, dict) and key in data: + items = data[key] + print(f" Found {len(items)} {key}.") + if items: + print(f" Sample: {json.dumps(items[0], indent=2)[:400]}...") + return + # Generic response + print(f" Data: {json.dumps(data, indent=2)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + # 1. Initialize Client + print_section("Initializing Lucid Client") + + config = None + + # Priority 1: OAuth2 + if CLIENT_ID and CLIENT_SECRET: + print(" Using OAuth2 authentication") + try: + print("Starting OAuth flow...") + token_response = perform_oauth_flow( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + auth_endpoint="https://lucid.app/oauth2/authorize", + token_endpoint="https://api.lucid.co/oauth2/token", + redirect_uri=REDIRECT_URI, + scopes=SCOPES, + scope_delimiter=" ", + auth_method="body", + ) + + access_token = token_response.get("access_token") + if not access_token: + raise Exception("No access_token found in OAuth response") + + config = LucidOAuthConfig( + access_token=access_token, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + ) + print(" OAuth authentication successful") + except Exception as e: + print(f" OAuth flow failed: {e}") + print(" Falling back to other authentication methods...") + + # Priority 2: Bearer Token + if config is None and API_TOKEN: + print(" Using Bearer Token authentication") + config = LucidTokenConfig(token=API_TOKEN) + + if config is None: + print(" No valid authentication method found.") + print(" Please set one of the following:") + print(" - LUCID_CLIENT_ID and LUCID_CLIENT_SECRET (for OAuth2)") + print(" - LUCID_API_TOKEN (for Bearer Token)") + return + + client = LucidClient.build_with_config(config) + data_source = LucidDataSource(client) + print("Client initialized successfully.") + + try: + # 2. Get Current User + print_section("Current User") + user_resp = await data_source.get_current_user() + print_result("Get Current User", user_resp) + + # 3. List Documents + print_section("Documents") + docs_resp = await data_source.list_documents(pageSize=10) + print_result("List Documents", docs_resp) + + # Extract first document ID for further exploration + document_id = None + if docs_resp.success and docs_resp.data: + documents = docs_resp.data.get("documents", []) + if not documents and isinstance(docs_resp.data, list): + documents = docs_resp.data + if documents: + document_id = str(documents[0].get("id") or documents[0].get("documentId", "")) + print(f" Using Document ID: {document_id}") + + if document_id: + # 4. Get Specific Document + print_section("Document Details") + doc_resp = await data_source.get_document(documentId=document_id) + print_result("Get Document", doc_resp) + + # 5. List Pages in Document + print_section("Document Pages") + pages_resp = await data_source.list_pages(documentId=document_id) + print_result("List Pages", pages_resp) + + # 6. List Folders + print_section("Folders") + folders_resp = await data_source.list_folders(pageSize=10) + print_result("List Folders", folders_resp) + + # Extract first folder ID + folder_id = None + if folders_resp.success and folders_resp.data: + folders = folders_resp.data.get("folders", []) + if not folders and isinstance(folders_resp.data, list): + folders = folders_resp.data + if folders: + folder_id = str(folders[0].get("id") or folders[0].get("folderId", "")) + print(f" Using Folder ID: {folder_id}") + + if folder_id: + # 7. Get Specific Folder + print_section("Folder Details") + folder_resp = await data_source.get_folder(folderId=folder_id) + print_result("Get Folder", folder_resp) + + # 8. List Folder Documents + print_section("Folder Documents") + folder_docs_resp = await data_source.list_folder_documents(folderId=folder_id) + print_result("List Folder Documents", folder_docs_resp) + + # 9. List Users + print_section("Users") + users_resp = await data_source.list_users(pageSize=10) + print_result("List Users", users_resp) + + # 10. List Data Sources + print_section("Data Sources") + ds_resp = await data_source.list_data_sources() + print_result("List Data Sources", ds_resp) + + finally: + # Cleanup: Close the HTTP client session + print("\nClosing client connection...") + inner_client = client.get_client() + if hasattr(inner_client, "close"): + await inner_client.close() + + print("\n" + "=" * 80) + print(" All Lucid API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/lucid/lucid.py b/backend/python/app/sources/external/lucid/lucid.py new file mode 100644 index 000000000..dd0d86aa0 --- /dev/null +++ b/backend/python/app/sources/external/lucid/lucid.py @@ -0,0 +1,499 @@ +""" +Lucid REST API DataSource - Auto-generated API wrapper + +Generated from Lucid REST API v1 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.http.http_request import HTTPRequest +from app.sources.client.lucid.lucid import LucidClient, LucidResponse + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class LucidDataSource: + """Lucid REST API DataSource + + Provides async wrapper methods for Lucid REST API operations: + - User profile management + - Document CRUD operations + - Folder management + - Page listing + - Data source operations + + The base URL is https://api.lucid.co/v1. + + All methods return LucidResponse objects. + """ + + def __init__(self, client: LucidClient) -> None: + """Initialize with LucidClient. + + Args: + client: LucidClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'LucidDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> LucidClient: + """Return the underlying LucidClient.""" + return self._client + + async def get_current_user( + self + ) -> LucidResponse: + """Get the current authenticated user details (API v1) + + Returns: + LucidResponse with operation result + """ + url = self.base_url + "/users/me" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return LucidResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_current_user" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return LucidResponse(success=False, error=str(e), message="Failed to execute get_current_user") + + async def list_users( + self, + pageSize: int | None = None, + cursor: str | None = None + ) -> LucidResponse: + """List users in the account (API v1) + + Args: + pageSize: Number of results per page + cursor: Cursor for pagination + + Returns: + LucidResponse with operation result + """ + query_params: dict[str, Any] = {} + if pageSize is not None: + query_params['pageSize'] = str(pageSize) + if cursor is not None: + query_params['cursor'] = cursor + + url = self.base_url + "/users" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return LucidResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_users" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return LucidResponse(success=False, error=str(e), message="Failed to execute list_users") + + async def list_documents( + self, + pageSize: int | None = None, + cursor: str | None = None, + product: str | None = None + ) -> LucidResponse: + """List all documents accessible to the authenticated user (API v1) + + Args: + pageSize: Number of results per page + cursor: Cursor for pagination + product: Filter by product (e.g., lucidchart, lucidspark) + + Returns: + LucidResponse with operation result + """ + query_params: dict[str, Any] = {} + if pageSize is not None: + query_params['pageSize'] = str(pageSize) + if cursor is not None: + query_params['cursor'] = cursor + if product is not None: + query_params['product'] = product + + url = self.base_url + "/documents" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return LucidResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_documents" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return LucidResponse(success=False, error=str(e), message="Failed to execute list_documents") + + async def get_document( + self, + documentId: str + ) -> LucidResponse: + """Get a specific document by ID (API v1) + + Args: + documentId: The document ID + + Returns: + LucidResponse with operation result + """ + url = self.base_url + "/documents/{documentId}".format(documentId=documentId) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return LucidResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_document" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return LucidResponse(success=False, error=str(e), message="Failed to execute get_document") + + async def create_document( + self, + title: str | None = None, + product: str | None = None, + folderId: str | None = None + ) -> LucidResponse: + """Create a new document (API v1) + + Args: + title: The title of the document + product: The product type (e.g., lucidchart, lucidspark) + folderId: The folder ID to create the document in + + Returns: + LucidResponse with operation result + """ + url = self.base_url + "/documents" + + body: dict[str, Any] = {} + if title is not None: + body['title'] = title + if product is not None: + body['product'] = product + if folderId is not None: + body['folderId'] = folderId + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return LucidResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed create_document" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return LucidResponse(success=False, error=str(e), message="Failed to execute create_document") + + async def delete_document( + self, + documentId: str + ) -> LucidResponse: + """Delete a document by ID (API v1) + + Args: + documentId: The document ID to delete + + Returns: + LucidResponse with operation result + """ + url = self.base_url + "/documents/{documentId}".format(documentId=documentId) + + try: + request = HTTPRequest( + method="DELETE", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return LucidResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed delete_document" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return LucidResponse(success=False, error=str(e), message="Failed to execute delete_document") + + async def list_folders( + self, + pageSize: int | None = None, + cursor: str | None = None + ) -> LucidResponse: + """List all folders accessible to the authenticated user (API v1) + + Args: + pageSize: Number of results per page + cursor: Cursor for pagination + + Returns: + LucidResponse with operation result + """ + query_params: dict[str, Any] = {} + if pageSize is not None: + query_params['pageSize'] = str(pageSize) + if cursor is not None: + query_params['cursor'] = cursor + + url = self.base_url + "/folders" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return LucidResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_folders" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return LucidResponse(success=False, error=str(e), message="Failed to execute list_folders") + + async def get_folder( + self, + folderId: str + ) -> LucidResponse: + """Get a specific folder by ID (API v1) + + Args: + folderId: The folder ID + + Returns: + LucidResponse with operation result + """ + url = self.base_url + "/folders/{folderId}".format(folderId=folderId) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return LucidResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_folder" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return LucidResponse(success=False, error=str(e), message="Failed to execute get_folder") + + async def list_folder_documents( + self, + folderId: str, + pageSize: int | None = None, + cursor: str | None = None + ) -> LucidResponse: + """List documents in a specific folder (API v1) + + Args: + folderId: The folder ID + pageSize: Number of results per page + cursor: Cursor for pagination + + Returns: + LucidResponse with operation result + """ + query_params: dict[str, Any] = {} + if pageSize is not None: + query_params['pageSize'] = str(pageSize) + if cursor is not None: + query_params['cursor'] = cursor + + url = self.base_url + "/folders/{folderId}/documents".format(folderId=folderId) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return LucidResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_folder_documents" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return LucidResponse(success=False, error=str(e), message="Failed to execute list_folder_documents") + + async def create_folder( + self, + name: str, + parentFolderId: str | None = None + ) -> LucidResponse: + """Create a new folder (API v1) + + Args: + name: The name of the folder + parentFolderId: The parent folder ID + + Returns: + LucidResponse with operation result + """ + url = self.base_url + "/folders" + + body: dict[str, Any] = {} + body['name'] = name + if parentFolderId is not None: + body['parentFolderId'] = parentFolderId + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return LucidResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed create_folder" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return LucidResponse(success=False, error=str(e), message="Failed to execute create_folder") + + async def list_pages( + self, + documentId: str + ) -> LucidResponse: + """List all pages in a document (API v1) + + Args: + documentId: The document ID + + Returns: + LucidResponse with operation result + """ + url = self.base_url + "/pages/{documentId}".format(documentId=documentId) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return LucidResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_pages" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return LucidResponse(success=False, error=str(e), message="Failed to execute list_pages") + + async def list_data_sources( + self + ) -> LucidResponse: + """List all data sources (API v1) + + Returns: + LucidResponse with operation result + """ + url = self.base_url + "/data-sources" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return LucidResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_data_sources" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return LucidResponse(success=False, error=str(e), message="Failed to execute list_data_sources") + + async def get_data_source_by_id( + self, + dataSourceId: str + ) -> LucidResponse: + """Get a specific data source by ID (API v1) + + Args: + dataSourceId: The data source ID + + Returns: + LucidResponse with operation result + """ + url = self.base_url + "/data-sources/{dataSourceId}".format(dataSourceId=dataSourceId) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return LucidResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_data_source_by_id" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return LucidResponse(success=False, error=str(e), message="Failed to execute get_data_source_by_id") diff --git a/backend/python/app/sources/external/lumapps/code_generator.py b/backend/python/app/sources/external/lumapps/code_generator.py new file mode 100644 index 000000000..100ff2aaa --- /dev/null +++ b/backend/python/app/sources/external/lumapps/code_generator.py @@ -0,0 +1,254 @@ +# ruff: noqa +""" +LumApps DataSource Code Generator + +Defines LumApps SDK method specifications and generates the DataSource +wrapper class (lumapps.py) from them. + +Methods wrap the official lumapps-sdk Python package. +The SDK uses a generic ``get_call`` / ``iter_call`` pattern. +""" + +from __future__ import annotations + +# Each spec: +# name: method name +# section: section heading +# doc: docstring line +# sdk_call: Python expression using `self._sdk` (the BaseClient instance) +# params: list of (param_name, param_type, default_or_None, doc_line) +METHODS = [ + # ---- Users ---- + { + "name": "list_users", + "section": "Users", + "doc": "List all users.", + "sdk_call": 'self._sdk.get_call("user/list")', + "params": [], + }, + { + "name": "get_user", + "section": "Users", + "doc": "Get a specific user by email.", + "sdk_call": 'self._sdk.get_call("user/get", email=email)', + "params": [("email", "str", None, "The user email address")], + }, + { + "name": "get_user_by_id", + "section": "Users", + "doc": "Get a specific user by ID.", + "sdk_call": 'self._sdk.get_call("user/get", uid=user_id)', + "params": [("user_id", "str", None, "The user ID")], + }, + # ---- Communities ---- + { + "name": "list_communities", + "section": "Communities", + "doc": "List all communities.", + "sdk_call": 'self._sdk.get_call("community/list")', + "params": [], + }, + { + "name": "get_community", + "section": "Communities", + "doc": "Get a specific community by ID.", + "sdk_call": 'self._sdk.get_call("community/get", uid=community_id)', + "params": [("community_id", "str", None, "The community ID")], + }, + # ---- Content ---- + { + "name": "list_content", + "section": "Content", + "doc": "List all content items.", + "sdk_call": 'self._sdk.get_call("content/list")', + "params": [], + }, + { + "name": "get_content", + "section": "Content", + "doc": "Get a specific content item by ID.", + "sdk_call": 'self._sdk.get_call("content/get", uid=content_id)', + "params": [("content_id", "str", None, "The content ID")], + }, + # ---- Feeds ---- + { + "name": "list_feeds", + "section": "Feeds", + "doc": "List all feeds.", + "sdk_call": 'self._sdk.get_call("feed/list")', + "params": [], + }, + { + "name": "get_feed", + "section": "Feeds", + "doc": "Get a specific feed by ID.", + "sdk_call": 'self._sdk.get_call("feed/get", uid=feed_id)', + "params": [("feed_id", "str", None, "The feed ID")], + }, + # ---- Search ---- + { + "name": "search", + "section": "Search", + "doc": "Search across LumApps content.", + "sdk_call": 'self._sdk.get_call("search", body=body)', + "params": [ + ("query", "str", None, "Search query string"), + ("content_types", "list[str] | None", "None", "Content type filters"), + ("limit", "int | None", "None", "Maximum number of results"), + ], + "build_body": True, + }, + # ---- Directories ---- + { + "name": "list_directories", + "section": "Directories", + "doc": "List all directories.", + "sdk_call": 'self._sdk.get_call("directory/list")', + "params": [], + }, + { + "name": "get_directory", + "section": "Directories", + "doc": "Get a specific directory by ID.", + "sdk_call": 'self._sdk.get_call("directory/get", uid=directory_id)', + "params": [("directory_id", "str", None, "The directory ID")], + }, + # ---- Spaces ---- + { + "name": "list_spaces", + "section": "Spaces", + "doc": "List all spaces.", + "sdk_call": 'self._sdk.get_call("space/list")', + "params": [], + }, +] + + +def _gen_method(spec: dict) -> str: + """Generate a single method from a spec.""" + name = spec["name"] + doc = spec["doc"] + sdk_call = spec["sdk_call"] + params = spec.get("params", []) + build_body = spec.get("build_body", False) + + # Build signature + sig_parts = ["self"] + has_kw_only = False + for p_name, p_type, p_default, _ in params: + if p_default is not None and not has_kw_only: + sig_parts.append("*") + has_kw_only = True + if p_default is None: + sig_parts.append(f"{p_name}: {p_type}") + else: + sig_parts.append(f"{p_name}: {p_type} = {p_default}") + + sig = ",\n ".join(sig_parts) + + # Build docstring args section + doc_args = "" + if params: + doc_args = "\n\n Args:\n" + for p_name, _, _, p_doc in params: + doc_args += f" {p_name}: {p_doc}\n" + + # Build body dict if needed + body_block = "" + if build_body: + lines = [' body: dict[str, object] = {"query": query}'] + for p_name, _, p_default, _ in params: + if p_default is not None and p_name != "query": + lines.append(f" if {p_name} is not None:") + # Map Python snake_case to camelCase API keys + api_key = p_name + if p_name == "content_types": + api_key = "contentTypes" + lines.append(f' body["{api_key}"] = {p_name}') + body_block = "\n".join(lines) + "\n" + + return f''' + def {name}( + {sig}, + ) -> LumAppsResponse: + """{doc}{doc_args} + Returns: + LumAppsResponse with operation result + """ + try: +{body_block} result = {sdk_call} + return LumAppsResponse(success=True, data=result) + except Exception as e: + return LumAppsResponse( + success=False, error=str(e), message="Failed to execute {name}" + ) +''' + + +def generate_datasource() -> str: + """Generate the full LumApps DataSource module code.""" + header = '''# ruff: noqa +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownParameterType=false +""" +LumApps SDK DataSource - Auto-generated SDK wrapper + +Generated from LumApps SDK method specifications. +Wraps the official lumapps-sdk Python package. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Union, cast + +from lumapps.api import BaseClient + +from app.sources.client.lumapps.lumapps import LumAppsClient, LumAppsResponse + + +class LumAppsDataSource: + """LumApps SDK DataSource + + Provides typed wrapper methods for LumApps SDK operations: + - Users management + - Communities management + - Content management + - Feeds management + - Search + - Directories management + - Spaces management + + All methods return LumAppsResponse objects. + """ + + def __init__(self, client_or_sdk: Union[LumAppsClient, BaseClient, object]) -> None: + """Initialize with LumAppsClient, raw SDK, or any wrapper with ``get_sdk()``. + + Args: + client_or_sdk: LumAppsClient, BaseClient instance, or wrapper + """ + if isinstance(client_or_sdk, BaseClient): + self._sdk: BaseClient = client_or_sdk + elif hasattr(client_or_sdk, "get_sdk"): + sdk_obj = getattr(client_or_sdk, "get_sdk")() + self._sdk = cast(BaseClient, sdk_obj) + else: + self._sdk = cast(BaseClient, client_or_sdk) +''' + + methods = [] + current_section = None + for spec in METHODS: + section = spec.get("section", "") + if section and section != current_section: + current_section = section + methods.append( + f"\n # {'-' * 71}\n # {section}\n # {'-' * 71}" + ) + methods.append(_gen_method(spec)) + + return header + "\n".join(methods) + "\n" + + +if __name__ == "__main__": + print(generate_datasource()) diff --git a/backend/python/app/sources/external/lumapps/example.py b/backend/python/app/sources/external/lumapps/example.py new file mode 100644 index 000000000..03f74fccf --- /dev/null +++ b/backend/python/app/sources/external/lumapps/example.py @@ -0,0 +1,138 @@ +# ruff: noqa + +""" +LumApps API Usage Examples + +This example demonstrates how to use the LumApps DataSource to interact with +the LumApps API via the official lumapps-sdk, covering: +- Authentication (Access Token, Service Account) +- Initializing the Client and DataSource +- Listing Users, Communities, Content, Feeds +- Searching content +- Getting directories and spaces + +Prerequisites: +For Access Token: +1. Get your LumApps API token +2. Set LUMAPPS_TOKEN environment variable + +For Service Account: +1. Register a service account with LumApps +2. Set LUMAPPS_CLIENT_ID and LUMAPPS_CLIENT_SECRET environment variables +""" + +import json +import os + +from app.sources.client.lumapps.lumapps import ( + LumAppsClient, + LumAppsOAuthConfig, + LumAppsResponse, + LumAppsTokenConfig, +) +from app.sources.external.lumapps.lumapps import LumAppsDataSource + +# --- Configuration --- +TOKEN = os.getenv("LUMAPPS_TOKEN") +CLIENT_ID = os.getenv("LUMAPPS_CLIENT_ID") +CLIENT_SECRET = os.getenv("LUMAPPS_CLIENT_SECRET") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: LumAppsResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + if isinstance(data, list): + print(f" Found {len(data)} items.") + if data: + print(f" Sample: {json.dumps(data[0], indent=2, default=str)[:400]}...") + elif isinstance(data, dict): + print(f" Data: {json.dumps(data, indent=2, default=str)[:500]}...") + else: + print(f" Data: {str(data)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +def main() -> None: + # 1. Initialize Client + print_section("Initializing LumApps Client") + + config = None + + # Priority 1: Service Account + if CLIENT_ID and CLIENT_SECRET: + print(" Using Service Account authentication") + config = LumAppsOAuthConfig( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + ) + + # Priority 2: Access Token + if config is None and TOKEN: + print(" Using Access Token authentication") + config = LumAppsTokenConfig(token=TOKEN) + + if config is None: + print(" No valid authentication method found.") + print(" Please set one of the following:") + print(" - LUMAPPS_CLIENT_ID and LUMAPPS_CLIENT_SECRET (for Service Account)") + print(" - LUMAPPS_TOKEN (for Access Token)") + return + + client = LumAppsClient.build_with_config(config) + data_source = LumAppsDataSource(client) + print(" Client initialized successfully.") + + # 2. List Users + print_section("Users") + users_resp = data_source.list_users() + print_result("List Users", users_resp) + + # 3. List Communities + print_section("Communities") + communities_resp = data_source.list_communities() + print_result("List Communities", communities_resp) + + # 4. List Content + print_section("Content") + content_resp = data_source.list_content() + print_result("List Content", content_resp) + + # 5. List Feeds + print_section("Feeds") + feeds_resp = data_source.list_feeds() + print_result("List Feeds", feeds_resp) + + # 6. Search + print_section("Search") + search_resp = data_source.search(query="getting started") + print_result("Search", search_resp) + + # 7. List Directories + print_section("Directories") + dirs_resp = data_source.list_directories() + print_result("List Directories", dirs_resp) + + # 8. List Spaces + print_section("Spaces") + spaces_resp = data_source.list_spaces() + print_result("List Spaces", spaces_resp) + + print("\n" + "=" * 80) + print(" All LumApps API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + main() diff --git a/backend/python/app/sources/external/lumapps/lumapps.py b/backend/python/app/sources/external/lumapps/lumapps.py new file mode 100644 index 000000000..46b89eee3 --- /dev/null +++ b/backend/python/app/sources/external/lumapps/lumapps.py @@ -0,0 +1,327 @@ +# ruff: noqa +""" +LumApps SDK DataSource - Auto-generated SDK wrapper + +Generated from LumApps SDK method specifications. +Wraps the official lumapps-sdk Python package. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any, Union, cast + +from lumapps.api import BaseClient # type: ignore[reportMissingImports] + +from app.sources.client.lumapps.lumapps import LumAppsClient, LumAppsResponse + + +class LumAppsDataSource: + """LumApps SDK DataSource + + Provides typed wrapper methods for LumApps SDK operations: + - Users management + - Communities management + - Content management + - Feeds management + - Search + - Directories management + - Spaces management + + All methods return LumAppsResponse objects. + """ + + def __init__(self, client_or_sdk: Union[LumAppsClient, BaseClient, object]) -> None: # type: ignore[reportUnknownParameterType] + """Initialize with LumAppsClient, raw SDK, or any wrapper with ``get_sdk()``. + + Args: + client_or_sdk: LumAppsClient, BaseClient instance, or wrapper + """ + super().__init__() + if isinstance(client_or_sdk, BaseClient): # type: ignore[reportUnknownMemberType] + self._sdk: BaseClient = client_or_sdk # type: ignore[reportUnknownMemberType] + elif hasattr(client_or_sdk, "get_sdk"): # type: ignore[reportUnknownArgumentType] + sdk_obj = getattr(client_or_sdk, "get_sdk")() # type: ignore[reportUnknownArgumentType] + self._sdk = cast(BaseClient, sdk_obj) + else: + self._sdk = cast(BaseClient, client_or_sdk) + + # ----------------------------------------------------------------------- + # Users + # ----------------------------------------------------------------------- + + def list_users( + self, + ) -> LumAppsResponse: + """List all users. + Returns: + LumAppsResponse with operation result + """ + try: + result: Any = self._sdk.get_call("user/list") # type: ignore[reportUnknownMemberType] + return LumAppsResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return LumAppsResponse( + success=False, error=str(e), message="Failed to execute list_users" + ) + + + def get_user( + self, + email: str, + ) -> LumAppsResponse: + """Get a specific user by email. + + Args: + email: The user email address + + Returns: + LumAppsResponse with operation result + """ + try: + result: Any = self._sdk.get_call("user/get", email=email) # type: ignore[reportUnknownMemberType] + return LumAppsResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return LumAppsResponse( + success=False, error=str(e), message="Failed to execute get_user" + ) + + + def get_user_by_id( + self, + user_id: str, + ) -> LumAppsResponse: + """Get a specific user by ID. + + Args: + user_id: The user ID + + Returns: + LumAppsResponse with operation result + """ + try: + result: Any = self._sdk.get_call("user/get", uid=user_id) # type: ignore[reportUnknownMemberType] + return LumAppsResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return LumAppsResponse( + success=False, error=str(e), message="Failed to execute get_user_by_id" + ) + + + # ----------------------------------------------------------------------- + # Communities + # ----------------------------------------------------------------------- + + def list_communities( + self, + ) -> LumAppsResponse: + """List all communities. + Returns: + LumAppsResponse with operation result + """ + try: + result: Any = self._sdk.get_call("community/list") # type: ignore[reportUnknownMemberType] + return LumAppsResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return LumAppsResponse( + success=False, error=str(e), message="Failed to execute list_communities" + ) + + + def get_community( + self, + community_id: str, + ) -> LumAppsResponse: + """Get a specific community by ID. + + Args: + community_id: The community ID + + Returns: + LumAppsResponse with operation result + """ + try: + result: Any = self._sdk.get_call("community/get", uid=community_id) # type: ignore[reportUnknownMemberType] + return LumAppsResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return LumAppsResponse( + success=False, error=str(e), message="Failed to execute get_community" + ) + + + # ----------------------------------------------------------------------- + # Content + # ----------------------------------------------------------------------- + + def list_content( + self, + ) -> LumAppsResponse: + """List all content items. + Returns: + LumAppsResponse with operation result + """ + try: + result: Any = self._sdk.get_call("content/list") # type: ignore[reportUnknownMemberType] + return LumAppsResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return LumAppsResponse( + success=False, error=str(e), message="Failed to execute list_content" + ) + + + def get_content( + self, + content_id: str, + ) -> LumAppsResponse: + """Get a specific content item by ID. + + Args: + content_id: The content ID + + Returns: + LumAppsResponse with operation result + """ + try: + result: Any = self._sdk.get_call("content/get", uid=content_id) # type: ignore[reportUnknownMemberType] + return LumAppsResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return LumAppsResponse( + success=False, error=str(e), message="Failed to execute get_content" + ) + + + # ----------------------------------------------------------------------- + # Feeds + # ----------------------------------------------------------------------- + + def list_feeds( + self, + ) -> LumAppsResponse: + """List all feeds. + Returns: + LumAppsResponse with operation result + """ + try: + result: Any = self._sdk.get_call("feed/list") # type: ignore[reportUnknownMemberType] + return LumAppsResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return LumAppsResponse( + success=False, error=str(e), message="Failed to execute list_feeds" + ) + + + def get_feed( + self, + feed_id: str, + ) -> LumAppsResponse: + """Get a specific feed by ID. + + Args: + feed_id: The feed ID + + Returns: + LumAppsResponse with operation result + """ + try: + result: Any = self._sdk.get_call("feed/get", uid=feed_id) # type: ignore[reportUnknownMemberType] + return LumAppsResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return LumAppsResponse( + success=False, error=str(e), message="Failed to execute get_feed" + ) + + + # ----------------------------------------------------------------------- + # Search + # ----------------------------------------------------------------------- + + def search( + self, + query: str, + *, + content_types: list[str] | None = None, + limit: int | None = None, + ) -> LumAppsResponse: + """Search across LumApps content. + + Args: + query: Search query string + content_types: Content type filters + limit: Maximum number of results + + Returns: + LumAppsResponse with operation result + """ + try: + body: dict[str, object] = {"query": query} + if content_types is not None: + body["contentTypes"] = content_types + if limit is not None: + body["limit"] = limit + result: Any = self._sdk.get_call("search", body=body) # type: ignore[reportUnknownMemberType] + return LumAppsResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return LumAppsResponse( + success=False, error=str(e), message="Failed to execute search" + ) + + + # ----------------------------------------------------------------------- + # Directories + # ----------------------------------------------------------------------- + + def list_directories( + self, + ) -> LumAppsResponse: + """List all directories. + Returns: + LumAppsResponse with operation result + """ + try: + result: Any = self._sdk.get_call("directory/list") # type: ignore[reportUnknownMemberType] + return LumAppsResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return LumAppsResponse( + success=False, error=str(e), message="Failed to execute list_directories" + ) + + + def get_directory( + self, + directory_id: str, + ) -> LumAppsResponse: + """Get a specific directory by ID. + + Args: + directory_id: The directory ID + + Returns: + LumAppsResponse with operation result + """ + try: + result: Any = self._sdk.get_call("directory/get", uid=directory_id) # type: ignore[reportUnknownMemberType] + return LumAppsResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return LumAppsResponse( + success=False, error=str(e), message="Failed to execute get_directory" + ) + + + # ----------------------------------------------------------------------- + # Spaces + # ----------------------------------------------------------------------- + + def list_spaces( + self, + ) -> LumAppsResponse: + """List all spaces. + Returns: + LumAppsResponse with operation result + """ + try: + result: Any = self._sdk.get_call("space/list") # type: ignore[reportUnknownMemberType] + return LumAppsResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + except Exception as e: + return LumAppsResponse( + success=False, error=str(e), message="Failed to execute list_spaces" + ) diff --git a/backend/python/app/sources/external/lumapps/run_generator.py b/backend/python/app/sources/external/lumapps/run_generator.py new file mode 100644 index 000000000..11b69103f --- /dev/null +++ b/backend/python/app/sources/external/lumapps/run_generator.py @@ -0,0 +1,25 @@ +# ruff: noqa: T201 +"""Runner script to generate the LumApps DataSource wrapper. + +Execute this script to regenerate lumapps.py from the method definitions +in code_generator.py. + +Usage: + python -m app.sources.external.lumapps.run_generator +""" + +from app.sources.external.lumapps.code_generator import generate_datasource + + +def main() -> None: + """Generate the LumApps DataSource file.""" + code = generate_datasource() + output_path = __file__.replace("run_generator.py", "lumapps.py") + with open(output_path, "w") as f: + f.write(code) + print(f"Generated LumApps DataSource -> {output_path}") + print(f" Total characters: {len(code)}") + + +if __name__ == "__main__": + main() diff --git a/backend/python/app/sources/external/marketo/marketo.py b/backend/python/app/sources/external/marketo/marketo.py new file mode 100644 index 000000000..c4c862c00 --- /dev/null +++ b/backend/python/app/sources/external/marketo/marketo.py @@ -0,0 +1,948 @@ +""" +Marketo REST API DataSource - Auto-generated API wrapper + +Generated from Marketo REST API documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.http.http_request import HTTPRequest +from app.sources.client.marketo.marketo import MarketoClient, MarketoResponse + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class MarketoDataSource: + """Marketo REST API DataSource + + Provides async wrapper methods for Marketo REST API operations: + - Lead management (describe, query, create/update) + - Activity types and activities + - Campaigns + - Static lists and list membership + - Programs + - Custom objects + - Folders + - Tokens + + The base URL is determined by the MarketoClient's configured + munchkin_id. All methods return MarketoResponse objects. + + Important: The client must call ensure_authenticated() before making + API requests. This is handled automatically in the _execute helper. + """ + + def __init__(self, client: MarketoClient) -> None: + """Initialize with MarketoClient. + + Args: + client: MarketoClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip("/") + except AttributeError as exc: + raise ValueError( + "HTTP client does not have get_base_url method" + ) from exc + + def get_data_source(self) -> "MarketoDataSource": + """Return the data source instance.""" + return self + + def get_client(self) -> MarketoClient: + """Return the underlying MarketoClient.""" + return self._client + + # ------------------------------------------------------------------ + # Leads + # ------------------------------------------------------------------ + + async def describe_leads(self) -> MarketoResponse: + """Describe the lead object schema + + HTTP GET /v1/lead/describe.json + + Returns: + MarketoResponse with lead field metadata + """ + url = self.base_url + "/v1/lead/describe.json" + + try: + await self.http.ensure_authenticated() + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MarketoResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed describe_leads" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return MarketoResponse( + success=False, + error=str(e), + message="Failed to execute describe_leads", + ) + + async def get_leads( + self, + filter_type: str, + filter_values: str, + *, + fields: str | None = None, + batch_size: int | None = None, + next_page_token: str | None = None, + ) -> MarketoResponse: + """Get leads by filter type and values + + HTTP GET /v1/leads.json + + Args: + filter_type: The lead field to filter on (e.g. "email", "id") + filter_values: Comma-separated list of filter values + fields: Comma-separated list of field names to return + batch_size: Maximum number of records to return (max 300) + next_page_token: Paging token from a previous response + + Returns: + MarketoResponse with matching leads + """ + query_params: dict[str, Any] = { + "filterType": filter_type, + "filterValues": filter_values, + } + if fields is not None: + query_params["fields"] = fields + if batch_size is not None: + query_params["batchSize"] = str(batch_size) + if next_page_token is not None: + query_params["nextPageToken"] = next_page_token + + url = self.base_url + "/v1/leads.json" + + try: + await self.http.ensure_authenticated() + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MarketoResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_leads" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return MarketoResponse( + success=False, + error=str(e), + message="Failed to execute get_leads", + ) + + async def get_lead_by_id( + self, + lead_id: int | str, + *, + fields: str | None = None, + ) -> MarketoResponse: + """Get a single lead by ID + + HTTP GET /v1/lead/{id}.json + + Args: + lead_id: The lead ID + fields: Comma-separated list of field names to return + + Returns: + MarketoResponse with lead data + """ + query_params: dict[str, Any] = {} + if fields is not None: + query_params["fields"] = fields + + url = self.base_url + "/v1/lead/{lead_id}.json".format( + lead_id=lead_id + ) + + try: + await self.http.ensure_authenticated() + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MarketoResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_lead_by_id" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return MarketoResponse( + success=False, + error=str(e), + message="Failed to execute get_lead_by_id", + ) + + async def create_or_update_leads( + self, + input_data: list[dict[str, Any]], + *, + action: str | None = None, + lookup_field: str | None = None, + partition_name: str | None = None, + async_processing: bool | None = None, + ) -> MarketoResponse: + """Create or update leads + + HTTP POST /v1/leads.json + + Args: + input_data: List of lead records to create or update + action: Action to perform: createOnly, updateOnly, + createOrUpdate (default), createDuplicate + lookup_field: Field to use for deduplication + partition_name: Lead partition name + async_processing: Process asynchronously + + Returns: + MarketoResponse with operation result + """ + url = self.base_url + "/v1/leads.json" + + body: dict[str, Any] = {"input": input_data} + if action is not None: + body["action"] = action + if lookup_field is not None: + body["lookupField"] = lookup_field + if partition_name is not None: + body["partitionName"] = partition_name + if async_processing is not None: + body["asyncProcessing"] = async_processing + + try: + await self.http.ensure_authenticated() + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MarketoResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed create_or_update_leads" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return MarketoResponse( + success=False, + error=str(e), + message="Failed to execute create_or_update_leads", + ) + + # ------------------------------------------------------------------ + # Activities + # ------------------------------------------------------------------ + + async def get_activity_types(self) -> MarketoResponse: + """Get all activity types + + HTTP GET /v1/activities/types.json + + Returns: + MarketoResponse with activity type definitions + """ + url = self.base_url + "/v1/activities/types.json" + + try: + await self.http.ensure_authenticated() + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MarketoResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_activity_types" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return MarketoResponse( + success=False, + error=str(e), + message="Failed to execute get_activity_types", + ) + + async def get_activities( + self, + activity_type_ids: str, + next_page_token: str, + *, + since_datetime: str | None = None, + batch_size: int | None = None, + list_id: int | None = None, + lead_ids: str | None = None, + ) -> MarketoResponse: + """Get lead activities + + HTTP GET /v1/activities.json + + Args: + activity_type_ids: Comma-separated activity type IDs + next_page_token: Paging token (use get_paging_token to obtain) + since_datetime: Earliest datetime for activities (ISO 8601) + batch_size: Maximum number of records to return (max 300) + list_id: Filter by static list ID + lead_ids: Comma-separated lead IDs to filter + + Returns: + MarketoResponse with activities + """ + query_params: dict[str, Any] = { + "activityTypeIds": activity_type_ids, + "nextPageToken": next_page_token, + } + if since_datetime is not None: + query_params["sinceDatetime"] = since_datetime + if batch_size is not None: + query_params["batchSize"] = str(batch_size) + if list_id is not None: + query_params["listId"] = str(list_id) + if lead_ids is not None: + query_params["leadIds"] = lead_ids + + url = self.base_url + "/v1/activities.json" + + try: + await self.http.ensure_authenticated() + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MarketoResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_activities" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return MarketoResponse( + success=False, + error=str(e), + message="Failed to execute get_activities", + ) + + async def get_paging_token( + self, + since_datetime: str, + ) -> MarketoResponse: + """Get a paging token for use with activity APIs + + HTTP GET /v1/activities/pagingtoken.json + + Args: + since_datetime: Earliest datetime (ISO 8601) + + Returns: + MarketoResponse with nextPageToken + """ + query_params: dict[str, Any] = { + "sinceDatetime": since_datetime, + } + + url = self.base_url + "/v1/activities/pagingtoken.json" + + try: + await self.http.ensure_authenticated() + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MarketoResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_paging_token" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return MarketoResponse( + success=False, + error=str(e), + message="Failed to execute get_paging_token", + ) + + # ------------------------------------------------------------------ + # Campaigns + # ------------------------------------------------------------------ + + async def get_campaigns( + self, + *, + batch_size: int | None = None, + next_page_token: str | None = None, + is_triggerable: bool | None = None, + ) -> MarketoResponse: + """Get all campaigns + + HTTP GET /v1/campaigns.json + + Args: + batch_size: Maximum number of records to return + next_page_token: Paging token from a previous response + is_triggerable: Filter to only triggerable campaigns + + Returns: + MarketoResponse with campaigns + """ + query_params: dict[str, Any] = {} + if batch_size is not None: + query_params["batchSize"] = str(batch_size) + if next_page_token is not None: + query_params["nextPageToken"] = next_page_token + if is_triggerable is not None: + query_params["isTriggerable"] = str(is_triggerable).lower() + + url = self.base_url + "/v1/campaigns.json" + + try: + await self.http.ensure_authenticated() + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MarketoResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_campaigns" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return MarketoResponse( + success=False, + error=str(e), + message="Failed to execute get_campaigns", + ) + + async def get_campaign_by_id( + self, + campaign_id: int | str, + ) -> MarketoResponse: + """Get a campaign by ID + + HTTP GET /v1/campaigns/{id}.json + + Args: + campaign_id: The campaign ID + + Returns: + MarketoResponse with campaign data + """ + url = self.base_url + "/v1/campaigns/{campaign_id}.json".format( + campaign_id=campaign_id + ) + + try: + await self.http.ensure_authenticated() + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MarketoResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_campaign_by_id" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return MarketoResponse( + success=False, + error=str(e), + message="Failed to execute get_campaign_by_id", + ) + + # ------------------------------------------------------------------ + # Static Lists + # ------------------------------------------------------------------ + + async def get_lists( + self, + *, + batch_size: int | None = None, + next_page_token: str | None = None, + name: str | None = None, + program_name: str | None = None, + workspace_name: str | None = None, + ) -> MarketoResponse: + """Get all static lists + + HTTP GET /v1/lists.json + + Args: + batch_size: Maximum number of records to return + next_page_token: Paging token from a previous response + name: Filter by list name + program_name: Filter by program name + workspace_name: Filter by workspace name + + Returns: + MarketoResponse with static lists + """ + query_params: dict[str, Any] = {} + if batch_size is not None: + query_params["batchSize"] = str(batch_size) + if next_page_token is not None: + query_params["nextPageToken"] = next_page_token + if name is not None: + query_params["name"] = name + if program_name is not None: + query_params["programName"] = program_name + if workspace_name is not None: + query_params["workspaceName"] = workspace_name + + url = self.base_url + "/v1/lists.json" + + try: + await self.http.ensure_authenticated() + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MarketoResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_lists" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return MarketoResponse( + success=False, + error=str(e), + message="Failed to execute get_lists", + ) + + async def get_list_by_id( + self, + list_id: int | str, + ) -> MarketoResponse: + """Get a static list by ID + + HTTP GET /v1/lists/{id}.json + + Args: + list_id: The list ID + + Returns: + MarketoResponse with list data + """ + url = self.base_url + "/v1/lists/{list_id}.json".format( + list_id=list_id + ) + + try: + await self.http.ensure_authenticated() + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MarketoResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_list_by_id" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return MarketoResponse( + success=False, + error=str(e), + message="Failed to execute get_list_by_id", + ) + + async def get_leads_in_list( + self, + list_id: int | str, + *, + fields: str | None = None, + batch_size: int | None = None, + next_page_token: str | None = None, + ) -> MarketoResponse: + """Get leads that are members of a static list + + HTTP GET /v1/lists/{id}/leads.json + + Args: + list_id: The list ID + fields: Comma-separated list of field names to return + batch_size: Maximum number of records to return + next_page_token: Paging token from a previous response + + Returns: + MarketoResponse with leads in the list + """ + query_params: dict[str, Any] = {} + if fields is not None: + query_params["fields"] = fields + if batch_size is not None: + query_params["batchSize"] = str(batch_size) + if next_page_token is not None: + query_params["nextPageToken"] = next_page_token + + url = self.base_url + "/v1/lists/{list_id}/leads.json".format( + list_id=list_id + ) + + try: + await self.http.ensure_authenticated() + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MarketoResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_leads_in_list" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return MarketoResponse( + success=False, + error=str(e), + message="Failed to execute get_leads_in_list", + ) + + # ------------------------------------------------------------------ + # Programs + # ------------------------------------------------------------------ + + async def get_programs( + self, + *, + offset: int | None = None, + max_return: int | None = None, + filter_type: str | None = None, + filter_values: str | None = None, + earliest_updated_at: str | None = None, + latest_updated_at: str | None = None, + ) -> MarketoResponse: + """Get all programs + + HTTP GET /v1/programs.json + + Args: + offset: Integer offset for paging + max_return: Maximum number of programs to return (max 200) + filter_type: Filter type (e.g. "id", "name") + filter_values: Comma-separated filter values + earliest_updated_at: Earliest updatedAt datetime (ISO 8601) + latest_updated_at: Latest updatedAt datetime (ISO 8601) + + Returns: + MarketoResponse with programs + """ + query_params: dict[str, Any] = {} + if offset is not None: + query_params["offset"] = str(offset) + if max_return is not None: + query_params["maxReturn"] = str(max_return) + if filter_type is not None: + query_params["filterType"] = filter_type + if filter_values is not None: + query_params["filterValues"] = filter_values + if earliest_updated_at is not None: + query_params["earliestUpdatedAt"] = earliest_updated_at + if latest_updated_at is not None: + query_params["latestUpdatedAt"] = latest_updated_at + + url = self.base_url + "/v1/programs.json" + + try: + await self.http.ensure_authenticated() + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MarketoResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_programs" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return MarketoResponse( + success=False, + error=str(e), + message="Failed to execute get_programs", + ) + + async def get_program_by_id( + self, + program_id: int | str, + ) -> MarketoResponse: + """Get a program by ID + + HTTP GET /v1/programs/{id}.json + + Args: + program_id: The program ID + + Returns: + MarketoResponse with program data + """ + url = self.base_url + "/v1/programs/{program_id}.json".format( + program_id=program_id + ) + + try: + await self.http.ensure_authenticated() + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MarketoResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_program_by_id" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return MarketoResponse( + success=False, + error=str(e), + message="Failed to execute get_program_by_id", + ) + + # ------------------------------------------------------------------ + # Custom Objects + # ------------------------------------------------------------------ + + async def get_custom_objects( + self, + *, + names: str | None = None, + ) -> MarketoResponse: + """List custom object types + + HTTP GET /v1/customobjects.json + + Args: + names: Comma-separated list of custom object API names + + Returns: + MarketoResponse with custom object type definitions + """ + query_params: dict[str, Any] = {} + if names is not None: + query_params["names"] = names + + url = self.base_url + "/v1/customobjects.json" + + try: + await self.http.ensure_authenticated() + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MarketoResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_custom_objects" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return MarketoResponse( + success=False, + error=str(e), + message="Failed to execute get_custom_objects", + ) + + # ------------------------------------------------------------------ + # Folders + # ------------------------------------------------------------------ + + async def get_folders( + self, + *, + root: int | None = None, + max_depth: int | None = None, + max_return: int | None = None, + offset: int | None = None, + workspace: str | None = None, + ) -> MarketoResponse: + """Get folders + + HTTP GET /v1/folders.json + + Args: + root: Parent folder ID + max_depth: Maximum folder depth to traverse + max_return: Maximum number of folders to return + offset: Integer offset for paging + workspace: Workspace name filter + + Returns: + MarketoResponse with folders + """ + query_params: dict[str, Any] = {} + if root is not None: + query_params["root"] = str(root) + if max_depth is not None: + query_params["maxDepth"] = str(max_depth) + if max_return is not None: + query_params["maxReturn"] = str(max_return) + if offset is not None: + query_params["offset"] = str(offset) + if workspace is not None: + query_params["workspace"] = workspace + + url = self.base_url + "/v1/folders.json" + + try: + await self.http.ensure_authenticated() + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MarketoResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_folders" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return MarketoResponse( + success=False, + error=str(e), + message="Failed to execute get_folders", + ) + + # ------------------------------------------------------------------ + # Tokens + # ------------------------------------------------------------------ + + async def get_tokens( + self, + folder_id: int | str, + folder_type: str, + ) -> MarketoResponse: + """Get tokens for a folder or program + + HTTP GET /v1/tokens.json + + Args: + folder_id: The folder or program ID + folder_type: The folder type (e.g. "Folder", "Program") + + Returns: + MarketoResponse with tokens + """ + query_params: dict[str, Any] = { + "folderId": str(folder_id), + "folderType": folder_type, + } + + url = self.base_url + "/v1/tokens.json" + + try: + await self.http.ensure_authenticated() + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MarketoResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_tokens" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return MarketoResponse( + success=False, + error=str(e), + message="Failed to execute get_tokens", + ) diff --git a/backend/python/app/sources/external/mattermost/example.py b/backend/python/app/sources/external/mattermost/example.py new file mode 100644 index 000000000..f4b9a38e8 --- /dev/null +++ b/backend/python/app/sources/external/mattermost/example.py @@ -0,0 +1,151 @@ +# ruff: noqa + +""" +Mattermost API Usage Examples + +This example demonstrates how to use the Mattermost DataSource to interact with +the Mattermost API v4, covering: +- Authentication (Personal Access Token) +- Initializing the Client and DataSource +- Fetching User Details +- Listing Teams, Channels, Posts +- System health check + +Prerequisites: +1. Set MATTERMOST_SERVER to your Mattermost server domain (e.g. "mattermost.example.com") +2. Set MATTERMOST_TOKEN to a personal access token + (Generated via Account Settings > Security > Personal Access Tokens) +""" + +import asyncio +import json +import os + +from app.sources.client.mattermost.mattermost import ( + MattermostClient, + MattermostResponse, + MattermostTokenConfig, +) +from app.sources.external.mattermost.mattermost import MattermostDataSource + +# --- Configuration --- +SERVER = os.getenv("MATTERMOST_SERVER", "") +TOKEN = os.getenv("MATTERMOST_TOKEN", "") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: MattermostResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + if isinstance(data, list): + print(f" Found {len(data)} items.") + if data: + print(f" Sample: {json.dumps(data[0], indent=2)[:400]}...") + elif isinstance(data, dict): + print(f" Data: {json.dumps(data, indent=2)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + # 1. Initialize Client + print_section("Initializing Mattermost Client") + + if not SERVER or not TOKEN: + print(" No valid authentication found.") + print(" Please set:") + print(" - MATTERMOST_SERVER (e.g. mattermost.example.com)") + print(" - MATTERMOST_TOKEN (personal access token)") + return + + config = MattermostTokenConfig(token=TOKEN, server=SERVER) + client = MattermostClient.build_with_config(config) + data_source = MattermostDataSource(client) + print(f"Client initialized for server: {SERVER}") + + try: + # 2. System Health + print_section("System Health") + ping_resp = await data_source.ping() + print_result("Ping", ping_resp) + + # 3. Get Current User + print_section("Current User") + me_resp = await data_source.get_me() + print_result("Get Me", me_resp) + + user_id = None + if me_resp.success and isinstance(me_resp.data, dict): + user_id = str(me_resp.data.get("id", "")) + print(f" User: {me_resp.data.get('username')} (ID: {user_id})") + + # 4. Get Teams + print_section("Teams") + teams_resp = await data_source.get_teams() + print_result("Get Teams", teams_resp) + + team_id = None + if teams_resp.success and isinstance(teams_resp.data, list) and teams_resp.data: + team_id = str(teams_resp.data[0].get("id", "")) + print(f" Using Team: {teams_resp.data[0].get('display_name')} (ID: {team_id})") + + if not team_id: + print(" No teams found. Skipping further operations.") + return + + # 5. Get Team Channels + print_section("Team Channels") + channels_resp = await data_source.get_team_channels(team_id) + print_result("Get Team Channels", channels_resp) + + channel_id = None + if channels_resp.success and isinstance(channels_resp.data, list) and channels_resp.data: + channel_id = str(channels_resp.data[0].get("id", "")) + print(f" Using Channel: {channels_resp.data[0].get('display_name')} (ID: {channel_id})") + + # 6. Get Channel Posts + if channel_id: + print_section("Channel Posts") + posts_resp = await data_source.get_channel_posts(channel_id, per_page=5) + print_result("Get Channel Posts", posts_resp) + + # 7. Get Team Members + print_section("Team Members") + members_resp = await data_source.get_team_members(team_id, per_page=5) + print_result("Get Team Members", members_resp) + + # 8. Get User Teams + if user_id: + print_section("User Teams") + user_teams_resp = await data_source.get_user_teams(user_id) + print_result("Get User Teams", user_teams_resp) + + # 9. Get Emoji + print_section("Custom Emoji") + emoji_resp = await data_source.get_emoji_list(per_page=5) + print_result("Get Emoji List", emoji_resp) + + finally: + # Cleanup + print("\nClosing client connection...") + inner_client = client.get_client() + if hasattr(inner_client, "close"): + await inner_client.close() + + print("\n" + "=" * 80) + print(" All Mattermost API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/mattermost/mattermost.py b/backend/python/app/sources/external/mattermost/mattermost.py new file mode 100644 index 000000000..9a79e748e --- /dev/null +++ b/backend/python/app/sources/external/mattermost/mattermost.py @@ -0,0 +1,744 @@ +""" +Mattermost REST API DataSource - Auto-generated API wrapper + +Generated from Mattermost REST API v4 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.http.http_request import HTTPRequest +from app.sources.client.mattermost.mattermost import ( + MattermostClient, + MattermostResponse, +) + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class MattermostDataSource: + """Mattermost REST API DataSource + + Provides async wrapper methods for Mattermost REST API v4 operations: + - User management + - Team management + - Channel management + - Post/message management + - File info + - System health + - Emoji + + The base URL is determined by the server domain configured in the client. + All methods return MattermostResponse objects. + """ + + def __init__(self, client: MattermostClient) -> None: + """Initialize with MattermostClient. + + Args: + client: MattermostClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip("/") + except AttributeError as exc: + raise ValueError( + "HTTP client does not have get_base_url method" + ) from exc + + def get_data_source(self) -> "MattermostDataSource": + """Return the data source instance.""" + return self + + def get_client(self) -> MattermostClient: + """Return the underlying MattermostClient.""" + return self._client + + # ----------------------------------------------------------------------- + # Users + # ----------------------------------------------------------------------- + + async def get_users( + self, + *, + page: int | None = None, + per_page: int | None = None, + in_team: str | None = None, + in_channel: str | None = None, + sort: str | None = None, + ) -> MattermostResponse: + """Get a list of users. + + Args: + page: Page number (0-based) + per_page: Number of results per page (max 200) + in_team: Filter by team ID + in_channel: Filter by channel ID + sort: Sort field (e.g. "last_activity_at", "create_at") + + Returns: + MattermostResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params["page"] = str(page) + if per_page is not None: + query_params["per_page"] = str(per_page) + if in_team is not None: + query_params["in_team"] = in_team + if in_channel is not None: + query_params["in_channel"] = in_channel + if sort is not None: + query_params["sort"] = sort + + url = self.base_url + "/users" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MattermostResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_users" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return MattermostResponse( + success=False, + error=str(e), + message="Failed to execute get_users", + ) + + async def get_user(self, user_id: str) -> MattermostResponse: + """Get a user by ID. + + Args: + user_id: The user ID + + Returns: + MattermostResponse with operation result + """ + url = self.base_url + "/users/{user_id}".format(user_id=user_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MattermostResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_user" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return MattermostResponse( + success=False, + error=str(e), + message="Failed to execute get_user", + ) + + async def get_me(self) -> MattermostResponse: + """Get the authenticated user's details. + + Returns: + MattermostResponse with operation result + """ + url = self.base_url + "/users/me" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MattermostResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_me" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return MattermostResponse( + success=False, + error=str(e), + message="Failed to execute get_me", + ) + + # ----------------------------------------------------------------------- + # Teams + # ----------------------------------------------------------------------- + + async def get_teams( + self, + *, + page: int | None = None, + per_page: int | None = None, + ) -> MattermostResponse: + """Get a list of teams. + + Args: + page: Page number (0-based) + per_page: Number of results per page + + Returns: + MattermostResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params["page"] = str(page) + if per_page is not None: + query_params["per_page"] = str(per_page) + + url = self.base_url + "/teams" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MattermostResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_teams" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return MattermostResponse( + success=False, + error=str(e), + message="Failed to execute get_teams", + ) + + async def get_team(self, team_id: str) -> MattermostResponse: + """Get a team by ID. + + Args: + team_id: The team ID + + Returns: + MattermostResponse with operation result + """ + url = self.base_url + "/teams/{team_id}".format(team_id=team_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MattermostResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_team" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return MattermostResponse( + success=False, + error=str(e), + message="Failed to execute get_team", + ) + + async def get_team_channels( + self, + team_id: str, + *, + page: int | None = None, + per_page: int | None = None, + ) -> MattermostResponse: + """Get channels for a team. + + Args: + team_id: The team ID + page: Page number (0-based) + per_page: Number of results per page + + Returns: + MattermostResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params["page"] = str(page) + if per_page is not None: + query_params["per_page"] = str(per_page) + + url = self.base_url + "/teams/{team_id}/channels".format( + team_id=team_id + ) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MattermostResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_team_channels" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return MattermostResponse( + success=False, + error=str(e), + message="Failed to execute get_team_channels", + ) + + async def get_team_members( + self, + team_id: str, + *, + page: int | None = None, + per_page: int | None = None, + ) -> MattermostResponse: + """Get members of a team. + + Args: + team_id: The team ID + page: Page number (0-based) + per_page: Number of results per page + + Returns: + MattermostResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params["page"] = str(page) + if per_page is not None: + query_params["per_page"] = str(per_page) + + url = self.base_url + "/teams/{team_id}/members".format( + team_id=team_id + ) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MattermostResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_team_members" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return MattermostResponse( + success=False, + error=str(e), + message="Failed to execute get_team_members", + ) + + async def get_user_teams(self, user_id: str) -> MattermostResponse: + """Get teams for a user. + + Args: + user_id: The user ID + + Returns: + MattermostResponse with operation result + """ + url = self.base_url + "/users/{user_id}/teams".format( + user_id=user_id + ) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MattermostResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_user_teams" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return MattermostResponse( + success=False, + error=str(e), + message="Failed to execute get_user_teams", + ) + + # ----------------------------------------------------------------------- + # Channels + # ----------------------------------------------------------------------- + + async def get_channels( + self, + *, + page: int | None = None, + per_page: int | None = None, + ) -> MattermostResponse: + """Get a list of all channels. + + Args: + page: Page number (0-based) + per_page: Number of results per page + + Returns: + MattermostResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params["page"] = str(page) + if per_page is not None: + query_params["per_page"] = str(per_page) + + url = self.base_url + "/channels" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MattermostResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_channels" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return MattermostResponse( + success=False, + error=str(e), + message="Failed to execute get_channels", + ) + + async def get_channel(self, channel_id: str) -> MattermostResponse: + """Get a channel by ID. + + Args: + channel_id: The channel ID + + Returns: + MattermostResponse with operation result + """ + url = self.base_url + "/channels/{channel_id}".format( + channel_id=channel_id + ) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MattermostResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_channel" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return MattermostResponse( + success=False, + error=str(e), + message="Failed to execute get_channel", + ) + + async def get_channel_posts( + self, + channel_id: str, + *, + page: int | None = None, + per_page: int | None = None, + since: int | None = None, + before: str | None = None, + after: str | None = None, + ) -> MattermostResponse: + """Get posts for a channel. + + Args: + channel_id: The channel ID + page: Page number (0-based) + per_page: Number of results per page + since: Unix timestamp in milliseconds to filter posts created after + before: Post ID to get posts before + after: Post ID to get posts after + + Returns: + MattermostResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params["page"] = str(page) + if per_page is not None: + query_params["per_page"] = str(per_page) + if since is not None: + query_params["since"] = str(since) + if before is not None: + query_params["before"] = before + if after is not None: + query_params["after"] = after + + url = self.base_url + "/channels/{channel_id}/posts".format( + channel_id=channel_id + ) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MattermostResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_channel_posts" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return MattermostResponse( + success=False, + error=str(e), + message="Failed to execute get_channel_posts", + ) + + # ----------------------------------------------------------------------- + # Posts + # ----------------------------------------------------------------------- + + async def get_post(self, post_id: str) -> MattermostResponse: + """Get a post by ID. + + Args: + post_id: The post ID + + Returns: + MattermostResponse with operation result + """ + url = self.base_url + "/posts/{post_id}".format(post_id=post_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MattermostResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_post" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return MattermostResponse( + success=False, + error=str(e), + message="Failed to execute get_post", + ) + + # ----------------------------------------------------------------------- + # Files + # ----------------------------------------------------------------------- + + async def get_file_info(self, file_id: str) -> MattermostResponse: + """Get file info by ID. + + Args: + file_id: The file ID + + Returns: + MattermostResponse with operation result + """ + url = self.base_url + "/files/{file_id}/info".format( + file_id=file_id + ) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MattermostResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_file_info" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return MattermostResponse( + success=False, + error=str(e), + message="Failed to execute get_file_info", + ) + + # ----------------------------------------------------------------------- + # System + # ----------------------------------------------------------------------- + + async def ping(self) -> MattermostResponse: + """Check system health (ping). + + Returns: + MattermostResponse with operation result + """ + url = self.base_url + "/system/ping" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MattermostResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed ping" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return MattermostResponse( + success=False, + error=str(e), + message="Failed to execute ping", + ) + + # ----------------------------------------------------------------------- + # Emoji + # ----------------------------------------------------------------------- + + async def get_emoji_list( + self, + *, + page: int | None = None, + per_page: int | None = None, + sort: str | None = None, + ) -> MattermostResponse: + """Get a list of custom emoji. + + Args: + page: Page number (0-based) + per_page: Number of results per page + sort: Sort order ("name" for alphabetical) + + Returns: + MattermostResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params["page"] = str(page) + if per_page is not None: + query_params["per_page"] = str(per_page) + if sort is not None: + query_params["sort"] = sort + + url = self.base_url + "/emoji" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MattermostResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_emoji_list" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return MattermostResponse( + success=False, + error=str(e), + message="Failed to execute get_emoji_list", + ) diff --git a/backend/python/app/sources/external/microsoft/entraid/__init__.py b/backend/python/app/sources/external/microsoft/entraid/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/python/app/sources/external/microsoft/entraid/entraid.py b/backend/python/app/sources/external/microsoft/entraid/entraid.py new file mode 100644 index 000000000..da71cb5d9 --- /dev/null +++ b/backend/python/app/sources/external/microsoft/entraid/entraid.py @@ -0,0 +1,2809 @@ +import json +import logging +from collections.abc import Mapping +from typing import Any, Optional + +from kiota_abstractions.base_request_configuration import RequestConfiguration # type: ignore[reportMissingImports, reportUnknownVariableType] +from msgraph.generated.groups.groups_request_builder import GroupsRequestBuilder # type: ignore[reportMissingImports, reportUnknownVariableType] +from msgraph.generated.organization.organization_request_builder import OrganizationRequestBuilder # type: ignore[reportMissingImports, reportUnknownVariableType] + +from app.sources.client.microsoft.microsoft import MSGraphClient + + +# Entra ID-specific response wrapper +class EntraIDResponse: + """Standardized Entra ID API response wrapper.""" + success: bool + data: Optional[dict[str, Any]] = None + error: Optional[str] = None + message: Optional[str] = None + + def __init__(self, success: bool, data: Optional[dict[str, Any]] = None, error: Optional[str] = None, message: Optional[str] = None) -> None: + super().__init__() + self.success = success + self.data = data + self.error = error + self.message = message + + def to_dict(self) -> dict[str, Any]: + return {"success": self.success, "data": self.data, "error": self.error, "message": self.message} + + def to_json(self) -> str: + return json.dumps(self.to_dict()) + +# Set up logger +logger = logging.getLogger(__name__) + +class EntraIDDataSource: + """ + Microsoft Entra ID (Azure Active Directory) API client for SSO, security, + and identity management operations. + + This datasource covers Entra ID-specific concerns that go beyond basic + user/group CRUD: + + Moved from UsersGroupsDataSource: + - Organization Branding: logo, favicon, background, custom CSS, localizations + - Group Lifecycle Policies: creation, renewal, expiration management + - Domains: domain management, verification, DNS records, federation config + - Subscriptions: webhook subscription management + - Tenant Relationships: multi-tenant organization management + - Cross-Tenant Access Policies: partner configuration templates + - Certificate-Based Auth Configuration: cert auth setup for organizations + + New Entra ID APIs: + - Service Principals: application identity objects in the directory + - Applications (App Registrations): registered application management + - Directory Roles: built-in and custom role management + - Audit Logs: sign-in logs and directory audit logs + - Conditional Access: policies governing access control + - Identity Providers: federated identity provider configuration + - Administrative Units: scoped admin management boundaries + - Authentication Methods: user MFA and auth method management + + All methods use the Microsoft Graph SDK via the shared MSGraphClient. + """ + + def __init__(self, client: MSGraphClient) -> None: + """Initialize with Microsoft Graph SDK client for Entra ID operations.""" + super().__init__() + self.client: Any = client.get_client().get_ms_graph_service_client() # type: ignore[reportUnknownMemberType] + if not hasattr(self.client, "users"): # type: ignore[reportUnknownArgumentType] + raise ValueError("Client must be a Microsoft Graph SDK client") + logger.info("Entra ID client initialized") + + def _handle_entra_id_response(self, response: Any) -> EntraIDResponse: + """Handle Entra ID API response with comprehensive error handling.""" + try: + if response is None: + return EntraIDResponse(success=False, error="Empty response from Entra ID API") + + success = True + error_msg = None + + if hasattr(response, 'error'): # type: ignore[reportUnknownArgumentType] + success = False + error_msg = str(response.error) # type: ignore[reportUnknownMemberType, reportUnknownArgumentType] + elif isinstance(response, dict) and 'error' in response: # type: ignore[reportUnknownArgumentType] + success = False + error_info = response['error'] # type: ignore[reportUnknownMemberType] + if isinstance(error_info, dict): # type: ignore[reportUnknownArgumentType] + error_code = error_info.get('code', 'Unknown') # type: ignore[reportUnknownMemberType] + error_message = error_info.get('message', 'No message') # type: ignore[reportUnknownMemberType] + error_msg = f"{error_code}: {error_message}" + else: + error_msg = str(error_info) # type: ignore[reportUnknownArgumentType] + elif hasattr(response, 'code') and hasattr(response, 'message'): # type: ignore[reportUnknownArgumentType] + success = False + error_msg = f"{response.code}: {response.message}" # type: ignore[reportUnknownMemberType] + + return EntraIDResponse( + success=success, + data=response, # type: ignore[reportArgumentType] + error=error_msg, + ) + except Exception as e: + logger.error(f"Error handling Entra ID response: {e}") + return EntraIDResponse(success=False, error=str(e)) + + def get_data_source(self) -> 'EntraIDDataSource': + """Get the underlying Entra ID client.""" + return self + + # ========================================================================== + # ORGANIZATION BRANDING OPERATIONS (moved from UsersGroupsDataSource) + # ========================================================================== + + async def organization_delete_branding( + self, + organization_id: str, + If_Match: Optional[str] = None, + select: Optional[list[str]] = None, + expand: Optional[list[str]] = None, + filter: Optional[str] = None, + orderby: Optional[str] = None, + search: Optional[str] = None, + top: Optional[int] = None, + skip: Optional[int] = None, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Delete organizationalBranding. + Entra ID operation: DELETE /organization/{organization-id}/branding + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.delete(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_get_branding( + self, + organization_id: str, + dollar_select: Optional[list[str]] = None, + dollar_expand: Optional[list[str]] = None, + select: Optional[list[str]] = None, + expand: Optional[list[str]] = None, + filter: Optional[str] = None, + orderby: Optional[str] = None, + search: Optional[str] = None, + top: Optional[int] = None, + skip: Optional[int] = None, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Get organizationalBranding. + Entra ID operation: GET /organization/{organization-id}/branding + """ + try: + query_params: Any = OrganizationRequestBuilder.OrganizationRequestBuilderGetQueryParameters() # type: ignore[reportUnknownVariableType, reportUnknownMemberType] + if select: + query_params.select = select if isinstance(select, list) else [select] # type: ignore[reportUnnecessaryIsInstance] + + if expand: + query_params.expand = expand if isinstance(expand, list) else [expand] # type: ignore[reportUnnecessaryIsInstance] + + if filter: + query_params.filter = filter # type: ignore[reportUnknownMemberType] + + + config: Any = OrganizationRequestBuilder.OrganizationRequestBuilderGetRequestConfiguration() # type: ignore[reportUnknownVariableType] + config.query_parameters = query_params # type: ignore[reportUnknownMemberType] + + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_update_branding( + self, + organization_id: str, + select: Optional[list[str]] = None, + expand: Optional[list[str]] = None, + filter: Optional[str] = None, + orderby: Optional[str] = None, + search: Optional[str] = None, + top: Optional[int] = None, + skip: Optional[int] = None, + request_body: Optional[Mapping[str, Any]] = None, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Update organizationalBranding. + Entra ID operation: PATCH /organization/{organization-id}/branding + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.patch(body=request_body, request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_delete_branding_background_image( + self, + organization_id: str, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Delete branding background image. + Entra ID operation: DELETE /organization/{organization-id}/branding/backgroundImage + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.background_image.delete(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_get_branding_background_image( + self, + organization_id: str, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Get branding background image. + Entra ID operation: GET /organization/{organization-id}/branding/backgroundImage + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.background_image.get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_update_branding_background_image( + self, + organization_id: str, + request_body: Optional[bytes] = None, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Update branding background image. + Entra ID operation: PUT /organization/{organization-id}/branding/backgroundImage + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.background_image.put(body=request_body, request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_delete_branding_banner_logo( + self, + organization_id: str, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Delete branding banner logo. + Entra ID operation: DELETE /organization/{organization-id}/branding/bannerLogo + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.banner_logo.delete(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_get_branding_banner_logo( + self, + organization_id: str, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Get branding banner logo. + Entra ID operation: GET /organization/{organization-id}/branding/bannerLogo + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.banner_logo.get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_update_branding_banner_logo( + self, + organization_id: str, + request_body: Optional[bytes] = None, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Update branding banner logo. + Entra ID operation: PUT /organization/{organization-id}/branding/bannerLogo + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.banner_logo.put(body=request_body, request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_delete_branding_custom_css( + self, + organization_id: str, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Delete branding custom CSS. + Entra ID operation: DELETE /organization/{organization-id}/branding/customCSS + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.custom_c_s_s.delete(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_get_branding_custom_css( + self, + organization_id: str, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Get branding custom CSS. + Entra ID operation: GET /organization/{organization-id}/branding/customCSS + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.custom_c_s_s.get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_update_branding_custom_css( + self, + organization_id: str, + request_body: Optional[bytes] = None, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Update branding custom CSS. + Entra ID operation: PUT /organization/{organization-id}/branding/customCSS + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.custom_c_s_s.put(body=request_body, request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_delete_branding_favicon( + self, + organization_id: str, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Delete branding favicon. + Entra ID operation: DELETE /organization/{organization-id}/branding/favicon + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.favicon.delete(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_get_branding_favicon( + self, + organization_id: str, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Get branding favicon. + Entra ID operation: GET /organization/{organization-id}/branding/favicon + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.favicon.get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_update_branding_favicon( + self, + organization_id: str, + request_body: Optional[bytes] = None, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Update branding favicon. + Entra ID operation: PUT /organization/{organization-id}/branding/favicon + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.favicon.put(body=request_body, request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_delete_branding_header_logo( + self, + organization_id: str, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Delete branding header logo. + Entra ID operation: DELETE /organization/{organization-id}/branding/headerLogo + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.header_logo.delete(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_get_branding_header_logo( + self, + organization_id: str, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Get branding header logo. + Entra ID operation: GET /organization/{organization-id}/branding/headerLogo + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.header_logo.get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_update_branding_header_logo( + self, + organization_id: str, + request_body: Optional[bytes] = None, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Update branding header logo. + Entra ID operation: PUT /organization/{organization-id}/branding/headerLogo + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.header_logo.put(body=request_body, request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_branding_create_localizations( + self, + organization_id: str, + request_body: Optional[Mapping[str, Any]] = None, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Create branding localization. + Entra ID operation: POST /organization/{organization-id}/branding/localizations + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.localizations.post(body=request_body, request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_branding_list_localizations( + self, + organization_id: str, + select: Optional[list[str]] = None, + expand: Optional[list[str]] = None, + filter: Optional[str] = None, + orderby: Optional[str] = None, + search: Optional[str] = None, + top: Optional[int] = None, + skip: Optional[int] = None, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """List branding localizations. + Entra ID operation: GET /organization/{organization-id}/branding/localizations + """ + try: + query_params: Any = OrganizationRequestBuilder.OrganizationRequestBuilderGetQueryParameters() # type: ignore[reportUnknownVariableType, reportUnknownMemberType] + if select: + query_params.select = select if isinstance(select, list) else [select] # type: ignore[reportUnnecessaryIsInstance] + + if expand: + query_params.expand = expand if isinstance(expand, list) else [expand] # type: ignore[reportUnnecessaryIsInstance] + + if filter: + query_params.filter = filter # type: ignore[reportUnknownMemberType] + + + config: Any = OrganizationRequestBuilder.OrganizationRequestBuilderGetRequestConfiguration() # type: ignore[reportUnknownVariableType] + config.query_parameters = query_params # type: ignore[reportUnknownMemberType] + + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + if search: + if not config.headers: # type: ignore[reportUnknownMemberType] + + config.headers = {} # type: ignore[reportUnknownMemberType] + + config.headers['ConsistencyLevel'] = 'eventual' # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.localizations.get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_branding_delete_localizations( + self, + organization_id: str, + organizationalBrandingLocalization_id: str, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Delete branding localization. + Entra ID operation: DELETE /organization/{organization-id}/branding/localizations/{localization-id} + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.localizations.by_organizational_branding_localization_id(organizationalBrandingLocalization_id).delete(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_branding_get_localizations( + self, + organization_id: str, + organizationalBrandingLocalization_id: str, + select: Optional[list[str]] = None, + expand: Optional[list[str]] = None, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Get branding localization. + Entra ID operation: GET /organization/{organization-id}/branding/localizations/{localization-id} + """ + try: + query_params: Any = OrganizationRequestBuilder.OrganizationRequestBuilderGetQueryParameters() # type: ignore[reportUnknownVariableType, reportUnknownMemberType] + if select: + query_params.select = select if isinstance(select, list) else [select] # type: ignore[reportUnnecessaryIsInstance] + + if expand: + query_params.expand = expand if isinstance(expand, list) else [expand] # type: ignore[reportUnnecessaryIsInstance] + + + config: Any = OrganizationRequestBuilder.OrganizationRequestBuilderGetRequestConfiguration() # type: ignore[reportUnknownVariableType] + config.query_parameters = query_params # type: ignore[reportUnknownMemberType] + + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.localizations.by_organizational_branding_localization_id(organizationalBrandingLocalization_id).get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_branding_update_localizations( + self, + organization_id: str, + organizationalBrandingLocalization_id: str, + request_body: Optional[Mapping[str, Any]] = None, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Update branding localization. + Entra ID operation: PATCH /organization/{organization-id}/branding/localizations/{localization-id} + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.localizations.by_organizational_branding_localization_id(organizationalBrandingLocalization_id).patch(body=request_body, request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_branding_delete_localizations_background_image( + self, + organization_id: str, + organizationalBrandingLocalization_id: str, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Delete localization background image. + Entra ID operation: DELETE /organization/{organization-id}/branding/localizations/{localization-id}/backgroundImage + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.localizations.by_organizational_branding_localization_id(organizationalBrandingLocalization_id).background_image.delete(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_branding_get_localizations_background_image( + self, + organization_id: str, + organizationalBrandingLocalization_id: str, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Get localization background image. + Entra ID operation: GET /organization/{organization-id}/branding/localizations/{localization-id}/backgroundImage + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.localizations.by_organizational_branding_localization_id(organizationalBrandingLocalization_id).background_image.get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_branding_update_localizations_background_image( + self, + organization_id: str, + organizationalBrandingLocalization_id: str, + request_body: Optional[bytes] = None, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Update localization background image. + Entra ID operation: PUT /organization/{organization-id}/branding/localizations/{localization-id}/backgroundImage + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.localizations.by_organizational_branding_localization_id(organizationalBrandingLocalization_id).background_image.put(body=request_body, request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_branding_delete_localizations_banner_logo( + self, + organization_id: str, + organizationalBrandingLocalization_id: str, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Delete localization banner logo. + Entra ID operation: DELETE /organization/{organization-id}/branding/localizations/{localization-id}/bannerLogo + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.localizations.by_organizational_branding_localization_id(organizationalBrandingLocalization_id).banner_logo.delete(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_branding_get_localizations_banner_logo( + self, + organization_id: str, + organizationalBrandingLocalization_id: str, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Get localization banner logo. + Entra ID operation: GET /organization/{organization-id}/branding/localizations/{localization-id}/bannerLogo + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.localizations.by_organizational_branding_localization_id(organizationalBrandingLocalization_id).banner_logo.get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_branding_update_localizations_banner_logo( + self, + organization_id: str, + organizationalBrandingLocalization_id: str, + request_body: Optional[bytes] = None, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Update localization banner logo. + Entra ID operation: PUT /organization/{organization-id}/branding/localizations/{localization-id}/bannerLogo + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.localizations.by_organizational_branding_localization_id(organizationalBrandingLocalization_id).banner_logo.put(body=request_body, request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_branding_delete_localizations_custom_css( + self, + organization_id: str, + organizationalBrandingLocalization_id: str, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Delete localization custom CSS. + Entra ID operation: DELETE /organization/{organization-id}/branding/localizations/{localization-id}/customCSS + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.localizations.by_organizational_branding_localization_id(organizationalBrandingLocalization_id).custom_c_s_s.delete(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_branding_get_localizations_custom_css( + self, + organization_id: str, + organizationalBrandingLocalization_id: str, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Get localization custom CSS. + Entra ID operation: GET /organization/{organization-id}/branding/localizations/{localization-id}/customCSS + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.localizations.by_organizational_branding_localization_id(organizationalBrandingLocalization_id).custom_c_s_s.get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_branding_update_localizations_custom_css( + self, + organization_id: str, + organizationalBrandingLocalization_id: str, + request_body: Optional[bytes] = None, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Update localization custom CSS. + Entra ID operation: PUT /organization/{organization-id}/branding/localizations/{localization-id}/customCSS + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.localizations.by_organizational_branding_localization_id(organizationalBrandingLocalization_id).custom_c_s_s.put(body=request_body, request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_branding_delete_localizations_favicon( + self, + organization_id: str, + organizationalBrandingLocalization_id: str, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Delete localization favicon. + Entra ID operation: DELETE /organization/{organization-id}/branding/localizations/{localization-id}/favicon + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.localizations.by_organizational_branding_localization_id(organizationalBrandingLocalization_id).favicon.delete(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_branding_get_localizations_favicon( + self, + organization_id: str, + organizationalBrandingLocalization_id: str, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Get localization favicon. + Entra ID operation: GET /organization/{organization-id}/branding/localizations/{localization-id}/favicon + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.localizations.by_organizational_branding_localization_id(organizationalBrandingLocalization_id).favicon.get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_branding_update_localizations_favicon( + self, + organization_id: str, + organizationalBrandingLocalization_id: str, + request_body: Optional[bytes] = None, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Update localization favicon. + Entra ID operation: PUT /organization/{organization-id}/branding/localizations/{localization-id}/favicon + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.localizations.by_organizational_branding_localization_id(organizationalBrandingLocalization_id).favicon.put(body=request_body, request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_branding_delete_localizations_header_logo( + self, + organization_id: str, + organizationalBrandingLocalization_id: str, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Delete localization header logo. + Entra ID operation: DELETE /organization/{organization-id}/branding/localizations/{localization-id}/headerLogo + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.localizations.by_organizational_branding_localization_id(organizationalBrandingLocalization_id).header_logo.delete(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_branding_get_localizations_header_logo( + self, + organization_id: str, + organizationalBrandingLocalization_id: str, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Get localization header logo. + Entra ID operation: GET /organization/{organization-id}/branding/localizations/{localization-id}/headerLogo + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.localizations.by_organizational_branding_localization_id(organizationalBrandingLocalization_id).header_logo.get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_branding_update_localizations_header_logo( + self, + organization_id: str, + organizationalBrandingLocalization_id: str, + request_body: Optional[bytes] = None, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Update localization header logo. + Entra ID operation: PUT /organization/{organization-id}/branding/localizations/{localization-id}/headerLogo + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.localizations.by_organizational_branding_localization_id(organizationalBrandingLocalization_id).header_logo.put(body=request_body, request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_branding_delete_localizations_square_logo( + self, + organization_id: str, + organizationalBrandingLocalization_id: str, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Delete localization square logo. + Entra ID operation: DELETE /organization/{organization-id}/branding/localizations/{localization-id}/squareLogo + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.localizations.by_organizational_branding_localization_id(organizationalBrandingLocalization_id).square_logo.delete(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_branding_get_localizations_square_logo( + self, + organization_id: str, + organizationalBrandingLocalization_id: str, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Get localization square logo. + Entra ID operation: GET /organization/{organization-id}/branding/localizations/{localization-id}/squareLogo + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.localizations.by_organizational_branding_localization_id(organizationalBrandingLocalization_id).square_logo.get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_branding_update_localizations_square_logo( + self, + organization_id: str, + organizationalBrandingLocalization_id: str, + request_body: Optional[bytes] = None, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Update localization square logo. + Entra ID operation: PUT /organization/{organization-id}/branding/localizations/{localization-id}/squareLogo + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.localizations.by_organizational_branding_localization_id(organizationalBrandingLocalization_id).square_logo.put(body=request_body, request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_branding_delete_localizations_square_logo_dark( + self, + organization_id: str, + organizationalBrandingLocalization_id: str, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Delete localization square logo dark. + Entra ID operation: DELETE /organization/{organization-id}/branding/localizations/{localization-id}/squareLogoDark + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.localizations.by_organizational_branding_localization_id(organizationalBrandingLocalization_id).square_logo_dark.delete(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_branding_get_localizations_square_logo_dark( + self, + organization_id: str, + organizationalBrandingLocalization_id: str, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Get localization square logo dark. + Entra ID operation: GET /organization/{organization-id}/branding/localizations/{localization-id}/squareLogoDark + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.localizations.by_organizational_branding_localization_id(organizationalBrandingLocalization_id).square_logo_dark.get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_branding_update_localizations_square_logo_dark( + self, + organization_id: str, + organizationalBrandingLocalization_id: str, + request_body: Optional[bytes] = None, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Update localization square logo dark. + Entra ID operation: PUT /organization/{organization-id}/branding/localizations/{localization-id}/squareLogoDark + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.localizations.by_organizational_branding_localization_id(organizationalBrandingLocalization_id).square_logo_dark.put(body=request_body, request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_delete_branding_square_logo( + self, + organization_id: str, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Delete branding square logo. + Entra ID operation: DELETE /organization/{organization-id}/branding/squareLogo + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.square_logo.delete(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_get_branding_square_logo( + self, + organization_id: str, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Get branding square logo. + Entra ID operation: GET /organization/{organization-id}/branding/squareLogo + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.square_logo.get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_update_branding_square_logo( + self, + organization_id: str, + request_body: Optional[bytes] = None, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Update branding square logo. + Entra ID operation: PUT /organization/{organization-id}/branding/squareLogo + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.square_logo.put(body=request_body, request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_delete_branding_square_logo_dark( + self, + organization_id: str, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Delete branding square logo dark. + Entra ID operation: DELETE /organization/{organization-id}/branding/squareLogoDark + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.square_logo_dark.delete(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_get_branding_square_logo_dark( + self, + organization_id: str, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Get branding square logo dark. + Entra ID operation: GET /organization/{organization-id}/branding/squareLogoDark + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.square_logo_dark.get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_update_branding_square_logo_dark( + self, + organization_id: str, + request_body: Optional[bytes] = None, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Update branding square logo dark. + Entra ID operation: PUT /organization/{organization-id}/branding/squareLogoDark + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).branding.square_logo_dark.put(body=request_body, request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + # ========================================================================== + # CERTIFICATE-BASED AUTH CONFIGURATION (moved from UsersGroupsDataSource) + # ========================================================================== + + async def organization_create_certificate_based_auth_configuration( + self, + organization_id: str, + request_body: Optional[Mapping[str, Any]] = None, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Create certificateBasedAuthConfiguration. + Entra ID operation: POST /organization/{organization-id}/certificateBasedAuthConfiguration + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).certificate_based_auth_configuration.post(body=request_body, request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_list_certificate_based_auth_configuration( + self, + organization_id: str, + select: Optional[list[str]] = None, + expand: Optional[list[str]] = None, + filter: Optional[str] = None, + orderby: Optional[str] = None, + search: Optional[str] = None, + top: Optional[int] = None, + skip: Optional[int] = None, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """List certificateBasedAuthConfigurations. + Entra ID operation: GET /organization/{organization-id}/certificateBasedAuthConfiguration + """ + try: + query_params: Any = OrganizationRequestBuilder.OrganizationRequestBuilderGetQueryParameters() # type: ignore[reportUnknownVariableType, reportUnknownMemberType] + if select: + query_params.select = select if isinstance(select, list) else [select] # type: ignore[reportUnnecessaryIsInstance] + + if expand: + query_params.expand = expand if isinstance(expand, list) else [expand] # type: ignore[reportUnnecessaryIsInstance] + + if filter: + query_params.filter = filter # type: ignore[reportUnknownMemberType] + + + config: Any = OrganizationRequestBuilder.OrganizationRequestBuilderGetRequestConfiguration() # type: ignore[reportUnknownVariableType] + config.query_parameters = query_params # type: ignore[reportUnknownMemberType] + + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + if search: + if not config.headers: # type: ignore[reportUnknownMemberType] + + config.headers = {} # type: ignore[reportUnknownMemberType] + + config.headers['ConsistencyLevel'] = 'eventual' # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).certificate_based_auth_configuration.get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_delete_certificate_based_auth_configuration( + self, + organization_id: str, + certificateBasedAuthConfiguration_id: str, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Delete certificateBasedAuthConfiguration. + Entra ID operation: DELETE /organization/{organization-id}/certificateBasedAuthConfiguration/{id} + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).certificate_based_auth_configuration.by_certificate_based_auth_configuration_id(certificateBasedAuthConfiguration_id).delete(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def organization_get_certificate_based_auth_configuration( + self, + organization_id: str, + certificateBasedAuthConfiguration_id: str, + select: Optional[list[str]] = None, + expand: Optional[list[str]] = None, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Get certificateBasedAuthConfiguration. + Entra ID operation: GET /organization/{organization-id}/certificateBasedAuthConfiguration/{id} + """ + try: + query_params: Any = OrganizationRequestBuilder.OrganizationRequestBuilderGetQueryParameters() # type: ignore[reportUnknownVariableType, reportUnknownMemberType] + if select: + query_params.select = select if isinstance(select, list) else [select] # type: ignore[reportUnnecessaryIsInstance] + + if expand: + query_params.expand = expand if isinstance(expand, list) else [expand] # type: ignore[reportUnnecessaryIsInstance] + + + config: Any = OrganizationRequestBuilder.OrganizationRequestBuilderGetRequestConfiguration() # type: ignore[reportUnknownVariableType] + config.query_parameters = query_params # type: ignore[reportUnknownMemberType] + + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.organization.by_organization_id(organization_id).certificate_based_auth_configuration.by_certificate_based_auth_configuration_id(certificateBasedAuthConfiguration_id).get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + # ========================================================================== + # CROSS-TENANT / MULTI-TENANT OPERATIONS (moved from UsersGroupsDataSource) + # ========================================================================== + + async def policies_cross_tenant_access_policy_templates_delete_multi_tenant_organization_partner_configuration( + self, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Delete multiTenantOrganizationPartnerConfiguration for policies. + Entra ID operation: DELETE /policies/crossTenantAccessPolicy/templates/multiTenantOrganizationPartnerConfiguration + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.policies.cross_tenant_access_policy.templates.multi_tenant_organization_partner_configuration.delete(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def tenant_relationships_get_multi_tenant_organization( + self, + select: Optional[list[str]] = None, + expand: Optional[list[str]] = None, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Get multiTenantOrganization. + Entra ID operation: GET /tenantRelationships/multiTenantOrganization + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.tenant_relationships.multi_tenant_organization.get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def tenant_relationships_update_multi_tenant_organization( + self, + request_body: Optional[Mapping[str, Any]] = None, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Update multiTenantOrganization. + Entra ID operation: PATCH /tenantRelationships/multiTenantOrganization + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.tenant_relationships.multi_tenant_organization.patch(body=request_body, request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def tenant_relationships_multi_tenant_organization_get_join_request( + self, + select: Optional[list[str]] = None, + expand: Optional[list[str]] = None, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Get multiTenantOrganizationJoinRequestRecord. + Entra ID operation: GET /tenantRelationships/multiTenantOrganization/joinRequest + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.tenant_relationships.multi_tenant_organization.join_request.get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def tenant_relationships_multi_tenant_organization_list_tenants( + self, + select: Optional[list[str]] = None, + expand: Optional[list[str]] = None, + filter: Optional[str] = None, + orderby: Optional[str] = None, + search: Optional[str] = None, + top: Optional[int] = None, + skip: Optional[int] = None, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """List multiTenantOrganizationMembers. + Entra ID operation: GET /tenantRelationships/multiTenantOrganization/tenants + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + if search: + if not config.headers: # type: ignore[reportUnknownMemberType] + + config.headers = {} # type: ignore[reportUnknownMemberType] + + config.headers['ConsistencyLevel'] = 'eventual' # type: ignore[reportUnknownMemberType] + + + response = await self.client.tenant_relationships.multi_tenant_organization.tenants.get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def tenant_relationships_multi_tenant_organization_delete_tenants( + self, + multiTenantOrganizationMember_id: str, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Remove multiTenantOrganizationMember. + Entra ID operation: DELETE /tenantRelationships/multiTenantOrganization/tenants/{id} + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.tenant_relationships.multi_tenant_organization.tenants.by_tenant_id(multiTenantOrganizationMember_id).delete(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def tenant_relationships_multi_tenant_organization_get_tenants( + self, + multiTenantOrganizationMember_id: str, + select: Optional[list[str]] = None, + expand: Optional[list[str]] = None, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Get multiTenantOrganizationMember. + Entra ID operation: GET /tenantRelationships/multiTenantOrganization/tenants/{id} + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.tenant_relationships.multi_tenant_organization.tenants.by_tenant_id(multiTenantOrganizationMember_id).get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def tenant_relationships_multi_tenant_organization_update_tenants( + self, + multiTenantOrganizationMember_id: str, + request_body: Optional[Mapping[str, Any]] = None, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Update tenant in tenantRelationships. + Entra ID operation: PATCH /tenantRelationships/multiTenantOrganization/tenants/{id} + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.tenant_relationships.multi_tenant_organization.tenants.by_tenant_id(multiTenantOrganizationMember_id).patch(body=request_body, request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + # ========================================================================== + # GROUP LIFECYCLE POLICIES (moved from UsersGroupsDataSource) + # ========================================================================== + + async def groups_create_group_lifecycle_policies( + self, + group_id: str, + request_body: Optional[Mapping[str, Any]] = None, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Create groupLifecyclePolicy for a group. + Entra ID operation: POST /groups/{group-id}/groupLifecyclePolicies + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.groups.by_group_id(group_id).group_lifecycle_policies.post(body=request_body, request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def groups_list_group_lifecycle_policies( + self, + group_id: str, + select: Optional[list[str]] = None, + expand: Optional[list[str]] = None, + filter: Optional[str] = None, + orderby: Optional[str] = None, + search: Optional[str] = None, + top: Optional[int] = None, + skip: Optional[int] = None, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """List groupLifecyclePolicies for a group. + Entra ID operation: GET /groups/{group-id}/groupLifecyclePolicies + """ + try: + query_params: Any = GroupsRequestBuilder.GroupsRequestBuilderGetQueryParameters() # type: ignore[reportUnknownVariableType, reportUnknownMemberType] + if select: + query_params.select = select if isinstance(select, list) else [select] # type: ignore[reportUnnecessaryIsInstance] + + if expand: + query_params.expand = expand if isinstance(expand, list) else [expand] # type: ignore[reportUnnecessaryIsInstance] + + if filter: + query_params.filter = filter # type: ignore[reportUnknownMemberType] + + + config: Any = GroupsRequestBuilder.GroupsRequestBuilderGetRequestConfiguration() # type: ignore[reportUnknownVariableType] + config.query_parameters = query_params # type: ignore[reportUnknownMemberType] + + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + if search: + if not config.headers: # type: ignore[reportUnknownMemberType] + + config.headers = {} # type: ignore[reportUnknownMemberType] + + config.headers['ConsistencyLevel'] = 'eventual' # type: ignore[reportUnknownMemberType] + + + response = await self.client.groups.by_group_id(group_id).group_lifecycle_policies.get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def groups_delete_group_lifecycle_policies( + self, + group_id: str, + groupLifecyclePolicy_id: str, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Delete groupLifecyclePolicy. + Entra ID operation: DELETE /groups/{group-id}/groupLifecyclePolicies/{id} + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.groups.by_group_id(group_id).group_lifecycle_policies.by_groupLifecyclePolicie_id(groupLifecyclePolicy_id).delete(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def groups_get_group_lifecycle_policies( + self, + group_id: str, + groupLifecyclePolicy_id: str, + select: Optional[list[str]] = None, + expand: Optional[list[str]] = None, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Get groupLifecyclePolicy. + Entra ID operation: GET /groups/{group-id}/groupLifecyclePolicies/{id} + """ + try: + query_params: Any = GroupsRequestBuilder.GroupsRequestBuilderGetQueryParameters() # type: ignore[reportUnknownVariableType, reportUnknownMemberType] + if select: + query_params.select = select if isinstance(select, list) else [select] # type: ignore[reportUnnecessaryIsInstance] + + if expand: + query_params.expand = expand if isinstance(expand, list) else [expand] # type: ignore[reportUnnecessaryIsInstance] + + + config: Any = GroupsRequestBuilder.GroupsRequestBuilderGetRequestConfiguration() # type: ignore[reportUnknownVariableType] + config.query_parameters = query_params # type: ignore[reportUnknownMemberType] + + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.groups.by_group_id(group_id).group_lifecycle_policies.by_groupLifecyclePolicie_id(groupLifecyclePolicy_id).get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def groups_update_group_lifecycle_policies( + self, + group_id: str, + groupLifecyclePolicy_id: str, + request_body: Optional[Mapping[str, Any]] = None, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Update groupLifecyclePolicy. + Entra ID operation: PATCH /groups/{group-id}/groupLifecyclePolicies/{id} + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.groups.by_group_id(group_id).group_lifecycle_policies.by_groupLifecyclePolicie_id(groupLifecyclePolicy_id).patch(body=request_body, request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def groups_group_group_lifecycle_policies_group_lifecycle_policy_add_group( + self, + group_id: str, + groupLifecyclePolicy_id: str, + request_body: Optional[Mapping[str, Any]] = None, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Invoke action addGroup on a lifecycle policy. + Entra ID operation: POST /groups/{group-id}/groupLifecyclePolicies/{id}/addGroup + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.groups.by_group_id(group_id).group_lifecycle_policies.by_groupLifecyclePolicie_id(groupLifecyclePolicy_id).add_group.post(body=request_body, request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + async def groups_group_group_lifecycle_policies_group_lifecycle_policy_remove_group( + self, + group_id: str, + groupLifecyclePolicy_id: str, + request_body: Optional[Mapping[str, Any]] = None, + headers: Optional[dict[str, str]] = None, + **kwargs: Any + ) -> EntraIDResponse: + """Invoke action removeGroup on a lifecycle policy. + Entra ID operation: POST /groups/{group-id}/groupLifecyclePolicies/{id}/removeGroup + """ + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + + response = await self.client.groups.by_group_id(group_id).group_lifecycle_policies.by_groupLifecyclePolicie_id(groupLifecyclePolicy_id).remove_group.post(body=request_body, request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse( + success=False, + error=f"Entra ID API call failed: {str(e)}", + ) + + # ========================================================================== + # DOMAINS OPERATIONS (moved from UsersGroupsDataSource) + # ========================================================================== + + async def domain_dns_records_create(self, request_body: Optional[Mapping[str, Any]] = None, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """Add new entity to domainDnsRecords. Entra ID operation: POST /domainDnsRecords""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.domain_dns_records.post(body=request_body, request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + async def domain_dns_records_list(self, select: Optional[list[str]] = None, expand: Optional[list[str]] = None, filter: Optional[str] = None, top: Optional[int] = None, skip: Optional[int] = None, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """Get entities from domainDnsRecords. Entra ID operation: GET /domainDnsRecords""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.domain_dns_records.get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + async def domain_dns_records_delete(self, domainDnsRecord_id: str, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """Delete entity from domainDnsRecords. Entra ID operation: DELETE /domainDnsRecords/{id}""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.domain_dns_records.by_domainDnsRecord_id(domainDnsRecord_id).delete(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + async def domain_dns_records_get(self, domainDnsRecord_id: str, select: Optional[list[str]] = None, expand: Optional[list[str]] = None, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """Get entity from domainDnsRecords by key. Entra ID operation: GET /domainDnsRecords/{id}""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.domain_dns_records.by_domainDnsRecord_id(domainDnsRecord_id).get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + async def domain_dns_records_update(self, domainDnsRecord_id: str, request_body: Optional[Mapping[str, Any]] = None, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """Update entity in domainDnsRecords. Entra ID operation: PATCH /domainDnsRecords/{id}""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.domain_dns_records.by_domainDnsRecord_id(domainDnsRecord_id).patch(body=request_body, request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + async def domains_create(self, request_body: Optional[Mapping[str, Any]] = None, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """Create domain. Entra ID operation: POST /domains""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.domains.post(body=request_body, request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + async def domains_list(self, select: Optional[list[str]] = None, expand: Optional[list[str]] = None, filter: Optional[str] = None, top: Optional[int] = None, skip: Optional[int] = None, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """List domains. Entra ID operation: GET /domains""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.domains.get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + async def domains_delete(self, domain_id: str, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """Delete domain. Entra ID operation: DELETE /domains/{domain-id}""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.domains.by_domain_id(domain_id).delete(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + async def domains_get(self, domain_id: str, select: Optional[list[str]] = None, expand: Optional[list[str]] = None, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """Get domain. Entra ID operation: GET /domains/{domain-id}""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.domains.by_domain_id(domain_id).get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + async def domains_update(self, domain_id: str, request_body: Optional[Mapping[str, Any]] = None, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """Update domain. Entra ID operation: PATCH /domains/{domain-id}""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.domains.by_domain_id(domain_id).patch(body=request_body, request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + async def domains_create_federation_configuration(self, domain_id: str, request_body: Optional[Mapping[str, Any]] = None, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """Create federation configuration. Entra ID operation: POST /domains/{domain-id}/federationConfiguration""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.domains.by_domain_id(domain_id).federation_configuration.post(body=request_body, request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + async def domains_list_federation_configuration(self, domain_id: str, select: Optional[list[str]] = None, expand: Optional[list[str]] = None, filter: Optional[str] = None, top: Optional[int] = None, skip: Optional[int] = None, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """List federation configurations. Entra ID operation: GET /domains/{domain-id}/federationConfiguration""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.domains.by_domain_id(domain_id).federation_configuration.get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + async def domains_delete_federation_configuration(self, domain_id: str, internalDomainFederation_id: str, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """Delete federation configuration. Entra ID operation: DELETE /domains/{domain-id}/federationConfiguration/{id}""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.domains.by_domain_id(domain_id).federation_configuration.by_internal_domain_federation_id(internalDomainFederation_id).delete(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + async def domains_get_federation_configuration(self, domain_id: str, internalDomainFederation_id: str, select: Optional[list[str]] = None, expand: Optional[list[str]] = None, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """Get federation configuration. Entra ID operation: GET /domains/{domain-id}/federationConfiguration/{id}""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.domains.by_domain_id(domain_id).federation_configuration.by_internal_domain_federation_id(internalDomainFederation_id).get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + async def domains_update_federation_configuration(self, domain_id: str, internalDomainFederation_id: str, request_body: Optional[Mapping[str, Any]] = None, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """Update federation configuration. Entra ID operation: PATCH /domains/{domain-id}/federationConfiguration/{id}""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.domains.by_domain_id(domain_id).federation_configuration.by_internal_domain_federation_id(internalDomainFederation_id).patch(body=request_body, request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + async def domains_force_delete(self, domain_id: str, request_body: Optional[Mapping[str, Any]] = None, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """Force delete domain. Entra ID operation: POST /domains/{domain-id}/forceDelete""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.domains.by_domain_id(domain_id).force_delete.post(body=request_body, request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + async def domains_promote(self, domain_id: str, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """Promote domain. Entra ID operation: POST /domains/{domain-id}/promote""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.domains.by_domain_id(domain_id).promote.post(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + async def domains_get_root_domain(self, domain_id: str, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """Get root domain. Entra ID operation: GET /domains/{domain-id}/rootDomain""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.domains.by_domain_id(domain_id).root_domain.get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + async def domains_create_service_configuration_records(self, domain_id: str, request_body: Optional[Mapping[str, Any]] = None, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """Create service configuration record. Entra ID operation: POST /domains/{domain-id}/serviceConfigurationRecords""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.domains.by_domain_id(domain_id).service_configuration_records.post(body=request_body, request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + async def domains_list_service_configuration_records(self, domain_id: str, select: Optional[list[str]] = None, expand: Optional[list[str]] = None, filter: Optional[str] = None, top: Optional[int] = None, skip: Optional[int] = None, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """List service configuration records. Entra ID operation: GET /domains/{domain-id}/serviceConfigurationRecords""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.domains.by_domain_id(domain_id).service_configuration_records.get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + async def domains_delete_service_configuration_records(self, domain_id: str, domainDnsRecord_id: str, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """Delete service configuration record. Entra ID operation: DELETE /domains/{domain-id}/serviceConfigurationRecords/{id}""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.domains.by_domain_id(domain_id).service_configuration_records.by_domain_dns_record_id(domainDnsRecord_id).delete(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + async def domains_get_service_configuration_records(self, domain_id: str, domainDnsRecord_id: str, select: Optional[list[str]] = None, expand: Optional[list[str]] = None, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """Get service configuration record. Entra ID operation: GET /domains/{domain-id}/serviceConfigurationRecords/{id}""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.domains.by_domain_id(domain_id).service_configuration_records.by_domain_dns_record_id(domainDnsRecord_id).get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + async def domains_update_service_configuration_records(self, domain_id: str, domainDnsRecord_id: str, request_body: Optional[Mapping[str, Any]] = None, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """Update service configuration record. Entra ID operation: PATCH /domains/{domain-id}/serviceConfigurationRecords/{id}""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.domains.by_domain_id(domain_id).service_configuration_records.by_domain_dns_record_id(domainDnsRecord_id).patch(body=request_body, request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + async def domains_create_verification_dns_records(self, domain_id: str, request_body: Optional[Mapping[str, Any]] = None, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """Create verification DNS record. Entra ID operation: POST /domains/{domain-id}/verificationDnsRecords""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.domains.by_domain_id(domain_id).verification_dns_records.post(body=request_body, request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + async def domains_list_verification_dns_records(self, domain_id: str, select: Optional[list[str]] = None, expand: Optional[list[str]] = None, filter: Optional[str] = None, top: Optional[int] = None, skip: Optional[int] = None, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """List verification DNS records. Entra ID operation: GET /domains/{domain-id}/verificationDnsRecords""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.domains.by_domain_id(domain_id).verification_dns_records.get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + async def domains_delete_verification_dns_records(self, domain_id: str, domainDnsRecord_id: str, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """Delete verification DNS record. Entra ID operation: DELETE /domains/{domain-id}/verificationDnsRecords/{id}""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.domains.by_domain_id(domain_id).verification_dns_records.by_domain_dns_record_id(domainDnsRecord_id).delete(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + async def domains_get_verification_dns_records(self, domain_id: str, domainDnsRecord_id: str, select: Optional[list[str]] = None, expand: Optional[list[str]] = None, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """Get verification DNS record. Entra ID operation: GET /domains/{domain-id}/verificationDnsRecords/{id}""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.domains.by_domain_id(domain_id).verification_dns_records.by_domain_dns_record_id(domainDnsRecord_id).get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + async def domains_update_verification_dns_records(self, domain_id: str, domainDnsRecord_id: str, request_body: Optional[Mapping[str, Any]] = None, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """Update verification DNS record. Entra ID operation: PATCH /domains/{domain-id}/verificationDnsRecords/{id}""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.domains.by_domain_id(domain_id).verification_dns_records.by_domain_dns_record_id(domainDnsRecord_id).patch(body=request_body, request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + async def domains_verify(self, domain_id: str, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """Verify domain. Entra ID operation: POST /domains/{domain-id}/verify""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.domains.by_domain_id(domain_id).verify.post(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + # ========================================================================== + # SUBSCRIPTIONS OPERATIONS (moved from UsersGroupsDataSource) + # ========================================================================== + + async def subscriptions_create(self, request_body: Optional[Mapping[str, Any]] = None, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """Create subscription. Entra ID operation: POST /subscriptions""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.subscriptions.post(body=request_body, request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + async def subscriptions_list(self, select: Optional[list[str]] = None, expand: Optional[list[str]] = None, filter: Optional[str] = None, top: Optional[int] = None, skip: Optional[int] = None, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """List subscriptions. Entra ID operation: GET /subscriptions""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.subscriptions.get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + async def subscriptions_delete(self, subscription_id: str, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """Delete subscription. Entra ID operation: DELETE /subscriptions/{subscription-id}""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.subscriptions.by_subscription_id(subscription_id).delete(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + async def subscriptions_get(self, subscription_id: str, select: Optional[list[str]] = None, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """Get subscription. Entra ID operation: GET /subscriptions/{subscription-id}""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.subscriptions.by_subscription_id(subscription_id).get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + async def subscriptions_update(self, subscription_id: str, request_body: Optional[Mapping[str, Any]] = None, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """Update subscription. Entra ID operation: PATCH /subscriptions/{subscription-id}""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.subscriptions.by_subscription_id(subscription_id).patch(body=request_body, request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + async def subscriptions_reauthorize(self, subscription_id: str, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """Reauthorize subscription. Entra ID operation: POST /subscriptions/{subscription-id}/reauthorize""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.subscriptions.by_subscription_id(subscription_id).reauthorize.post(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + # ========================================================================== + # NEW: SERVICE PRINCIPALS + # ========================================================================== + + async def list_service_principals(self, select: Optional[list[str]] = None, filter: Optional[str] = None, top: Optional[int] = None, search: Optional[str] = None, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """List service principals. Entra ID operation: GET /servicePrincipals""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + if search: + if not config.headers: # type: ignore[reportUnknownMemberType] + + config.headers = {} # type: ignore[reportUnknownMemberType] + + config.headers['ConsistencyLevel'] = 'eventual' # type: ignore[reportUnknownMemberType] + + response = await self.client.service_principals.get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + async def get_service_principal(self, service_principal_id: str, select: Optional[list[str]] = None, expand: Optional[list[str]] = None, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """Get service principal. Entra ID operation: GET /servicePrincipals/{id}""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.service_principals.by_service_principal_id(service_principal_id).get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + async def create_service_principal(self, request_body: Optional[Mapping[str, Any]] = None, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """Create service principal. Entra ID operation: POST /servicePrincipals""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.service_principals.post(body=request_body, request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + async def delete_service_principal(self, service_principal_id: str, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """Delete service principal. Entra ID operation: DELETE /servicePrincipals/{id}""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.service_principals.by_service_principal_id(service_principal_id).delete(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + # ========================================================================== + # NEW: APPLICATIONS (App Registrations) + # ========================================================================== + + async def list_applications(self, select: Optional[list[str]] = None, filter: Optional[str] = None, top: Optional[int] = None, search: Optional[str] = None, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """List applications. Entra ID operation: GET /applications""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + if search: + if not config.headers: # type: ignore[reportUnknownMemberType] + + config.headers = {} # type: ignore[reportUnknownMemberType] + + config.headers['ConsistencyLevel'] = 'eventual' # type: ignore[reportUnknownMemberType] + + response = await self.client.applications.get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + async def get_application(self, application_id: str, select: Optional[list[str]] = None, expand: Optional[list[str]] = None, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """Get application. Entra ID operation: GET /applications/{id}""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.applications.by_application_id(application_id).get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + async def create_application(self, request_body: Optional[Mapping[str, Any]] = None, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """Create application. Entra ID operation: POST /applications""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.applications.post(body=request_body, request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + async def update_application(self, application_id: str, request_body: Optional[Mapping[str, Any]] = None, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """Update application. Entra ID operation: PATCH /applications/{id}""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.applications.by_application_id(application_id).patch(body=request_body, request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + async def delete_application(self, application_id: str, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """Delete application. Entra ID operation: DELETE /applications/{id}""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.applications.by_application_id(application_id).delete(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + # ========================================================================== + # NEW: DIRECTORY ROLES + # ========================================================================== + + async def list_directory_roles(self, select: Optional[list[str]] = None, expand: Optional[list[str]] = None, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """List directory roles. Entra ID operation: GET /directoryRoles""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.directory_roles.get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + async def get_directory_role(self, directory_role_id: str, select: Optional[list[str]] = None, expand: Optional[list[str]] = None, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """Get directory role. Entra ID operation: GET /directoryRoles/{id}""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.directory_roles.by_directory_role_id(directory_role_id).get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + async def list_directory_role_members(self, directory_role_id: str, select: Optional[list[str]] = None, filter: Optional[str] = None, top: Optional[int] = None, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """List directory role members. Entra ID operation: GET /directoryRoles/{id}/members""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.directory_roles.by_directory_role_id(directory_role_id).members.get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + # ========================================================================== + # NEW: AUDIT LOGS + # ========================================================================== + + async def list_sign_in_logs(self, select: Optional[list[str]] = None, filter: Optional[str] = None, top: Optional[int] = None, orderby: Optional[str] = None, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """List sign-in logs. Entra ID operation: GET /auditLogs/signIns""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.audit_logs.sign_ins.get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + async def list_directory_audit_logs(self, select: Optional[list[str]] = None, filter: Optional[str] = None, top: Optional[int] = None, orderby: Optional[str] = None, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """List directory audit logs. Entra ID operation: GET /auditLogs/directoryAudits""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.audit_logs.directory_audits.get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + # ========================================================================== + # NEW: CONDITIONAL ACCESS POLICIES + # ========================================================================== + + async def list_conditional_access_policies(self, select: Optional[list[str]] = None, filter: Optional[str] = None, top: Optional[int] = None, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """List conditional access policies. Entra ID operation: GET /identity/conditionalAccess/policies""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.identity.conditional_access.policies.get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + async def get_conditional_access_policy(self, policy_id: str, select: Optional[list[str]] = None, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """Get conditional access policy. Entra ID operation: GET /identity/conditionalAccess/policies/{id}""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.identity.conditional_access.policies.by_conditional_access_policy_id(policy_id).get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + # ========================================================================== + # NEW: IDENTITY PROVIDERS + # ========================================================================== + + async def list_identity_providers(self, select: Optional[list[str]] = None, filter: Optional[str] = None, top: Optional[int] = None, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """List identity providers. Entra ID operation: GET /identity/identityProviders""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.identity.identity_providers.get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + async def get_identity_provider(self, identity_provider_id: str, select: Optional[list[str]] = None, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """Get identity provider. Entra ID operation: GET /identity/identityProviders/{id}""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.identity.identity_providers.by_identity_provider_base_id(identity_provider_id).get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + # ========================================================================== + # NEW: ADMINISTRATIVE UNITS + # ========================================================================== + + async def list_administrative_units(self, select: Optional[list[str]] = None, filter: Optional[str] = None, top: Optional[int] = None, search: Optional[str] = None, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """List administrative units. Entra ID operation: GET /directory/administrativeUnits""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + if search: + if not config.headers: # type: ignore[reportUnknownMemberType] + + config.headers = {} # type: ignore[reportUnknownMemberType] + + config.headers['ConsistencyLevel'] = 'eventual' # type: ignore[reportUnknownMemberType] + + response = await self.client.directory.administrative_units.get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + async def get_administrative_unit(self, administrative_unit_id: str, select: Optional[list[str]] = None, expand: Optional[list[str]] = None, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """Get administrative unit. Entra ID operation: GET /directory/administrativeUnits/{id}""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.directory.administrative_units.by_administrative_unit_id(administrative_unit_id).get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + async def list_administrative_unit_members(self, administrative_unit_id: str, select: Optional[list[str]] = None, filter: Optional[str] = None, top: Optional[int] = None, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """List administrative unit members. Entra ID operation: GET /directory/administrativeUnits/{id}/members""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.directory.administrative_units.by_administrative_unit_id(administrative_unit_id).members.get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") + + # ========================================================================== + # NEW: USER AUTHENTICATION METHODS + # ========================================================================== + + async def list_user_authentication_methods(self, user_id: str, select: Optional[list[str]] = None, headers: Optional[dict[str, str]] = None, **kwargs: Any) -> EntraIDResponse: + """List user authentication methods. Entra ID operation: GET /users/{user-id}/authentication/methods""" + try: + config: Any = RequestConfiguration() # type: ignore[reportUnknownVariableType] + if headers: + config.headers = headers # type: ignore[reportUnknownMemberType] + + response = await self.client.users.by_user_id(user_id).authentication.methods.get(request_configuration=config) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + + return self._handle_entra_id_response(response) + except Exception as e: + return EntraIDResponse(success=False, error=f"Entra ID API call failed: {str(e)}") diff --git a/backend/python/app/sources/external/microsoft/entraid/example.py b/backend/python/app/sources/external/microsoft/entraid/example.py new file mode 100644 index 000000000..3c4ddb10f --- /dev/null +++ b/backend/python/app/sources/external/microsoft/entraid/example.py @@ -0,0 +1,77 @@ +# ruff: noqa +import asyncio +import os + +from app.sources.client.microsoft.microsoft import GraphMode, MSGraphClient, MSGraphClientWithClientIdSecretConfig +from app.sources.external.microsoft.entraid.entraid import EntraIDDataSource, EntraIDResponse + + +async def main(): + tenant_id = os.getenv("ENTRAID_CLIENT_TENANT_ID") + client_id = os.getenv("ENTRAID_CLIENT_ID") + client_secret = os.getenv("ENTRAID_CLIENT_SECRET") + if not tenant_id or not client_id or not client_secret: + raise Exception("ENTRAID_CLIENT_TENANT_ID, ENTRAID_CLIENT_ID, and ENTRAID_CLIENT_SECRET must be set") + + # Build a client with app-only (client credentials) auth + client: MSGraphClient = MSGraphClient.build_with_config( + MSGraphClientWithClientIdSecretConfig(client_id, client_secret, tenant_id), + mode=GraphMode.APP) + print(client) + print("****************************") + + entra_id_ds: EntraIDDataSource = EntraIDDataSource(client) + print("entra_id_ds:", entra_id_ds) + print("****************************") + + # List domains + print("Listing domains...") + response: EntraIDResponse = await entra_id_ds.domains_list() + print("Success:", response.success) + print("Data:", response.data) + print("Error:", response.error) + print("****************************") + + # List service principals + print("Listing service principals...") + response = await entra_id_ds.list_service_principals() + print("Success:", response.success) + print("Data:", response.data) + print("Error:", response.error) + print("****************************") + + # List applications + print("Listing applications...") + response = await entra_id_ds.list_applications() + print("Success:", response.success) + print("Data:", response.data) + print("Error:", response.error) + print("****************************") + + # List directory roles + print("Listing directory roles...") + response = await entra_id_ds.list_directory_roles() + print("Success:", response.success) + print("Data:", response.data) + print("Error:", response.error) + print("****************************") + + # List conditional access policies + print("Listing conditional access policies...") + response = await entra_id_ds.list_conditional_access_policies() + print("Success:", response.success) + print("Data:", response.data) + print("Error:", response.error) + print("****************************") + + # List subscriptions + print("Listing subscriptions...") + response = await entra_id_ds.subscriptions_list() + print("Success:", response.success) + print("Data:", response.data) + print("Error:", response.error) + print("****************************") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/microsoft/users_groups/users_groups.py b/backend/python/app/sources/external/microsoft/users_groups/users_groups.py index 47782180b..28929bfd1 100644 --- a/backend/python/app/sources/external/microsoft/users_groups/users_groups.py +++ b/backend/python/app/sources/external/microsoft/users_groups/users_groups.py @@ -8618,9 +8618,12 @@ async def groups_group_check_member_objects( error=f"Users Groups API call failed: {str(e)}", ) - async def groups_create_group_lifecycle_policies( + + async def groups_delete_ref_members( self, group_id: str, + at_id: str, + If_Match: Optional[str] = None, select: Optional[List[str]] = None, expand: Optional[List[str]] = None, filter: Optional[str] = None, @@ -8628,15 +8631,16 @@ async def groups_create_group_lifecycle_policies( search: Optional[str] = None, top: Optional[int] = None, skip: Optional[int] = None, - request_body: Optional[Mapping[str, Any]] = None, headers: Optional[Dict[str, str]] = None, **kwargs ) -> UsersGroupsResponse: - """Create new navigation property to groupLifecyclePolicies for groups. - Users Groups operation: POST /groups/{group-id}/groupLifecyclePolicies + """Remove member. + Users Groups operation: DELETE /groups/{group-id}/members/$ref Operation type: groups Args: group_id (str, required): Users Groups group id identifier + If_Match (str, optional): ETag + at_id (str, required): The delete Uri select (optional): Select specific properties to return expand (optional): Expand related entities (e.g., manager, memberOf, directReports) filter (optional): Filter the results using OData syntax @@ -8644,7 +8648,6 @@ async def groups_create_group_lifecycle_policies( search (optional): Search for users, groups, or directory objects by content top (optional): Limit number of results returned skip (optional): Skip number of results for pagination - request_body (optional): Request body data for Users Groups operations headers (optional): Additional headers for the request **kwargs: Additional query parameters Returns: @@ -8684,7 +8687,7 @@ async def groups_create_group_lifecycle_policies( config.headers = {} config.headers['ConsistencyLevel'] = 'eventual' - response = await self.client.groups.by_group_id(group_id).group_lifecycle_policies.post(body=request_body, request_configuration=config) + response = await self.client.groups.by_group_id(group_id).members.ref.delete(request_configuration=config) return self._handle_users_groups_response(response) except Exception as e: return UsersGroupsResponse( @@ -8692,9 +8695,10 @@ async def groups_create_group_lifecycle_policies( error=f"Users Groups API call failed: {str(e)}", ) - async def groups_list_group_lifecycle_policies( + async def groups_list_members_with_license_errors( self, group_id: str, + ConsistencyLevel: Optional[str] = None, dollar_orderby: Optional[List[str]] = None, dollar_select: Optional[List[str]] = None, dollar_expand: Optional[List[str]] = None, @@ -8708,11 +8712,12 @@ async def groups_list_group_lifecycle_policies( headers: Optional[Dict[str, str]] = None, **kwargs ) -> UsersGroupsResponse: - """List groupLifecyclePolicies. - Users Groups operation: GET /groups/{group-id}/groupLifecyclePolicies + """Get membersWithLicenseErrors from groups. + Users Groups operation: GET /groups/{group-id}/membersWithLicenseErrors Operation type: groups Args: group_id (str, required): Users Groups group id identifier + ConsistencyLevel (str, optional): Indicates the requested consistency level. Documentation URL: https://docs.microsoft.com/graph/aad-advanced-queries dollar_orderby (List[str], optional): Order items by property values dollar_select (List[str], optional): Select properties to be returned dollar_expand (List[str], optional): Expand related entities @@ -8762,83 +8767,7 @@ async def groups_list_group_lifecycle_policies( config.headers = {} config.headers['ConsistencyLevel'] = 'eventual' - response = await self.client.groups.by_group_id(group_id).group_lifecycle_policies.get(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def groups_delete_group_lifecycle_policies( - self, - group_id: str, - groupLifecyclePolicy_id: str, - If_Match: Optional[str] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Delete navigation property groupLifecyclePolicies for groups. - Users Groups operation: DELETE /groups/{group-id}/groupLifecyclePolicies/{groupLifecyclePolicy-id} - Operation type: groups - Args: - group_id (str, required): Users Groups group id identifier - groupLifecyclePolicy_id (str, required): Users Groups groupLifecyclePolicy id identifier - If_Match (str, optional): ETag - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.groups.by_group_id(group_id).group_lifecycle_policies.by_groupLifecyclePolicie_id(groupLifecyclePolicy_id).delete(request_configuration=config) + response = await self.client.groups.by_group_id(group_id).members_with_license_errors.get(request_configuration=config) return self._handle_users_groups_response(response) except Exception as e: return UsersGroupsResponse( @@ -8846,10 +8775,11 @@ async def groups_delete_group_lifecycle_policies( error=f"Users Groups API call failed: {str(e)}", ) - async def groups_get_group_lifecycle_policies( + async def groups_list_owners( self, group_id: str, - groupLifecyclePolicy_id: str, + ConsistencyLevel: Optional[str] = None, + dollar_orderby: Optional[List[str]] = None, dollar_select: Optional[List[str]] = None, dollar_expand: Optional[List[str]] = None, select: Optional[List[str]] = None, @@ -8862,12 +8792,13 @@ async def groups_get_group_lifecycle_policies( headers: Optional[Dict[str, str]] = None, **kwargs ) -> UsersGroupsResponse: - """Get groupLifecyclePolicies from groups. - Users Groups operation: GET /groups/{group-id}/groupLifecyclePolicies/{groupLifecyclePolicy-id} + """List group owners. + Users Groups operation: GET /groups/{group-id}/owners Operation type: groups Args: group_id (str, required): Users Groups group id identifier - groupLifecyclePolicy_id (str, required): Users Groups groupLifecyclePolicy id identifier + ConsistencyLevel (str, optional): Indicates the requested consistency level. Documentation URL: https://docs.microsoft.com/graph/aad-advanced-queries + dollar_orderby (List[str], optional): Order items by property values dollar_select (List[str], optional): Select properties to be returned dollar_expand (List[str], optional): Expand related entities select (optional): Select specific properties to return @@ -8916,7 +8847,7 @@ async def groups_get_group_lifecycle_policies( config.headers = {} config.headers['ConsistencyLevel'] = 'eventual' - response = await self.client.groups.by_group_id(group_id).group_lifecycle_policies.by_groupLifecyclePolicie_id(groupLifecyclePolicy_id).get(request_configuration=config) + response = await self.client.groups.by_group_id(group_id).owners.get(request_configuration=config) return self._handle_users_groups_response(response) except Exception as e: return UsersGroupsResponse( @@ -8924,10 +8855,11 @@ async def groups_get_group_lifecycle_policies( error=f"Users Groups API call failed: {str(e)}", ) - async def groups_update_group_lifecycle_policies( + async def groups_list_ref_owners( self, group_id: str, - groupLifecyclePolicy_id: str, + ConsistencyLevel: Optional[str] = None, + dollar_orderby: Optional[List[str]] = None, select: Optional[List[str]] = None, expand: Optional[List[str]] = None, filter: Optional[str] = None, @@ -8935,16 +8867,16 @@ async def groups_update_group_lifecycle_policies( search: Optional[str] = None, top: Optional[int] = None, skip: Optional[int] = None, - request_body: Optional[Mapping[str, Any]] = None, headers: Optional[Dict[str, str]] = None, **kwargs ) -> UsersGroupsResponse: - """Update the navigation property groupLifecyclePolicies in groups. - Users Groups operation: PATCH /groups/{group-id}/groupLifecyclePolicies/{groupLifecyclePolicy-id} + """List group owners. + Users Groups operation: GET /groups/{group-id}/owners/$ref Operation type: groups Args: group_id (str, required): Users Groups group id identifier - groupLifecyclePolicy_id (str, required): Users Groups groupLifecyclePolicy id identifier + ConsistencyLevel (str, optional): Indicates the requested consistency level. Documentation URL: https://docs.microsoft.com/graph/aad-advanced-queries + dollar_orderby (List[str], optional): Order items by property values select (optional): Select specific properties to return expand (optional): Expand related entities (e.g., manager, memberOf, directReports) filter (optional): Filter the results using OData syntax @@ -8952,7 +8884,6 @@ async def groups_update_group_lifecycle_policies( search (optional): Search for users, groups, or directory objects by content top (optional): Limit number of results returned skip (optional): Skip number of results for pagination - request_body (optional): Request body data for Users Groups operations headers (optional): Additional headers for the request **kwargs: Additional query parameters Returns: @@ -8961,7 +8892,7 @@ async def groups_update_group_lifecycle_policies( # Build query parameters including OData for Users Groups try: # Use typed query parameters - query_params = RequestConfiguration() + query_params = GroupsRequestBuilder.GroupsRequestBuilderGetQueryParameters() # Set query parameters using typed object properties if select: @@ -8980,7 +8911,7 @@ async def groups_update_group_lifecycle_policies( query_params.skip = skip # Create proper typed request configuration - config = RequestConfiguration() + config = GroupsRequestBuilder.GroupsRequestBuilderGetRequestConfiguration() config.query_parameters = query_params if headers: @@ -8992,7 +8923,7 @@ async def groups_update_group_lifecycle_policies( config.headers = {} config.headers['ConsistencyLevel'] = 'eventual' - response = await self.client.groups.by_group_id(group_id).group_lifecycle_policies.by_groupLifecyclePolicie_id(groupLifecyclePolicy_id).patch(body=request_body, request_configuration=config) + response = await self.client.groups.by_group_id(group_id).owners.ref.get(request_configuration=config) return self._handle_users_groups_response(response) except Exception as e: return UsersGroupsResponse( @@ -9000,10 +8931,9 @@ async def groups_update_group_lifecycle_policies( error=f"Users Groups API call failed: {str(e)}", ) - async def groups_group_group_lifecycle_policies_group_lifecycle_policy_add_group( + async def groups_create_permission_grants( self, group_id: str, - groupLifecyclePolicy_id: str, select: Optional[List[str]] = None, expand: Optional[List[str]] = None, filter: Optional[str] = None, @@ -9015,12 +8945,11 @@ async def groups_group_group_lifecycle_policies_group_lifecycle_policy_add_group headers: Optional[Dict[str, str]] = None, **kwargs ) -> UsersGroupsResponse: - """Invoke action addGroup. - Users Groups operation: POST /groups/{group-id}/groupLifecyclePolicies/{groupLifecyclePolicy-id}/addGroup + """Create new navigation property to permissionGrants for groups. + Users Groups operation: POST /groups/{group-id}/permissionGrants Operation type: groups Args: group_id (str, required): Users Groups group id identifier - groupLifecyclePolicy_id (str, required): Users Groups groupLifecyclePolicy id identifier select (optional): Select specific properties to return expand (optional): Expand related entities (e.g., manager, memberOf, directReports) filter (optional): Filter the results using OData syntax @@ -9068,7 +8997,7 @@ async def groups_group_group_lifecycle_policies_group_lifecycle_policy_add_group config.headers = {} config.headers['ConsistencyLevel'] = 'eventual' - response = await self.client.groups.by_group_id(group_id).group_lifecycle_policies.by_groupLifecyclePolicie_id(groupLifecyclePolicy_id).add_group.post(body=request_body, request_configuration=config) + response = await self.client.groups.by_group_id(group_id).permission_grants.post(body=request_body, request_configuration=config) return self._handle_users_groups_response(response) except Exception as e: return UsersGroupsResponse( @@ -9076,10 +9005,12 @@ async def groups_group_group_lifecycle_policies_group_lifecycle_policy_add_group error=f"Users Groups API call failed: {str(e)}", ) - async def groups_group_group_lifecycle_policies_group_lifecycle_policy_remove_group( + async def groups_list_permission_grants( self, group_id: str, - groupLifecyclePolicy_id: str, + dollar_orderby: Optional[List[str]] = None, + dollar_select: Optional[List[str]] = None, + dollar_expand: Optional[List[str]] = None, select: Optional[List[str]] = None, expand: Optional[List[str]] = None, filter: Optional[str] = None, @@ -9087,16 +9018,17 @@ async def groups_group_group_lifecycle_policies_group_lifecycle_policy_remove_gr search: Optional[str] = None, top: Optional[int] = None, skip: Optional[int] = None, - request_body: Optional[Mapping[str, Any]] = None, headers: Optional[Dict[str, str]] = None, **kwargs ) -> UsersGroupsResponse: - """Invoke action removeGroup. - Users Groups operation: POST /groups/{group-id}/groupLifecyclePolicies/{groupLifecyclePolicy-id}/removeGroup + """List permissionGrants of a group. + Users Groups operation: GET /groups/{group-id}/permissionGrants Operation type: groups Args: group_id (str, required): Users Groups group id identifier - groupLifecyclePolicy_id (str, required): Users Groups groupLifecyclePolicy id identifier + dollar_orderby (List[str], optional): Order items by property values + dollar_select (List[str], optional): Select properties to be returned + dollar_expand (List[str], optional): Expand related entities select (optional): Select specific properties to return expand (optional): Expand related entities (e.g., manager, memberOf, directReports) filter (optional): Filter the results using OData syntax @@ -9104,7 +9036,6 @@ async def groups_group_group_lifecycle_policies_group_lifecycle_policy_remove_gr search (optional): Search for users, groups, or directory objects by content top (optional): Limit number of results returned skip (optional): Skip number of results for pagination - request_body (optional): Request body data for Users Groups operations headers (optional): Additional headers for the request **kwargs: Additional query parameters Returns: @@ -9113,7 +9044,7 @@ async def groups_group_group_lifecycle_policies_group_lifecycle_policy_remove_gr # Build query parameters including OData for Users Groups try: # Use typed query parameters - query_params = RequestConfiguration() + query_params = GroupsRequestBuilder.GroupsRequestBuilderGetQueryParameters() # Set query parameters using typed object properties if select: @@ -9132,7 +9063,7 @@ async def groups_group_group_lifecycle_policies_group_lifecycle_policy_remove_gr query_params.skip = skip # Create proper typed request configuration - config = RequestConfiguration() + config = GroupsRequestBuilder.GroupsRequestBuilderGetRequestConfiguration() config.query_parameters = query_params if headers: @@ -9144,7 +9075,7 @@ async def groups_group_group_lifecycle_policies_group_lifecycle_policy_remove_gr config.headers = {} config.headers['ConsistencyLevel'] = 'eventual' - response = await self.client.groups.by_group_id(group_id).group_lifecycle_policies.by_groupLifecyclePolicie_id(groupLifecyclePolicy_id).remove_group.post(body=request_body, request_configuration=config) + response = await self.client.groups.by_group_id(group_id).permission_grants.get(request_configuration=config) return self._handle_users_groups_response(response) except Exception as e: return UsersGroupsResponse( @@ -9152,10 +9083,10 @@ async def groups_group_group_lifecycle_policies_group_lifecycle_policy_remove_gr error=f"Users Groups API call failed: {str(e)}", ) - async def groups_delete_ref_members( + async def groups_delete_permission_grants( self, group_id: str, - at_id: str, + resourceSpecificPermissionGrant_id: str, If_Match: Optional[str] = None, select: Optional[List[str]] = None, expand: Optional[List[str]] = None, @@ -9167,13 +9098,13 @@ async def groups_delete_ref_members( headers: Optional[Dict[str, str]] = None, **kwargs ) -> UsersGroupsResponse: - """Remove member. - Users Groups operation: DELETE /groups/{group-id}/members/$ref + """Delete navigation property permissionGrants for groups. + Users Groups operation: DELETE /groups/{group-id}/permissionGrants/{resourceSpecificPermissionGrant-id} Operation type: groups Args: group_id (str, required): Users Groups group id identifier + resourceSpecificPermissionGrant_id (str, required): Users Groups resourceSpecificPermissionGrant id identifier If_Match (str, optional): ETag - at_id (str, required): The delete Uri select (optional): Select specific properties to return expand (optional): Expand related entities (e.g., manager, memberOf, directReports) filter (optional): Filter the results using OData syntax @@ -9220,7 +9151,7 @@ async def groups_delete_ref_members( config.headers = {} config.headers['ConsistencyLevel'] = 'eventual' - response = await self.client.groups.by_group_id(group_id).members.ref.delete(request_configuration=config) + response = await self.client.groups.by_group_id(group_id).permission_grants.by_permissionGrant_id(resourceSpecificPermissionGrant_id).delete(request_configuration=config) return self._handle_users_groups_response(response) except Exception as e: return UsersGroupsResponse( @@ -9228,11 +9159,10 @@ async def groups_delete_ref_members( error=f"Users Groups API call failed: {str(e)}", ) - async def groups_list_members_with_license_errors( + async def groups_get_permission_grants( self, group_id: str, - ConsistencyLevel: Optional[str] = None, - dollar_orderby: Optional[List[str]] = None, + resourceSpecificPermissionGrant_id: str, dollar_select: Optional[List[str]] = None, dollar_expand: Optional[List[str]] = None, select: Optional[List[str]] = None, @@ -9245,13 +9175,12 @@ async def groups_list_members_with_license_errors( headers: Optional[Dict[str, str]] = None, **kwargs ) -> UsersGroupsResponse: - """Get membersWithLicenseErrors from groups. - Users Groups operation: GET /groups/{group-id}/membersWithLicenseErrors + """Get permissionGrants from groups. + Users Groups operation: GET /groups/{group-id}/permissionGrants/{resourceSpecificPermissionGrant-id} Operation type: groups Args: group_id (str, required): Users Groups group id identifier - ConsistencyLevel (str, optional): Indicates the requested consistency level. Documentation URL: https://docs.microsoft.com/graph/aad-advanced-queries - dollar_orderby (List[str], optional): Order items by property values + resourceSpecificPermissionGrant_id (str, required): Users Groups resourceSpecificPermissionGrant id identifier dollar_select (List[str], optional): Select properties to be returned dollar_expand (List[str], optional): Expand related entities select (optional): Select specific properties to return @@ -9300,7 +9229,7 @@ async def groups_list_members_with_license_errors( config.headers = {} config.headers['ConsistencyLevel'] = 'eventual' - response = await self.client.groups.by_group_id(group_id).members_with_license_errors.get(request_configuration=config) + response = await self.client.groups.by_group_id(group_id).permission_grants.by_permissionGrant_id(resourceSpecificPermissionGrant_id).get(request_configuration=config) return self._handle_users_groups_response(response) except Exception as e: return UsersGroupsResponse( @@ -9308,13 +9237,10 @@ async def groups_list_members_with_license_errors( error=f"Users Groups API call failed: {str(e)}", ) - async def groups_list_owners( + async def groups_update_permission_grants( self, group_id: str, - ConsistencyLevel: Optional[str] = None, - dollar_orderby: Optional[List[str]] = None, - dollar_select: Optional[List[str]] = None, - dollar_expand: Optional[List[str]] = None, + resourceSpecificPermissionGrant_id: str, select: Optional[List[str]] = None, expand: Optional[List[str]] = None, filter: Optional[str] = None, @@ -9322,18 +9248,16 @@ async def groups_list_owners( search: Optional[str] = None, top: Optional[int] = None, skip: Optional[int] = None, + request_body: Optional[Mapping[str, Any]] = None, headers: Optional[Dict[str, str]] = None, **kwargs ) -> UsersGroupsResponse: - """List group owners. - Users Groups operation: GET /groups/{group-id}/owners + """Update the navigation property permissionGrants in groups. + Users Groups operation: PATCH /groups/{group-id}/permissionGrants/{resourceSpecificPermissionGrant-id} Operation type: groups Args: group_id (str, required): Users Groups group id identifier - ConsistencyLevel (str, optional): Indicates the requested consistency level. Documentation URL: https://docs.microsoft.com/graph/aad-advanced-queries - dollar_orderby (List[str], optional): Order items by property values - dollar_select (List[str], optional): Select properties to be returned - dollar_expand (List[str], optional): Expand related entities + resourceSpecificPermissionGrant_id (str, required): Users Groups resourceSpecificPermissionGrant id identifier select (optional): Select specific properties to return expand (optional): Expand related entities (e.g., manager, memberOf, directReports) filter (optional): Filter the results using OData syntax @@ -9341,6 +9265,7 @@ async def groups_list_owners( search (optional): Search for users, groups, or directory objects by content top (optional): Limit number of results returned skip (optional): Skip number of results for pagination + request_body (optional): Request body data for Users Groups operations headers (optional): Additional headers for the request **kwargs: Additional query parameters Returns: @@ -9349,7 +9274,7 @@ async def groups_list_owners( # Build query parameters including OData for Users Groups try: # Use typed query parameters - query_params = GroupsRequestBuilder.GroupsRequestBuilderGetQueryParameters() + query_params = RequestConfiguration() # Set query parameters using typed object properties if select: @@ -9368,7 +9293,7 @@ async def groups_list_owners( query_params.skip = skip # Create proper typed request configuration - config = GroupsRequestBuilder.GroupsRequestBuilderGetRequestConfiguration() + config = RequestConfiguration() config.query_parameters = query_params if headers: @@ -9380,7 +9305,7 @@ async def groups_list_owners( config.headers = {} config.headers['ConsistencyLevel'] = 'eventual' - response = await self.client.groups.by_group_id(group_id).owners.get(request_configuration=config) + response = await self.client.groups.by_group_id(group_id).permission_grants.by_permissionGrant_id(resourceSpecificPermissionGrant_id).patch(body=request_body, request_configuration=config) return self._handle_users_groups_response(response) except Exception as e: return UsersGroupsResponse( @@ -9388,11 +9313,11 @@ async def groups_list_owners( error=f"Users Groups API call failed: {str(e)}", ) - async def groups_list_ref_owners( + async def groups_delete_ref_rejected_senders( self, group_id: str, - ConsistencyLevel: Optional[str] = None, - dollar_orderby: Optional[List[str]] = None, + at_id: str, + If_Match: Optional[str] = None, select: Optional[List[str]] = None, expand: Optional[List[str]] = None, filter: Optional[str] = None, @@ -9403,13 +9328,13 @@ async def groups_list_ref_owners( headers: Optional[Dict[str, str]] = None, **kwargs ) -> UsersGroupsResponse: - """List group owners. - Users Groups operation: GET /groups/{group-id}/owners/$ref + """Remove rejectedSender. + Users Groups operation: DELETE /groups/{group-id}/rejectedSenders/$ref Operation type: groups Args: group_id (str, required): Users Groups group id identifier - ConsistencyLevel (str, optional): Indicates the requested consistency level. Documentation URL: https://docs.microsoft.com/graph/aad-advanced-queries - dollar_orderby (List[str], optional): Order items by property values + If_Match (str, optional): ETag + at_id (str, required): The delete Uri select (optional): Select specific properties to return expand (optional): Expand related entities (e.g., manager, memberOf, directReports) filter (optional): Filter the results using OData syntax @@ -9425,7 +9350,7 @@ async def groups_list_ref_owners( # Build query parameters including OData for Users Groups try: # Use typed query parameters - query_params = GroupsRequestBuilder.GroupsRequestBuilderGetQueryParameters() + query_params = RequestConfiguration() # Set query parameters using typed object properties if select: @@ -9444,7 +9369,7 @@ async def groups_list_ref_owners( query_params.skip = skip # Create proper typed request configuration - config = GroupsRequestBuilder.GroupsRequestBuilderGetRequestConfiguration() + config = RequestConfiguration() config.query_parameters = query_params if headers: @@ -9456,7 +9381,7 @@ async def groups_list_ref_owners( config.headers = {} config.headers['ConsistencyLevel'] = 'eventual' - response = await self.client.groups.by_group_id(group_id).owners.ref.get(request_configuration=config) + response = await self.client.groups.by_group_id(group_id).rejected_senders.ref.delete(request_configuration=config) return self._handle_users_groups_response(response) except Exception as e: return UsersGroupsResponse( @@ -9464,7712 +9389,9 @@ async def groups_list_ref_owners( error=f"Users Groups API call failed: {str(e)}", ) - async def groups_create_permission_grants( + async def groups_group_remove_favorite( self, - group_id: str, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - request_body: Optional[Mapping[str, Any]] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Create new navigation property to permissionGrants for groups. - Users Groups operation: POST /groups/{group-id}/permissionGrants - Operation type: groups - Args: - group_id (str, required): Users Groups group id identifier - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - request_body (optional): Request body data for Users Groups operations - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.groups.by_group_id(group_id).permission_grants.post(body=request_body, request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def groups_list_permission_grants( - self, - group_id: str, - dollar_orderby: Optional[List[str]] = None, - dollar_select: Optional[List[str]] = None, - dollar_expand: Optional[List[str]] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """List permissionGrants of a group. - Users Groups operation: GET /groups/{group-id}/permissionGrants - Operation type: groups - Args: - group_id (str, required): Users Groups group id identifier - dollar_orderby (List[str], optional): Order items by property values - dollar_select (List[str], optional): Select properties to be returned - dollar_expand (List[str], optional): Expand related entities - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = GroupsRequestBuilder.GroupsRequestBuilderGetQueryParameters() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = GroupsRequestBuilder.GroupsRequestBuilderGetRequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.groups.by_group_id(group_id).permission_grants.get(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def groups_delete_permission_grants( - self, - group_id: str, - resourceSpecificPermissionGrant_id: str, - If_Match: Optional[str] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Delete navigation property permissionGrants for groups. - Users Groups operation: DELETE /groups/{group-id}/permissionGrants/{resourceSpecificPermissionGrant-id} - Operation type: groups - Args: - group_id (str, required): Users Groups group id identifier - resourceSpecificPermissionGrant_id (str, required): Users Groups resourceSpecificPermissionGrant id identifier - If_Match (str, optional): ETag - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.groups.by_group_id(group_id).permission_grants.by_permissionGrant_id(resourceSpecificPermissionGrant_id).delete(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def groups_get_permission_grants( - self, - group_id: str, - resourceSpecificPermissionGrant_id: str, - dollar_select: Optional[List[str]] = None, - dollar_expand: Optional[List[str]] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Get permissionGrants from groups. - Users Groups operation: GET /groups/{group-id}/permissionGrants/{resourceSpecificPermissionGrant-id} - Operation type: groups - Args: - group_id (str, required): Users Groups group id identifier - resourceSpecificPermissionGrant_id (str, required): Users Groups resourceSpecificPermissionGrant id identifier - dollar_select (List[str], optional): Select properties to be returned - dollar_expand (List[str], optional): Expand related entities - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = GroupsRequestBuilder.GroupsRequestBuilderGetQueryParameters() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = GroupsRequestBuilder.GroupsRequestBuilderGetRequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.groups.by_group_id(group_id).permission_grants.by_permissionGrant_id(resourceSpecificPermissionGrant_id).get(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def groups_update_permission_grants( - self, - group_id: str, - resourceSpecificPermissionGrant_id: str, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - request_body: Optional[Mapping[str, Any]] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Update the navigation property permissionGrants in groups. - Users Groups operation: PATCH /groups/{group-id}/permissionGrants/{resourceSpecificPermissionGrant-id} - Operation type: groups - Args: - group_id (str, required): Users Groups group id identifier - resourceSpecificPermissionGrant_id (str, required): Users Groups resourceSpecificPermissionGrant id identifier - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - request_body (optional): Request body data for Users Groups operations - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.groups.by_group_id(group_id).permission_grants.by_permissionGrant_id(resourceSpecificPermissionGrant_id).patch(body=request_body, request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def groups_delete_ref_rejected_senders( - self, - group_id: str, - at_id: str, - If_Match: Optional[str] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Remove rejectedSender. - Users Groups operation: DELETE /groups/{group-id}/rejectedSenders/$ref - Operation type: groups - Args: - group_id (str, required): Users Groups group id identifier - If_Match (str, optional): ETag - at_id (str, required): The delete Uri - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.groups.by_group_id(group_id).rejected_senders.ref.delete(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def groups_group_remove_favorite( - self, - group_id: str, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Invoke action removeFavorite. - Users Groups operation: POST /groups/{group-id}/removeFavorite - Operation type: groups - Args: - group_id (str, required): Users Groups group id identifier - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.groups.by_group_id(group_id).remove_favorite.post(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def groups_group_retry_service_provisioning( - self, - group_id: str, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Invoke action retryServiceProvisioning. - Users Groups operation: POST /groups/{group-id}/retryServiceProvisioning - Operation type: groups - Args: - group_id (str, required): Users Groups group id identifier - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.groups.by_group_id(group_id).retry_service_provisioning.post(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def groups_list_service_provisioning_errors( - self, - group_id: str, - dollar_orderby: Optional[List[str]] = None, - dollar_select: Optional[List[str]] = None, - dollar_expand: Optional[List[str]] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Get serviceProvisioningErrors property value. - Users Groups operation: GET /groups/{group-id}/serviceProvisioningErrors - Operation type: groups - Args: - group_id (str, required): Users Groups group id identifier - dollar_orderby (List[str], optional): Order items by property values - dollar_select (List[str], optional): Select properties to be returned - dollar_expand (List[str], optional): Expand related entities - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = GroupsRequestBuilder.GroupsRequestBuilderGetQueryParameters() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = GroupsRequestBuilder.GroupsRequestBuilderGetRequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.groups.by_group_id(group_id).service_provisioning_errors.get(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def groups_list_transitive_members( - self, - group_id: str, - next_url: Optional[str] = None, - ConsistencyLevel: Optional[str] = None, - dollar_orderby: Optional[List[str]] = None, - dollar_select: Optional[List[str]] = None, - dollar_expand: Optional[List[str]] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """List group transitive members with pagination support. - Users Groups operation: GET /groups/{group-id}/transitiveMembers - Operation type: groups - Args: - group_id (str, required): Users Groups group id identifier - next_url (str, optional): Next link URL for pagination - select (optional): Select specific properties to return - filter (optional): Filter the results using OData syntax - top (optional): Limit number of results returned - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error and odata_next_link - """ - try: - if next_url: - # Use nextLink URL for pagination - response = await self.client.groups.by_group_id(group_id).transitive_members.with_url(next_url).get() - else: - # Build query parameters for initial request - query_params = GroupsRequestBuilder.GroupsRequestBuilderGetQueryParameters() - - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - config = GroupsRequestBuilder.GroupsRequestBuilderGetRequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.groups.by_group_id(group_id).transitive_members.get(request_configuration=config) - - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def groups_list_transitive_members_as_group( - self, - group_id: str, - ConsistencyLevel: Optional[str] = None, - dollar_orderby: Optional[List[str]] = None, - dollar_select: Optional[List[str]] = None, - dollar_expand: Optional[List[str]] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """List group transitive members. - Users Groups operation: GET /groups/{group-id}/transitiveMembers/graph.group - Operation type: groups - Args: - group_id (str, required): Users Groups group id identifier - ConsistencyLevel (str, optional): Indicates the requested consistency level. Documentation URL: https://docs.microsoft.com/graph/aad-advanced-queries - dollar_orderby (List[str], optional): Order items by property values - dollar_select (List[str], optional): Select properties to be returned - dollar_expand (List[str], optional): Expand related entities - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = GroupsRequestBuilder.GroupsRequestBuilderGetQueryParameters() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = GroupsRequestBuilder.GroupsRequestBuilderGetRequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.groups.by_group_id(group_id).transitive_members.graph_group.get(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def scoped_role_memberships_scoped_role_membership_create_scoped_role_membership( - self, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - request_body: Optional[Mapping[str, Any]] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Add new entity to scopedRoleMemberships. - Users Groups operation: POST /scopedRoleMemberships - Operation type: groups - Args: - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - request_body (optional): Request body data for Users Groups operations - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.scoped_role_memberships.post(body=request_body, request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def scoped_role_memberships_scoped_role_membership_list_scoped_role_membership( - self, - dollar_orderby: Optional[List[str]] = None, - dollar_select: Optional[List[str]] = None, - dollar_expand: Optional[List[str]] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Get entities from scopedRoleMemberships. - Users Groups operation: GET /scopedRoleMemberships - Operation type: groups - Args: - dollar_orderby (List[str], optional): Order items by property values - dollar_select (List[str], optional): Select properties to be returned - dollar_expand (List[str], optional): Expand related entities - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.scoped_role_memberships.get(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def scoped_role_memberships_scoped_role_membership_delete_scoped_role_membership( - self, - scopedRoleMembership_id: str, - If_Match: Optional[str] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Delete entity from scopedRoleMemberships. - Users Groups operation: DELETE /scopedRoleMemberships/{scopedRoleMembership-id} - Operation type: groups - Args: - scopedRoleMembership_id (str, required): Users Groups scopedRoleMembership id identifier - If_Match (str, optional): ETag - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.scoped_role_memberships.by_scopedRoleMembership_id(scopedRoleMembership_id).delete(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def scoped_role_memberships_scoped_role_membership_get_scoped_role_membership( - self, - scopedRoleMembership_id: str, - dollar_select: Optional[List[str]] = None, - dollar_expand: Optional[List[str]] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Get entity from scopedRoleMemberships by key. - Users Groups operation: GET /scopedRoleMemberships/{scopedRoleMembership-id} - Operation type: groups - Args: - scopedRoleMembership_id (str, required): Users Groups scopedRoleMembership id identifier - dollar_select (List[str], optional): Select properties to be returned - dollar_expand (List[str], optional): Expand related entities - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.scoped_role_memberships.by_scopedRoleMembership_id(scopedRoleMembership_id).get(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def scoped_role_memberships_scoped_role_membership_update_scoped_role_membership( - self, - scopedRoleMembership_id: str, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - request_body: Optional[Mapping[str, Any]] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Update entity in scopedRoleMemberships. - Users Groups operation: PATCH /scopedRoleMemberships/{scopedRoleMembership-id} - Operation type: groups - Args: - scopedRoleMembership_id (str, required): Users Groups scopedRoleMembership id identifier - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - request_body (optional): Request body data for Users Groups operations - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.scoped_role_memberships.by_scopedRoleMembership_id(scopedRoleMembership_id).patch(body=request_body, request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - # ========== INVITATIONS OPERATIONS (2 methods) ========== - - async def invitations_invitation_create_invitation( - self, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - request_body: Optional[Mapping[str, Any]] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Create invitation. - Users Groups operation: POST /invitations - Operation type: invitations - Args: - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - request_body (optional): Request body data for Users Groups operations - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.invitations.post(body=request_body, request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def invitations_invitation_list_invitation( - self, - dollar_orderby: Optional[List[str]] = None, - dollar_select: Optional[List[str]] = None, - dollar_expand: Optional[List[str]] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Get entities from invitations. - Users Groups operation: GET /invitations - Operation type: invitations - Args: - dollar_orderby (List[str], optional): Order items by property values - dollar_select (List[str], optional): Select properties to be returned - dollar_expand (List[str], optional): Expand related entities - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = InvitationsRequestBuilder.InvitationsRequestBuilderGetQueryParameters() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = InvitationsRequestBuilder.InvitationsRequestBuilderGetRequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.invitations.get(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - # ========== ORGANIZATION OPERATIONS (68 methods) ========== - - async def organization_organization_create_organization( - self, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - request_body: Optional[Mapping[str, Any]] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Add new entity to organization. - Users Groups operation: POST /organization - Operation type: organization - Args: - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - request_body (optional): Request body data for Users Groups operations - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.post(body=request_body, request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_organization_list_organization( - self, - dollar_orderby: Optional[List[str]] = None, - dollar_select: Optional[List[str]] = None, - dollar_expand: Optional[List[str]] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """List organizations. - Users Groups operation: GET /organization - Operation type: organization - Args: - dollar_orderby (List[str], optional): Order items by property values - dollar_select (List[str], optional): Select properties to be returned - dollar_expand (List[str], optional): Expand related entities - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = OrganizationRequestBuilder.OrganizationRequestBuilderGetQueryParameters() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = OrganizationRequestBuilder.OrganizationRequestBuilderGetRequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.get(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_validate_properties( - self, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - request_body: Optional[Mapping[str, Any]] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Invoke action validateProperties. - Users Groups operation: POST /organization/validateProperties - Operation type: organization - Args: - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - request_body (optional): Request body data for Users Groups operations - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.validate_properties.post(body=request_body, request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_organization_delete_organization( - self, - organization_id: str, - If_Match: Optional[str] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Delete entity from organization. - Users Groups operation: DELETE /organization/{organization-id} - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - If_Match (str, optional): ETag - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).delete(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_organization_update_organization( - self, - organization_id: str, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - request_body: Optional[Mapping[str, Any]] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Update organization. - Users Groups operation: PATCH /organization/{organization-id} - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - request_body (optional): Request body data for Users Groups operations - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).patch(body=request_body, request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_delete_branding( - self, - organization_id: str, - If_Match: Optional[str] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Delete organizationalBranding. - Users Groups operation: DELETE /organization/{organization-id}/branding - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - If_Match (str, optional): ETag - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.delete(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_get_branding( - self, - organization_id: str, - dollar_select: Optional[List[str]] = None, - dollar_expand: Optional[List[str]] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Get organizationalBranding. - Users Groups operation: GET /organization/{organization-id}/branding - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - dollar_select (List[str], optional): Select properties to be returned - dollar_expand (List[str], optional): Expand related entities - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = OrganizationRequestBuilder.OrganizationRequestBuilderGetQueryParameters() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = OrganizationRequestBuilder.OrganizationRequestBuilderGetRequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.get(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_update_branding( - self, - organization_id: str, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - request_body: Optional[Mapping[str, Any]] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Update organizationalBranding. - Users Groups operation: PATCH /organization/{organization-id}/branding - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - request_body (optional): Request body data for Users Groups operations - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.patch(body=request_body, request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_delete_branding_background_image( - self, - organization_id: str, - If_Match: Optional[str] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Delete backgroundImage for the navigation property branding in organization. - Users Groups operation: DELETE /organization/{organization-id}/branding/backgroundImage - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - If_Match (str, optional): ETag - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.background_image.delete(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_get_branding_background_image( - self, - organization_id: str, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Get backgroundImage for the navigation property branding from organization. - Users Groups operation: GET /organization/{organization-id}/branding/backgroundImage - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = OrganizationRequestBuilder.OrganizationRequestBuilderGetQueryParameters() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = OrganizationRequestBuilder.OrganizationRequestBuilderGetRequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.background_image.get(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_update_branding_background_image( - self, - organization_id: str, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - request_body: Optional[Mapping[str, Any]] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Update backgroundImage for the navigation property branding in organization. - Users Groups operation: PUT /organization/{organization-id}/branding/backgroundImage - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - request_body (optional): Request body data for Users Groups operations - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.background_image.put(body=request_body, request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_delete_branding_banner_logo( - self, - organization_id: str, - If_Match: Optional[str] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Delete bannerLogo for the navigation property branding in organization. - Users Groups operation: DELETE /organization/{organization-id}/branding/bannerLogo - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - If_Match (str, optional): ETag - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.banner_logo.delete(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_get_branding_banner_logo( - self, - organization_id: str, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Get bannerLogo for the navigation property branding from organization. - Users Groups operation: GET /organization/{organization-id}/branding/bannerLogo - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = OrganizationRequestBuilder.OrganizationRequestBuilderGetQueryParameters() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = OrganizationRequestBuilder.OrganizationRequestBuilderGetRequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.banner_logo.get(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_update_branding_banner_logo( - self, - organization_id: str, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - request_body: Optional[Mapping[str, Any]] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Update bannerLogo for the navigation property branding in organization. - Users Groups operation: PUT /organization/{organization-id}/branding/bannerLogo - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - request_body (optional): Request body data for Users Groups operations - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.banner_logo.put(body=request_body, request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_delete_branding_custom_css( - self, - organization_id: str, - If_Match: Optional[str] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Delete customCSS for the navigation property branding in organization. - Users Groups operation: DELETE /organization/{organization-id}/branding/customCSS - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - If_Match (str, optional): ETag - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.custom_css.delete(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_get_branding_custom_css( - self, - organization_id: str, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Get customCSS for the navigation property branding from organization. - Users Groups operation: GET /organization/{organization-id}/branding/customCSS - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = OrganizationRequestBuilder.OrganizationRequestBuilderGetQueryParameters() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = OrganizationRequestBuilder.OrganizationRequestBuilderGetRequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.custom_css.get(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_update_branding_custom_css( - self, - organization_id: str, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - request_body: Optional[Mapping[str, Any]] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Update customCSS for the navigation property branding in organization. - Users Groups operation: PUT /organization/{organization-id}/branding/customCSS - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - request_body (optional): Request body data for Users Groups operations - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.custom_css.put(body=request_body, request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_delete_branding_favicon( - self, - organization_id: str, - If_Match: Optional[str] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Delete favicon for the navigation property branding in organization. - Users Groups operation: DELETE /organization/{organization-id}/branding/favicon - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - If_Match (str, optional): ETag - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.favicon.delete(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_get_branding_favicon( - self, - organization_id: str, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Get favicon for the navigation property branding from organization. - Users Groups operation: GET /organization/{organization-id}/branding/favicon - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = OrganizationRequestBuilder.OrganizationRequestBuilderGetQueryParameters() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = OrganizationRequestBuilder.OrganizationRequestBuilderGetRequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.favicon.get(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_update_branding_favicon( - self, - organization_id: str, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - request_body: Optional[Mapping[str, Any]] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Update favicon for the navigation property branding in organization. - Users Groups operation: PUT /organization/{organization-id}/branding/favicon - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - request_body (optional): Request body data for Users Groups operations - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.favicon.put(body=request_body, request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_delete_branding_header_logo( - self, - organization_id: str, - If_Match: Optional[str] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Delete headerLogo for the navigation property branding in organization. - Users Groups operation: DELETE /organization/{organization-id}/branding/headerLogo - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - If_Match (str, optional): ETag - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.header_logo.delete(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_get_branding_header_logo( - self, - organization_id: str, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Get headerLogo for the navigation property branding from organization. - Users Groups operation: GET /organization/{organization-id}/branding/headerLogo - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = OrganizationRequestBuilder.OrganizationRequestBuilderGetQueryParameters() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = OrganizationRequestBuilder.OrganizationRequestBuilderGetRequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.header_logo.get(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_update_branding_header_logo( - self, - organization_id: str, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - request_body: Optional[Mapping[str, Any]] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Update headerLogo for the navigation property branding in organization. - Users Groups operation: PUT /organization/{organization-id}/branding/headerLogo - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - request_body (optional): Request body data for Users Groups operations - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.header_logo.put(body=request_body, request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_branding_create_localizations( - self, - organization_id: str, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - request_body: Optional[Mapping[str, Any]] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Create organizationalBrandingLocalization. - Users Groups operation: POST /organization/{organization-id}/branding/localizations - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - request_body (optional): Request body data for Users Groups operations - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.localizations.post(body=request_body, request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_branding_list_localizations( - self, - organization_id: str, - dollar_orderby: Optional[List[str]] = None, - dollar_select: Optional[List[str]] = None, - dollar_expand: Optional[List[str]] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """List localizations. - Users Groups operation: GET /organization/{organization-id}/branding/localizations - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - dollar_orderby (List[str], optional): Order items by property values - dollar_select (List[str], optional): Select properties to be returned - dollar_expand (List[str], optional): Expand related entities - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = OrganizationRequestBuilder.OrganizationRequestBuilderGetQueryParameters() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = OrganizationRequestBuilder.OrganizationRequestBuilderGetRequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.localizations.get(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_branding_delete_localizations( - self, - organization_id: str, - organizationalBrandingLocalization_id: str, - If_Match: Optional[str] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Delete organizationalBrandingLocalization. - Users Groups operation: DELETE /organization/{organization-id}/branding/localizations/{organizationalBrandingLocalization-id} - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - organizationalBrandingLocalization_id (str, required): Users Groups organizationalBrandingLocalization id identifier - If_Match (str, optional): ETag - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.localizations.by_organizational_branding_localization_id(organizationalBrandingLocalization_id).delete(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_branding_get_localizations( - self, - organization_id: str, - organizationalBrandingLocalization_id: str, - dollar_select: Optional[List[str]] = None, - dollar_expand: Optional[List[str]] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Get organizationalBrandingLocalization. - Users Groups operation: GET /organization/{organization-id}/branding/localizations/{organizationalBrandingLocalization-id} - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - organizationalBrandingLocalization_id (str, required): Users Groups organizationalBrandingLocalization id identifier - dollar_select (List[str], optional): Select properties to be returned - dollar_expand (List[str], optional): Expand related entities - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = OrganizationRequestBuilder.OrganizationRequestBuilderGetQueryParameters() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = OrganizationRequestBuilder.OrganizationRequestBuilderGetRequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.localizations.by_organizational_branding_localization_id(organizationalBrandingLocalization_id).get(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_branding_update_localizations( - self, - organization_id: str, - organizationalBrandingLocalization_id: str, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - request_body: Optional[Mapping[str, Any]] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Update organizationalBrandingLocalization. - Users Groups operation: PATCH /organization/{organization-id}/branding/localizations/{organizationalBrandingLocalization-id} - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - organizationalBrandingLocalization_id (str, required): Users Groups organizationalBrandingLocalization id identifier - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - request_body (optional): Request body data for Users Groups operations - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.localizations.by_organizational_branding_localization_id(organizationalBrandingLocalization_id).patch(body=request_body, request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_branding_delete_localizations_background_image( - self, - organization_id: str, - organizationalBrandingLocalization_id: str, - If_Match: Optional[str] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Delete backgroundImage for the navigation property localizations in organization. - Users Groups operation: DELETE /organization/{organization-id}/branding/localizations/{organizationalBrandingLocalization-id}/backgroundImage - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - organizationalBrandingLocalization_id (str, required): Users Groups organizationalBrandingLocalization id identifier - If_Match (str, optional): ETag - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.localizations.by_organizational_branding_localization_id(organizationalBrandingLocalization_id).background_image.delete(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_branding_get_localizations_background_image( - self, - organization_id: str, - organizationalBrandingLocalization_id: str, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Get backgroundImage for the navigation property localizations from organization. - Users Groups operation: GET /organization/{organization-id}/branding/localizations/{organizationalBrandingLocalization-id}/backgroundImage - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - organizationalBrandingLocalization_id (str, required): Users Groups organizationalBrandingLocalization id identifier - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = OrganizationRequestBuilder.OrganizationRequestBuilderGetQueryParameters() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = OrganizationRequestBuilder.OrganizationRequestBuilderGetRequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.localizations.by_organizational_branding_localization_id(organizationalBrandingLocalization_id).background_image.get(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_branding_update_localizations_background_image( - self, - organization_id: str, - organizationalBrandingLocalization_id: str, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - request_body: Optional[Mapping[str, Any]] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Update backgroundImage for the navigation property localizations in organization. - Users Groups operation: PUT /organization/{organization-id}/branding/localizations/{organizationalBrandingLocalization-id}/backgroundImage - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - organizationalBrandingLocalization_id (str, required): Users Groups organizationalBrandingLocalization id identifier - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - request_body (optional): Request body data for Users Groups operations - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.localizations.by_organizational_branding_localization_id(organizationalBrandingLocalization_id).background_image.put(body=request_body, request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_branding_delete_localizations_banner_logo( - self, - organization_id: str, - organizationalBrandingLocalization_id: str, - If_Match: Optional[str] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Delete bannerLogo for the navigation property localizations in organization. - Users Groups operation: DELETE /organization/{organization-id}/branding/localizations/{organizationalBrandingLocalization-id}/bannerLogo - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - organizationalBrandingLocalization_id (str, required): Users Groups organizationalBrandingLocalization id identifier - If_Match (str, optional): ETag - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.localizations.by_organizational_branding_localization_id(organizationalBrandingLocalization_id).banner_logo.delete(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_branding_get_localizations_banner_logo( - self, - organization_id: str, - organizationalBrandingLocalization_id: str, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Get organizationalBrandingLocalization. - Users Groups operation: GET /organization/{organization-id}/branding/localizations/{organizationalBrandingLocalization-id}/bannerLogo - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - organizationalBrandingLocalization_id (str, required): Users Groups organizationalBrandingLocalization id identifier - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = OrganizationRequestBuilder.OrganizationRequestBuilderGetQueryParameters() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = OrganizationRequestBuilder.OrganizationRequestBuilderGetRequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.localizations.by_organizational_branding_localization_id(organizationalBrandingLocalization_id).banner_logo.get(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_branding_update_localizations_banner_logo( - self, - organization_id: str, - organizationalBrandingLocalization_id: str, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - request_body: Optional[Mapping[str, Any]] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Update organizationalBrandingLocalization. - Users Groups operation: PUT /organization/{organization-id}/branding/localizations/{organizationalBrandingLocalization-id}/bannerLogo - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - organizationalBrandingLocalization_id (str, required): Users Groups organizationalBrandingLocalization id identifier - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - request_body (optional): Request body data for Users Groups operations - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.localizations.by_organizational_branding_localization_id(organizationalBrandingLocalization_id).banner_logo.put(body=request_body, request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_branding_delete_localizations_custom_css( - self, - organization_id: str, - organizationalBrandingLocalization_id: str, - If_Match: Optional[str] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Delete customCSS for the navigation property localizations in organization. - Users Groups operation: DELETE /organization/{organization-id}/branding/localizations/{organizationalBrandingLocalization-id}/customCSS - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - organizationalBrandingLocalization_id (str, required): Users Groups organizationalBrandingLocalization id identifier - If_Match (str, optional): ETag - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.localizations.by_organizational_branding_localization_id(organizationalBrandingLocalization_id).custom_css.delete(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_branding_get_localizations_custom_css( - self, - organization_id: str, - organizationalBrandingLocalization_id: str, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Get customCSS for the navigation property localizations from organization. - Users Groups operation: GET /organization/{organization-id}/branding/localizations/{organizationalBrandingLocalization-id}/customCSS - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - organizationalBrandingLocalization_id (str, required): Users Groups organizationalBrandingLocalization id identifier - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = OrganizationRequestBuilder.OrganizationRequestBuilderGetQueryParameters() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = OrganizationRequestBuilder.OrganizationRequestBuilderGetRequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.localizations.by_organizational_branding_localization_id(organizationalBrandingLocalization_id).custom_css.get(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_branding_update_localizations_custom_css( - self, - organization_id: str, - organizationalBrandingLocalization_id: str, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - request_body: Optional[Mapping[str, Any]] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Update customCSS for the navigation property localizations in organization. - Users Groups operation: PUT /organization/{organization-id}/branding/localizations/{organizationalBrandingLocalization-id}/customCSS - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - organizationalBrandingLocalization_id (str, required): Users Groups organizationalBrandingLocalization id identifier - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - request_body (optional): Request body data for Users Groups operations - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.localizations.by_organizational_branding_localization_id(organizationalBrandingLocalization_id).custom_css.put(body=request_body, request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_branding_delete_localizations_favicon( - self, - organization_id: str, - organizationalBrandingLocalization_id: str, - If_Match: Optional[str] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Delete favicon for the navigation property localizations in organization. - Users Groups operation: DELETE /organization/{organization-id}/branding/localizations/{organizationalBrandingLocalization-id}/favicon - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - organizationalBrandingLocalization_id (str, required): Users Groups organizationalBrandingLocalization id identifier - If_Match (str, optional): ETag - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.localizations.by_organizational_branding_localization_id(organizationalBrandingLocalization_id).favicon.delete(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_branding_get_localizations_favicon( - self, - organization_id: str, - organizationalBrandingLocalization_id: str, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Get favicon for the navigation property localizations from organization. - Users Groups operation: GET /organization/{organization-id}/branding/localizations/{organizationalBrandingLocalization-id}/favicon - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - organizationalBrandingLocalization_id (str, required): Users Groups organizationalBrandingLocalization id identifier - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = OrganizationRequestBuilder.OrganizationRequestBuilderGetQueryParameters() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = OrganizationRequestBuilder.OrganizationRequestBuilderGetRequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.localizations.by_organizational_branding_localization_id(organizationalBrandingLocalization_id).favicon.get(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_branding_update_localizations_favicon( - self, - organization_id: str, - organizationalBrandingLocalization_id: str, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - request_body: Optional[Mapping[str, Any]] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Update favicon for the navigation property localizations in organization. - Users Groups operation: PUT /organization/{organization-id}/branding/localizations/{organizationalBrandingLocalization-id}/favicon - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - organizationalBrandingLocalization_id (str, required): Users Groups organizationalBrandingLocalization id identifier - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - request_body (optional): Request body data for Users Groups operations - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.localizations.by_organizational_branding_localization_id(organizationalBrandingLocalization_id).favicon.put(body=request_body, request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_branding_delete_localizations_header_logo( - self, - organization_id: str, - organizationalBrandingLocalization_id: str, - If_Match: Optional[str] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Delete headerLogo for the navigation property localizations in organization. - Users Groups operation: DELETE /organization/{organization-id}/branding/localizations/{organizationalBrandingLocalization-id}/headerLogo - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - organizationalBrandingLocalization_id (str, required): Users Groups organizationalBrandingLocalization id identifier - If_Match (str, optional): ETag - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.localizations.by_organizational_branding_localization_id(organizationalBrandingLocalization_id).header_logo.delete(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_branding_get_localizations_header_logo( - self, - organization_id: str, - organizationalBrandingLocalization_id: str, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Get headerLogo for the navigation property localizations from organization. - Users Groups operation: GET /organization/{organization-id}/branding/localizations/{organizationalBrandingLocalization-id}/headerLogo - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - organizationalBrandingLocalization_id (str, required): Users Groups organizationalBrandingLocalization id identifier - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = OrganizationRequestBuilder.OrganizationRequestBuilderGetQueryParameters() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = OrganizationRequestBuilder.OrganizationRequestBuilderGetRequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.localizations.by_organizational_branding_localization_id(organizationalBrandingLocalization_id).header_logo.get(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_branding_update_localizations_header_logo( - self, - organization_id: str, - organizationalBrandingLocalization_id: str, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - request_body: Optional[Mapping[str, Any]] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Update headerLogo for the navigation property localizations in organization. - Users Groups operation: PUT /organization/{organization-id}/branding/localizations/{organizationalBrandingLocalization-id}/headerLogo - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - organizationalBrandingLocalization_id (str, required): Users Groups organizationalBrandingLocalization id identifier - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - request_body (optional): Request body data for Users Groups operations - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.localizations.by_organizational_branding_localization_id(organizationalBrandingLocalization_id).header_logo.put(body=request_body, request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_branding_delete_localizations_square_logo( - self, - organization_id: str, - organizationalBrandingLocalization_id: str, - If_Match: Optional[str] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Delete squareLogo for the navigation property localizations in organization. - Users Groups operation: DELETE /organization/{organization-id}/branding/localizations/{organizationalBrandingLocalization-id}/squareLogo - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - organizationalBrandingLocalization_id (str, required): Users Groups organizationalBrandingLocalization id identifier - If_Match (str, optional): ETag - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.localizations.by_organizational_branding_localization_id(organizationalBrandingLocalization_id).square_logo.delete(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_branding_get_localizations_square_logo( - self, - organization_id: str, - organizationalBrandingLocalization_id: str, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Get squareLogo for the navigation property localizations from organization. - Users Groups operation: GET /organization/{organization-id}/branding/localizations/{organizationalBrandingLocalization-id}/squareLogo - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - organizationalBrandingLocalization_id (str, required): Users Groups organizationalBrandingLocalization id identifier - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = OrganizationRequestBuilder.OrganizationRequestBuilderGetQueryParameters() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = OrganizationRequestBuilder.OrganizationRequestBuilderGetRequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.localizations.by_organizational_branding_localization_id(organizationalBrandingLocalization_id).square_logo.get(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_branding_update_localizations_square_logo( - self, - organization_id: str, - organizationalBrandingLocalization_id: str, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - request_body: Optional[Mapping[str, Any]] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Update squareLogo for the navigation property localizations in organization. - Users Groups operation: PUT /organization/{organization-id}/branding/localizations/{organizationalBrandingLocalization-id}/squareLogo - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - organizationalBrandingLocalization_id (str, required): Users Groups organizationalBrandingLocalization id identifier - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - request_body (optional): Request body data for Users Groups operations - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.localizations.by_organizational_branding_localization_id(organizationalBrandingLocalization_id).square_logo.put(body=request_body, request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_branding_delete_localizations_square_logo_dark( - self, - organization_id: str, - organizationalBrandingLocalization_id: str, - If_Match: Optional[str] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Delete squareLogoDark for the navigation property localizations in organization. - Users Groups operation: DELETE /organization/{organization-id}/branding/localizations/{organizationalBrandingLocalization-id}/squareLogoDark - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - organizationalBrandingLocalization_id (str, required): Users Groups organizationalBrandingLocalization id identifier - If_Match (str, optional): ETag - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.localizations.by_organizational_branding_localization_id(organizationalBrandingLocalization_id).square_logo_dark.delete(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_branding_get_localizations_square_logo_dark( - self, - organization_id: str, - organizationalBrandingLocalization_id: str, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Get squareLogoDark for the navigation property localizations from organization. - Users Groups operation: GET /organization/{organization-id}/branding/localizations/{organizationalBrandingLocalization-id}/squareLogoDark - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - organizationalBrandingLocalization_id (str, required): Users Groups organizationalBrandingLocalization id identifier - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = OrganizationRequestBuilder.OrganizationRequestBuilderGetQueryParameters() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = OrganizationRequestBuilder.OrganizationRequestBuilderGetRequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.localizations.by_organizational_branding_localization_id(organizationalBrandingLocalization_id).square_logo_dark.get(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_branding_update_localizations_square_logo_dark( - self, - organization_id: str, - organizationalBrandingLocalization_id: str, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - request_body: Optional[Mapping[str, Any]] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Update squareLogoDark for the navigation property localizations in organization. - Users Groups operation: PUT /organization/{organization-id}/branding/localizations/{organizationalBrandingLocalization-id}/squareLogoDark - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - organizationalBrandingLocalization_id (str, required): Users Groups organizationalBrandingLocalization id identifier - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - request_body (optional): Request body data for Users Groups operations - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.localizations.by_organizational_branding_localization_id(organizationalBrandingLocalization_id).square_logo_dark.put(body=request_body, request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_delete_branding_square_logo( - self, - organization_id: str, - If_Match: Optional[str] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Delete squareLogo for the navigation property branding in organization. - Users Groups operation: DELETE /organization/{organization-id}/branding/squareLogo - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - If_Match (str, optional): ETag - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.square_logo.delete(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_get_branding_square_logo( - self, - organization_id: str, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Get squareLogo for the navigation property branding from organization. - Users Groups operation: GET /organization/{organization-id}/branding/squareLogo - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = OrganizationRequestBuilder.OrganizationRequestBuilderGetQueryParameters() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = OrganizationRequestBuilder.OrganizationRequestBuilderGetRequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.square_logo.get(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_update_branding_square_logo( - self, - organization_id: str, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - request_body: Optional[Mapping[str, Any]] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Update squareLogo for the navigation property branding in organization. - Users Groups operation: PUT /organization/{organization-id}/branding/squareLogo - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - request_body (optional): Request body data for Users Groups operations - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.square_logo.put(body=request_body, request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_delete_branding_square_logo_dark( - self, - organization_id: str, - If_Match: Optional[str] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Delete squareLogoDark for the navigation property branding in organization. - Users Groups operation: DELETE /organization/{organization-id}/branding/squareLogoDark - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - If_Match (str, optional): ETag - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.square_logo_dark.delete(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_get_branding_square_logo_dark( - self, - organization_id: str, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Get squareLogoDark for the navigation property branding from organization. - Users Groups operation: GET /organization/{organization-id}/branding/squareLogoDark - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = OrganizationRequestBuilder.OrganizationRequestBuilderGetQueryParameters() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = OrganizationRequestBuilder.OrganizationRequestBuilderGetRequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.square_logo_dark.get(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_update_branding_square_logo_dark( - self, - organization_id: str, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - request_body: Optional[Mapping[str, Any]] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Update squareLogoDark for the navigation property branding in organization. - Users Groups operation: PUT /organization/{organization-id}/branding/squareLogoDark - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - request_body (optional): Request body data for Users Groups operations - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).branding.square_logo_dark.put(body=request_body, request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_create_certificate_based_auth_configuration( - self, - organization_id: str, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - request_body: Optional[Mapping[str, Any]] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Create certificateBasedAuthConfiguration. - Users Groups operation: POST /organization/{organization-id}/certificateBasedAuthConfiguration - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - request_body (optional): Request body data for Users Groups operations - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).certificate_based_auth_configuration.post(body=request_body, request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_list_certificate_based_auth_configuration( - self, - organization_id: str, - dollar_orderby: Optional[List[str]] = None, - dollar_select: Optional[List[str]] = None, - dollar_expand: Optional[List[str]] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """List certificateBasedAuthConfigurations. - Users Groups operation: GET /organization/{organization-id}/certificateBasedAuthConfiguration - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - dollar_orderby (List[str], optional): Order items by property values - dollar_select (List[str], optional): Select properties to be returned - dollar_expand (List[str], optional): Expand related entities - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = OrganizationRequestBuilder.OrganizationRequestBuilderGetQueryParameters() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = OrganizationRequestBuilder.OrganizationRequestBuilderGetRequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).certificate_based_auth_configuration.get(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_delete_certificate_based_auth_configuration( - self, - organization_id: str, - certificateBasedAuthConfiguration_id: str, - If_Match: Optional[str] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Delete certificateBasedAuthConfiguration. - Users Groups operation: DELETE /organization/{organization-id}/certificateBasedAuthConfiguration/{certificateBasedAuthConfiguration-id} - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - certificateBasedAuthConfiguration_id (str, required): Users Groups certificateBasedAuthConfiguration id identifier - If_Match (str, optional): ETag - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).certificate_based_auth_configuration.by_certificate_based_auth_configuration_id(certificateBasedAuthConfiguration_id).delete(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_get_certificate_based_auth_configuration( - self, - organization_id: str, - certificateBasedAuthConfiguration_id: str, - dollar_select: Optional[List[str]] = None, - dollar_expand: Optional[List[str]] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Get certificateBasedAuthConfiguration. - Users Groups operation: GET /organization/{organization-id}/certificateBasedAuthConfiguration/{certificateBasedAuthConfiguration-id} - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - certificateBasedAuthConfiguration_id (str, required): Users Groups certificateBasedAuthConfiguration id identifier - dollar_select (List[str], optional): Select properties to be returned - dollar_expand (List[str], optional): Expand related entities - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = OrganizationRequestBuilder.OrganizationRequestBuilderGetQueryParameters() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = OrganizationRequestBuilder.OrganizationRequestBuilderGetRequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).certificate_based_auth_configuration.by_certificate_based_auth_configuration_id(certificateBasedAuthConfiguration_id).get(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def organization_organization_check_member_objects( - self, - organization_id: str, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - request_body: Optional[Mapping[str, Any]] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Invoke action checkMemberObjects. - Users Groups operation: POST /organization/{organization-id}/checkMemberObjects - Operation type: organization - Args: - organization_id (str, required): Users Groups organization id identifier - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - request_body (optional): Request body data for Users Groups operations - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.organization.by_organization_id(organization_id).check_member_objects.post(body=request_body, request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def policies_cross_tenant_access_policy_templates_delete_multi_tenant_organization_partner_configuration( - self, - If_Match: Optional[str] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Delete navigation property multiTenantOrganizationPartnerConfiguration for policies. - Users Groups operation: DELETE /policies/crossTenantAccessPolicy/templates/multiTenantOrganizationPartnerConfiguration - Operation type: organization - Args: - If_Match (str, optional): ETag - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.policies.cross_tenant_access_policy.templates.multi_tenant_organization_partner_configuration.delete(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def tenant_relationships_get_multi_tenant_organization( - self, - dollar_select: Optional[List[str]] = None, - dollar_expand: Optional[List[str]] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Get multiTenantOrganization. - Users Groups operation: GET /tenantRelationships/multiTenantOrganization - Operation type: organization - Args: - dollar_select (List[str], optional): Select properties to be returned - dollar_expand (List[str], optional): Expand related entities - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.tenant_relationships.multi_tenant_organization.get(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def tenant_relationships_update_multi_tenant_organization( - self, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - request_body: Optional[Mapping[str, Any]] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Update multiTenantOrganization. - Users Groups operation: PATCH /tenantRelationships/multiTenantOrganization - Operation type: organization - Args: - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - request_body (optional): Request body data for Users Groups operations - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.tenant_relationships.multi_tenant_organization.patch(body=request_body, request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def tenant_relationships_multi_tenant_organization_get_join_request( - self, - dollar_select: Optional[List[str]] = None, - dollar_expand: Optional[List[str]] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Get multiTenantOrganizationJoinRequestRecord. - Users Groups operation: GET /tenantRelationships/multiTenantOrganization/joinRequest - Operation type: organization - Args: - dollar_select (List[str], optional): Select properties to be returned - dollar_expand (List[str], optional): Expand related entities - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.tenant_relationships.multi_tenant_organization.join_request.get(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def tenant_relationships_multi_tenant_organization_list_tenants( - self, - dollar_orderby: Optional[List[str]] = None, - dollar_select: Optional[List[str]] = None, - dollar_expand: Optional[List[str]] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """List multiTenantOrganizationMembers. - Users Groups operation: GET /tenantRelationships/multiTenantOrganization/tenants - Operation type: organization - Args: - dollar_orderby (List[str], optional): Order items by property values - dollar_select (List[str], optional): Select properties to be returned - dollar_expand (List[str], optional): Expand related entities - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.tenant_relationships.multi_tenant_organization.tenants.get(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def tenant_relationships_multi_tenant_organization_delete_tenants( - self, - multiTenantOrganizationMember_id: str, - If_Match: Optional[str] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Remove multiTenantOrganizationMember. - Users Groups operation: DELETE /tenantRelationships/multiTenantOrganization/tenants/{multiTenantOrganizationMember-id} - Operation type: organization - Args: - multiTenantOrganizationMember_id (str, required): Users Groups multiTenantOrganizationMember id identifier - If_Match (str, optional): ETag - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.tenant_relationships.multi_tenant_organization.tenants.by_tenant_id(multiTenantOrganizationMember_id).delete(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def tenant_relationships_multi_tenant_organization_get_tenants( - self, - multiTenantOrganizationMember_id: str, - dollar_select: Optional[List[str]] = None, - dollar_expand: Optional[List[str]] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Get multiTenantOrganizationMember. - Users Groups operation: GET /tenantRelationships/multiTenantOrganization/tenants/{multiTenantOrganizationMember-id} - Operation type: organization - Args: - multiTenantOrganizationMember_id (str, required): Users Groups multiTenantOrganizationMember id identifier - dollar_select (List[str], optional): Select properties to be returned - dollar_expand (List[str], optional): Expand related entities - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.tenant_relationships.multi_tenant_organization.tenants.by_tenant_id(multiTenantOrganizationMember_id).get(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def tenant_relationships_multi_tenant_organization_update_tenants( - self, - multiTenantOrganizationMember_id: str, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - request_body: Optional[Mapping[str, Any]] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Update the navigation property tenants in tenantRelationships. - Users Groups operation: PATCH /tenantRelationships/multiTenantOrganization/tenants/{multiTenantOrganizationMember-id} - Operation type: organization - Args: - multiTenantOrganizationMember_id (str, required): Users Groups multiTenantOrganizationMember id identifier - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - request_body (optional): Request body data for Users Groups operations - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.tenant_relationships.multi_tenant_organization.tenants.by_tenant_id(multiTenantOrganizationMember_id).patch(body=request_body, request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - # ========== DOMAINS OPERATIONS (29 methods) ========== - - async def domain_dns_records_domain_dns_record_create_domain_dns_record( - self, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - request_body: Optional[Mapping[str, Any]] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Add new entity to domainDnsRecords. - Users Groups operation: POST /domainDnsRecords - Operation type: domains - Args: - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - request_body (optional): Request body data for Users Groups operations - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.domain_dns_records.post(body=request_body, request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def domain_dns_records_domain_dns_record_list_domain_dns_record( - self, - dollar_orderby: Optional[List[str]] = None, - dollar_select: Optional[List[str]] = None, - dollar_expand: Optional[List[str]] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Get entities from domainDnsRecords. - Users Groups operation: GET /domainDnsRecords - Operation type: domains - Args: - dollar_orderby (List[str], optional): Order items by property values - dollar_select (List[str], optional): Select properties to be returned - dollar_expand (List[str], optional): Expand related entities - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.domain_dns_records.get(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def domain_dns_records_domain_dns_record_delete_domain_dns_record( - self, - domainDnsRecord_id: str, - If_Match: Optional[str] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Delete entity from domainDnsRecords. - Users Groups operation: DELETE /domainDnsRecords/{domainDnsRecord-id} - Operation type: domains - Args: - domainDnsRecord_id (str, required): Users Groups domainDnsRecord id identifier - If_Match (str, optional): ETag - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.domain_dns_records.by_domainDnsRecord_id(domainDnsRecord_id).delete(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def domain_dns_records_domain_dns_record_get_domain_dns_record( - self, - domainDnsRecord_id: str, - dollar_select: Optional[List[str]] = None, - dollar_expand: Optional[List[str]] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Get entity from domainDnsRecords by key. - Users Groups operation: GET /domainDnsRecords/{domainDnsRecord-id} - Operation type: domains - Args: - domainDnsRecord_id (str, required): Users Groups domainDnsRecord id identifier - dollar_select (List[str], optional): Select properties to be returned - dollar_expand (List[str], optional): Expand related entities - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.domain_dns_records.by_domainDnsRecord_id(domainDnsRecord_id).get(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def domain_dns_records_domain_dns_record_update_domain_dns_record( - self, - domainDnsRecord_id: str, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - request_body: Optional[Mapping[str, Any]] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Update entity in domainDnsRecords. - Users Groups operation: PATCH /domainDnsRecords/{domainDnsRecord-id} - Operation type: domains - Args: - domainDnsRecord_id (str, required): Users Groups domainDnsRecord id identifier - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - request_body (optional): Request body data for Users Groups operations - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.domain_dns_records.by_domainDnsRecord_id(domainDnsRecord_id).patch(body=request_body, request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def domains_domain_create_domain( - self, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - request_body: Optional[Mapping[str, Any]] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Create domain. - Users Groups operation: POST /domains - Operation type: domains - Args: - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - request_body (optional): Request body data for Users Groups operations - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.domains.post(body=request_body, request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def domains_domain_list_domain( - self, - dollar_orderby: Optional[List[str]] = None, - dollar_select: Optional[List[str]] = None, - dollar_expand: Optional[List[str]] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """List domains. - Users Groups operation: GET /domains - Operation type: domains - Args: - dollar_orderby (List[str], optional): Order items by property values - dollar_select (List[str], optional): Select properties to be returned - dollar_expand (List[str], optional): Expand related entities - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.domains.get(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def domains_domain_delete_domain( - self, - domain_id: str, - If_Match: Optional[str] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Delete domain. - Users Groups operation: DELETE /domains/{domain-id} - Operation type: domains - Args: - domain_id (str, required): Users Groups domain id identifier - If_Match (str, optional): ETag - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.domains.by_domain_id(domain_id).delete(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def domains_domain_get_domain( - self, - domain_id: str, - dollar_select: Optional[List[str]] = None, - dollar_expand: Optional[List[str]] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Get domain. - Users Groups operation: GET /domains/{domain-id} - Operation type: domains - Args: - domain_id (str, required): Users Groups domain id identifier - dollar_select (List[str], optional): Select properties to be returned - dollar_expand (List[str], optional): Expand related entities - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.domains.by_domain_id(domain_id).get(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def domains_domain_update_domain( - self, - domain_id: str, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - request_body: Optional[Mapping[str, Any]] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Update domain. - Users Groups operation: PATCH /domains/{domain-id} - Operation type: domains - Args: - domain_id (str, required): Users Groups domain id identifier - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - request_body (optional): Request body data for Users Groups operations - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.domains.by_domain_id(domain_id).patch(body=request_body, request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def domains_create_federation_configuration( - self, - domain_id: str, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - request_body: Optional[Mapping[str, Any]] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Create internalDomainFederation. - Users Groups operation: POST /domains/{domain-id}/federationConfiguration - Operation type: domains - Args: - domain_id (str, required): Users Groups domain id identifier - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - request_body (optional): Request body data for Users Groups operations - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.domains.by_domain_id(domain_id).federation_configuration.post(body=request_body, request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def domains_list_federation_configuration( - self, - domain_id: str, - dollar_orderby: Optional[List[str]] = None, - dollar_select: Optional[List[str]] = None, - dollar_expand: Optional[List[str]] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """List internalDomainFederations. - Users Groups operation: GET /domains/{domain-id}/federationConfiguration - Operation type: domains - Args: - domain_id (str, required): Users Groups domain id identifier - dollar_orderby (List[str], optional): Order items by property values - dollar_select (List[str], optional): Select properties to be returned - dollar_expand (List[str], optional): Expand related entities - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.domains.by_domain_id(domain_id).federation_configuration.get(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def domains_delete_federation_configuration( - self, - domain_id: str, - internalDomainFederation_id: str, - If_Match: Optional[str] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Delete internalDomainFederation. - Users Groups operation: DELETE /domains/{domain-id}/federationConfiguration/{internalDomainFederation-id} - Operation type: domains - Args: - domain_id (str, required): Users Groups domain id identifier - internalDomainFederation_id (str, required): Users Groups internalDomainFederation id identifier - If_Match (str, optional): ETag - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.domains.by_domain_id(domain_id).federation_configuration.by_federationConfiguration_id(internalDomainFederation_id).delete(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def domains_get_federation_configuration( - self, - domain_id: str, - internalDomainFederation_id: str, - dollar_select: Optional[List[str]] = None, - dollar_expand: Optional[List[str]] = None, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Get internalDomainFederation. - Users Groups operation: GET /domains/{domain-id}/federationConfiguration/{internalDomainFederation-id} - Operation type: domains - Args: - domain_id (str, required): Users Groups domain id identifier - internalDomainFederation_id (str, required): Users Groups internalDomainFederation id identifier - dollar_select (List[str], optional): Select properties to be returned - dollar_expand (List[str], optional): Expand related entities - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.domains.by_domain_id(domain_id).federation_configuration.by_federationConfiguration_id(internalDomainFederation_id).get(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def domains_update_federation_configuration( - self, - domain_id: str, - internalDomainFederation_id: str, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - request_body: Optional[Mapping[str, Any]] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Update internalDomainFederation. - Users Groups operation: PATCH /domains/{domain-id}/federationConfiguration/{internalDomainFederation-id} - Operation type: domains - Args: - domain_id (str, required): Users Groups domain id identifier - internalDomainFederation_id (str, required): Users Groups internalDomainFederation id identifier - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - request_body (optional): Request body data for Users Groups operations - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.domains.by_domain_id(domain_id).federation_configuration.by_federationConfiguration_id(internalDomainFederation_id).patch(body=request_body, request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def domains_domain_force_delete( - self, - domain_id: str, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - request_body: Optional[Mapping[str, Any]] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Invoke action forceDelete. - Users Groups operation: POST /domains/{domain-id}/forceDelete - Operation type: domains - Args: - domain_id (str, required): Users Groups domain id identifier - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - request_body (optional): Request body data for Users Groups operations - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.domains.by_domain_id(domain_id).force_delete.post(body=request_body, request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def domains_domain_promote( - self, - domain_id: str, - select: Optional[List[str]] = None, - expand: Optional[List[str]] = None, - filter: Optional[str] = None, - orderby: Optional[str] = None, - search: Optional[str] = None, - top: Optional[int] = None, - skip: Optional[int] = None, - headers: Optional[Dict[str, str]] = None, - **kwargs - ) -> UsersGroupsResponse: - """Invoke action promote. - Users Groups operation: POST /domains/{domain-id}/promote - Operation type: domains - Args: - domain_id (str, required): Users Groups domain id identifier - select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) - filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content - top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination - headers (optional): Additional headers for the request - **kwargs: Additional query parameters - Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error - """ - # Build query parameters including OData for Users Groups - try: - # Use typed query parameters - query_params = RequestConfiguration() - - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip - - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params - - if headers: - config.headers = headers - - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' - - response = await self.client.domains.by_domain_id(domain_id).promote.post(request_configuration=config) - return self._handle_users_groups_response(response) - except Exception as e: - return UsersGroupsResponse( - success=False, - error=f"Users Groups API call failed: {str(e)}", - ) - - async def domains_get_root_domain( - self, - domain_id: str, - dollar_select: Optional[List[str]] = None, - dollar_expand: Optional[List[str]] = None, + group_id: str, select: Optional[List[str]] = None, expand: Optional[List[str]] = None, filter: Optional[str] = None, @@ -17180,13 +9402,11 @@ async def domains_get_root_domain( headers: Optional[Dict[str, str]] = None, **kwargs ) -> UsersGroupsResponse: - """Get rootDomain. - Users Groups operation: GET /domains/{domain-id}/rootDomain - Operation type: domains + """Invoke action removeFavorite. + Users Groups operation: POST /groups/{group-id}/removeFavorite + Operation type: groups Args: - domain_id (str, required): Users Groups domain id identifier - dollar_select (List[str], optional): Select properties to be returned - dollar_expand (List[str], optional): Expand related entities + group_id (str, required): Users Groups group id identifier select (optional): Select specific properties to return expand (optional): Expand related entities (e.g., manager, memberOf, directReports) filter (optional): Filter the results using OData syntax @@ -17233,7 +9453,7 @@ async def domains_get_root_domain( config.headers = {} config.headers['ConsistencyLevel'] = 'eventual' - response = await self.client.domains.by_domain_id(domain_id).root_domain.get(request_configuration=config) + response = await self.client.groups.by_group_id(group_id).remove_favorite.post(request_configuration=config) return self._handle_users_groups_response(response) except Exception as e: return UsersGroupsResponse( @@ -17241,9 +9461,9 @@ async def domains_get_root_domain( error=f"Users Groups API call failed: {str(e)}", ) - async def domains_create_service_configuration_records( + async def groups_group_retry_service_provisioning( self, - domain_id: str, + group_id: str, select: Optional[List[str]] = None, expand: Optional[List[str]] = None, filter: Optional[str] = None, @@ -17251,15 +9471,14 @@ async def domains_create_service_configuration_records( search: Optional[str] = None, top: Optional[int] = None, skip: Optional[int] = None, - request_body: Optional[Mapping[str, Any]] = None, headers: Optional[Dict[str, str]] = None, **kwargs ) -> UsersGroupsResponse: - """Create new navigation property to serviceConfigurationRecords for domains. - Users Groups operation: POST /domains/{domain-id}/serviceConfigurationRecords - Operation type: domains + """Invoke action retryServiceProvisioning. + Users Groups operation: POST /groups/{group-id}/retryServiceProvisioning + Operation type: groups Args: - domain_id (str, required): Users Groups domain id identifier + group_id (str, required): Users Groups group id identifier select (optional): Select specific properties to return expand (optional): Expand related entities (e.g., manager, memberOf, directReports) filter (optional): Filter the results using OData syntax @@ -17267,7 +9486,6 @@ async def domains_create_service_configuration_records( search (optional): Search for users, groups, or directory objects by content top (optional): Limit number of results returned skip (optional): Skip number of results for pagination - request_body (optional): Request body data for Users Groups operations headers (optional): Additional headers for the request **kwargs: Additional query parameters Returns: @@ -17307,7 +9525,7 @@ async def domains_create_service_configuration_records( config.headers = {} config.headers['ConsistencyLevel'] = 'eventual' - response = await self.client.domains.by_domain_id(domain_id).service_configuration_records.post(body=request_body, request_configuration=config) + response = await self.client.groups.by_group_id(group_id).retry_service_provisioning.post(request_configuration=config) return self._handle_users_groups_response(response) except Exception as e: return UsersGroupsResponse( @@ -17315,9 +9533,9 @@ async def domains_create_service_configuration_records( error=f"Users Groups API call failed: {str(e)}", ) - async def domains_list_service_configuration_records( + async def groups_list_service_provisioning_errors( self, - domain_id: str, + group_id: str, dollar_orderby: Optional[List[str]] = None, dollar_select: Optional[List[str]] = None, dollar_expand: Optional[List[str]] = None, @@ -17331,11 +9549,11 @@ async def domains_list_service_configuration_records( headers: Optional[Dict[str, str]] = None, **kwargs ) -> UsersGroupsResponse: - """List serviceConfigurationRecords. - Users Groups operation: GET /domains/{domain-id}/serviceConfigurationRecords - Operation type: domains + """Get serviceProvisioningErrors property value. + Users Groups operation: GET /groups/{group-id}/serviceProvisioningErrors + Operation type: groups Args: - domain_id (str, required): Users Groups domain id identifier + group_id (str, required): Users Groups group id identifier dollar_orderby (List[str], optional): Order items by property values dollar_select (List[str], optional): Select properties to be returned dollar_expand (List[str], optional): Expand related entities @@ -17354,7 +9572,7 @@ async def domains_list_service_configuration_records( # Build query parameters including OData for Users Groups try: # Use typed query parameters - query_params = RequestConfiguration() + query_params = GroupsRequestBuilder.GroupsRequestBuilderGetQueryParameters() # Set query parameters using typed object properties if select: @@ -17373,7 +9591,7 @@ async def domains_list_service_configuration_records( query_params.skip = skip # Create proper typed request configuration - config = RequestConfiguration() + config = GroupsRequestBuilder.GroupsRequestBuilderGetRequestConfiguration() config.query_parameters = query_params if headers: @@ -17385,7 +9603,7 @@ async def domains_list_service_configuration_records( config.headers = {} config.headers['ConsistencyLevel'] = 'eventual' - response = await self.client.domains.by_domain_id(domain_id).service_configuration_records.get(request_configuration=config) + response = await self.client.groups.by_group_id(group_id).service_provisioning_errors.get(request_configuration=config) return self._handle_users_groups_response(response) except Exception as e: return UsersGroupsResponse( @@ -17393,11 +9611,14 @@ async def domains_list_service_configuration_records( error=f"Users Groups API call failed: {str(e)}", ) - async def domains_delete_service_configuration_records( + async def groups_list_transitive_members( self, - domain_id: str, - domainDnsRecord_id: str, - If_Match: Optional[str] = None, + group_id: str, + next_url: Optional[str] = None, + ConsistencyLevel: Optional[str] = None, + dollar_orderby: Optional[List[str]] = None, + dollar_select: Optional[List[str]] = None, + dollar_expand: Optional[List[str]] = None, select: Optional[List[str]] = None, expand: Optional[List[str]] = None, filter: Optional[str] = None, @@ -17408,60 +9629,56 @@ async def domains_delete_service_configuration_records( headers: Optional[Dict[str, str]] = None, **kwargs ) -> UsersGroupsResponse: - """Delete navigation property serviceConfigurationRecords for domains. - Users Groups operation: DELETE /domains/{domain-id}/serviceConfigurationRecords/{domainDnsRecord-id} - Operation type: domains + """List group transitive members with pagination support. + Users Groups operation: GET /groups/{group-id}/transitiveMembers + Operation type: groups Args: - domain_id (str, required): Users Groups domain id identifier - domainDnsRecord_id (str, required): Users Groups domainDnsRecord id identifier - If_Match (str, optional): ETag + group_id (str, required): Users Groups group id identifier + next_url (str, optional): Next link URL for pagination select (optional): Select specific properties to return - expand (optional): Expand related entities (e.g., manager, memberOf, directReports) filter (optional): Filter the results using OData syntax - orderby (optional): Order the results by specified properties - search (optional): Search for users, groups, or directory objects by content top (optional): Limit number of results returned - skip (optional): Skip number of results for pagination headers (optional): Additional headers for the request **kwargs: Additional query parameters Returns: - UsersGroupsResponse: Users Groups response wrapper with success/data/error + UsersGroupsResponse: Users Groups response wrapper with success/data/error and odata_next_link """ - # Build query parameters including OData for Users Groups try: - # Use typed query parameters - query_params = RequestConfiguration() + if next_url: + # Use nextLink URL for pagination + response = await self.client.groups.by_group_id(group_id).transitive_members.with_url(next_url).get() + else: + # Build query parameters for initial request + query_params = GroupsRequestBuilder.GroupsRequestBuilderGetQueryParameters() - # Set query parameters using typed object properties - if select: - query_params.select = select if isinstance(select, list) else [select] - if expand: - query_params.expand = expand if isinstance(expand, list) else [expand] - if filter: - query_params.filter = filter - if orderby: - query_params.orderby = orderby - if search: - query_params.search = search - if top is not None: - query_params.top = top - if skip is not None: - query_params.skip = skip + if select: + query_params.select = select if isinstance(select, list) else [select] + if expand: + query_params.expand = expand if isinstance(expand, list) else [expand] + if filter: + query_params.filter = filter + if orderby: + query_params.orderby = orderby + if search: + query_params.search = search + if top is not None: + query_params.top = top + if skip is not None: + query_params.skip = skip - # Create proper typed request configuration - config = RequestConfiguration() - config.query_parameters = query_params + config = GroupsRequestBuilder.GroupsRequestBuilderGetRequestConfiguration() + config.query_parameters = query_params - if headers: - config.headers = headers + if headers: + config.headers = headers - # Add consistency level for search operations in Users Groups - if search: - if not config.headers: - config.headers = {} - config.headers['ConsistencyLevel'] = 'eventual' + if search: + if not config.headers: + config.headers = {} + config.headers['ConsistencyLevel'] = 'eventual' + + response = await self.client.groups.by_group_id(group_id).transitive_members.get(request_configuration=config) - response = await self.client.domains.by_domain_id(domain_id).service_configuration_records.by_domain_dns_record_id(domainDnsRecord_id).delete(request_configuration=config) return self._handle_users_groups_response(response) except Exception as e: return UsersGroupsResponse( @@ -17469,10 +9686,11 @@ async def domains_delete_service_configuration_records( error=f"Users Groups API call failed: {str(e)}", ) - async def domains_get_service_configuration_records( + async def groups_list_transitive_members_as_group( self, - domain_id: str, - domainDnsRecord_id: str, + group_id: str, + ConsistencyLevel: Optional[str] = None, + dollar_orderby: Optional[List[str]] = None, dollar_select: Optional[List[str]] = None, dollar_expand: Optional[List[str]] = None, select: Optional[List[str]] = None, @@ -17485,12 +9703,13 @@ async def domains_get_service_configuration_records( headers: Optional[Dict[str, str]] = None, **kwargs ) -> UsersGroupsResponse: - """Get serviceConfigurationRecords from domains. - Users Groups operation: GET /domains/{domain-id}/serviceConfigurationRecords/{domainDnsRecord-id} - Operation type: domains + """List group transitive members. + Users Groups operation: GET /groups/{group-id}/transitiveMembers/graph.group + Operation type: groups Args: - domain_id (str, required): Users Groups domain id identifier - domainDnsRecord_id (str, required): Users Groups domainDnsRecord id identifier + group_id (str, required): Users Groups group id identifier + ConsistencyLevel (str, optional): Indicates the requested consistency level. Documentation URL: https://docs.microsoft.com/graph/aad-advanced-queries + dollar_orderby (List[str], optional): Order items by property values dollar_select (List[str], optional): Select properties to be returned dollar_expand (List[str], optional): Expand related entities select (optional): Select specific properties to return @@ -17508,7 +9727,7 @@ async def domains_get_service_configuration_records( # Build query parameters including OData for Users Groups try: # Use typed query parameters - query_params = RequestConfiguration() + query_params = GroupsRequestBuilder.GroupsRequestBuilderGetQueryParameters() # Set query parameters using typed object properties if select: @@ -17527,7 +9746,7 @@ async def domains_get_service_configuration_records( query_params.skip = skip # Create proper typed request configuration - config = RequestConfiguration() + config = GroupsRequestBuilder.GroupsRequestBuilderGetRequestConfiguration() config.query_parameters = query_params if headers: @@ -17539,7 +9758,7 @@ async def domains_get_service_configuration_records( config.headers = {} config.headers['ConsistencyLevel'] = 'eventual' - response = await self.client.domains.by_domain_id(domain_id).service_configuration_records.by_domain_dns_record_id(domainDnsRecord_id).get(request_configuration=config) + response = await self.client.groups.by_group_id(group_id).transitive_members.graph_group.get(request_configuration=config) return self._handle_users_groups_response(response) except Exception as e: return UsersGroupsResponse( @@ -17547,10 +9766,8 @@ async def domains_get_service_configuration_records( error=f"Users Groups API call failed: {str(e)}", ) - async def domains_update_service_configuration_records( + async def scoped_role_memberships_scoped_role_membership_create_scoped_role_membership( self, - domain_id: str, - domainDnsRecord_id: str, select: Optional[List[str]] = None, expand: Optional[List[str]] = None, filter: Optional[str] = None, @@ -17562,12 +9779,10 @@ async def domains_update_service_configuration_records( headers: Optional[Dict[str, str]] = None, **kwargs ) -> UsersGroupsResponse: - """Update the navigation property serviceConfigurationRecords in domains. - Users Groups operation: PATCH /domains/{domain-id}/serviceConfigurationRecords/{domainDnsRecord-id} - Operation type: domains + """Add new entity to scopedRoleMemberships. + Users Groups operation: POST /scopedRoleMemberships + Operation type: groups Args: - domain_id (str, required): Users Groups domain id identifier - domainDnsRecord_id (str, required): Users Groups domainDnsRecord id identifier select (optional): Select specific properties to return expand (optional): Expand related entities (e.g., manager, memberOf, directReports) filter (optional): Filter the results using OData syntax @@ -17615,7 +9830,7 @@ async def domains_update_service_configuration_records( config.headers = {} config.headers['ConsistencyLevel'] = 'eventual' - response = await self.client.domains.by_domain_id(domain_id).service_configuration_records.by_domain_dns_record_id(domainDnsRecord_id).patch(body=request_body, request_configuration=config) + response = await self.client.scoped_role_memberships.post(body=request_body, request_configuration=config) return self._handle_users_groups_response(response) except Exception as e: return UsersGroupsResponse( @@ -17623,9 +9838,11 @@ async def domains_update_service_configuration_records( error=f"Users Groups API call failed: {str(e)}", ) - async def domains_create_verification_dns_records( + async def scoped_role_memberships_scoped_role_membership_list_scoped_role_membership( self, - domain_id: str, + dollar_orderby: Optional[List[str]] = None, + dollar_select: Optional[List[str]] = None, + dollar_expand: Optional[List[str]] = None, select: Optional[List[str]] = None, expand: Optional[List[str]] = None, filter: Optional[str] = None, @@ -17633,15 +9850,16 @@ async def domains_create_verification_dns_records( search: Optional[str] = None, top: Optional[int] = None, skip: Optional[int] = None, - request_body: Optional[Mapping[str, Any]] = None, headers: Optional[Dict[str, str]] = None, **kwargs ) -> UsersGroupsResponse: - """Create new navigation property to verificationDnsRecords for domains. - Users Groups operation: POST /domains/{domain-id}/verificationDnsRecords - Operation type: domains + """Get entities from scopedRoleMemberships. + Users Groups operation: GET /scopedRoleMemberships + Operation type: groups Args: - domain_id (str, required): Users Groups domain id identifier + dollar_orderby (List[str], optional): Order items by property values + dollar_select (List[str], optional): Select properties to be returned + dollar_expand (List[str], optional): Expand related entities select (optional): Select specific properties to return expand (optional): Expand related entities (e.g., manager, memberOf, directReports) filter (optional): Filter the results using OData syntax @@ -17649,7 +9867,6 @@ async def domains_create_verification_dns_records( search (optional): Search for users, groups, or directory objects by content top (optional): Limit number of results returned skip (optional): Skip number of results for pagination - request_body (optional): Request body data for Users Groups operations headers (optional): Additional headers for the request **kwargs: Additional query parameters Returns: @@ -17689,7 +9906,7 @@ async def domains_create_verification_dns_records( config.headers = {} config.headers['ConsistencyLevel'] = 'eventual' - response = await self.client.domains.by_domain_id(domain_id).verification_dns_records.post(body=request_body, request_configuration=config) + response = await self.client.scoped_role_memberships.get(request_configuration=config) return self._handle_users_groups_response(response) except Exception as e: return UsersGroupsResponse( @@ -17697,12 +9914,10 @@ async def domains_create_verification_dns_records( error=f"Users Groups API call failed: {str(e)}", ) - async def domains_list_verification_dns_records( + async def scoped_role_memberships_scoped_role_membership_delete_scoped_role_membership( self, - domain_id: str, - dollar_orderby: Optional[List[str]] = None, - dollar_select: Optional[List[str]] = None, - dollar_expand: Optional[List[str]] = None, + scopedRoleMembership_id: str, + If_Match: Optional[str] = None, select: Optional[List[str]] = None, expand: Optional[List[str]] = None, filter: Optional[str] = None, @@ -17713,14 +9928,12 @@ async def domains_list_verification_dns_records( headers: Optional[Dict[str, str]] = None, **kwargs ) -> UsersGroupsResponse: - """List verificationDnsRecords. - Users Groups operation: GET /domains/{domain-id}/verificationDnsRecords - Operation type: domains + """Delete entity from scopedRoleMemberships. + Users Groups operation: DELETE /scopedRoleMemberships/{scopedRoleMembership-id} + Operation type: groups Args: - domain_id (str, required): Users Groups domain id identifier - dollar_orderby (List[str], optional): Order items by property values - dollar_select (List[str], optional): Select properties to be returned - dollar_expand (List[str], optional): Expand related entities + scopedRoleMembership_id (str, required): Users Groups scopedRoleMembership id identifier + If_Match (str, optional): ETag select (optional): Select specific properties to return expand (optional): Expand related entities (e.g., manager, memberOf, directReports) filter (optional): Filter the results using OData syntax @@ -17767,7 +9980,7 @@ async def domains_list_verification_dns_records( config.headers = {} config.headers['ConsistencyLevel'] = 'eventual' - response = await self.client.domains.by_domain_id(domain_id).verification_dns_records.get(request_configuration=config) + response = await self.client.scoped_role_memberships.by_scopedRoleMembership_id(scopedRoleMembership_id).delete(request_configuration=config) return self._handle_users_groups_response(response) except Exception as e: return UsersGroupsResponse( @@ -17775,11 +9988,11 @@ async def domains_list_verification_dns_records( error=f"Users Groups API call failed: {str(e)}", ) - async def domains_delete_verification_dns_records( + async def scoped_role_memberships_scoped_role_membership_get_scoped_role_membership( self, - domain_id: str, - domainDnsRecord_id: str, - If_Match: Optional[str] = None, + scopedRoleMembership_id: str, + dollar_select: Optional[List[str]] = None, + dollar_expand: Optional[List[str]] = None, select: Optional[List[str]] = None, expand: Optional[List[str]] = None, filter: Optional[str] = None, @@ -17790,13 +10003,13 @@ async def domains_delete_verification_dns_records( headers: Optional[Dict[str, str]] = None, **kwargs ) -> UsersGroupsResponse: - """Delete navigation property verificationDnsRecords for domains. - Users Groups operation: DELETE /domains/{domain-id}/verificationDnsRecords/{domainDnsRecord-id} - Operation type: domains + """Get entity from scopedRoleMemberships by key. + Users Groups operation: GET /scopedRoleMemberships/{scopedRoleMembership-id} + Operation type: groups Args: - domain_id (str, required): Users Groups domain id identifier - domainDnsRecord_id (str, required): Users Groups domainDnsRecord id identifier - If_Match (str, optional): ETag + scopedRoleMembership_id (str, required): Users Groups scopedRoleMembership id identifier + dollar_select (List[str], optional): Select properties to be returned + dollar_expand (List[str], optional): Expand related entities select (optional): Select specific properties to return expand (optional): Expand related entities (e.g., manager, memberOf, directReports) filter (optional): Filter the results using OData syntax @@ -17843,7 +10056,7 @@ async def domains_delete_verification_dns_records( config.headers = {} config.headers['ConsistencyLevel'] = 'eventual' - response = await self.client.domains.by_domain_id(domain_id).verification_dns_records.by_domain_dns_record_id(domainDnsRecord_id).delete(request_configuration=config) + response = await self.client.scoped_role_memberships.by_scopedRoleMembership_id(scopedRoleMembership_id).get(request_configuration=config) return self._handle_users_groups_response(response) except Exception as e: return UsersGroupsResponse( @@ -17851,12 +10064,9 @@ async def domains_delete_verification_dns_records( error=f"Users Groups API call failed: {str(e)}", ) - async def domains_get_verification_dns_records( + async def scoped_role_memberships_scoped_role_membership_update_scoped_role_membership( self, - domain_id: str, - domainDnsRecord_id: str, - dollar_select: Optional[List[str]] = None, - dollar_expand: Optional[List[str]] = None, + scopedRoleMembership_id: str, select: Optional[List[str]] = None, expand: Optional[List[str]] = None, filter: Optional[str] = None, @@ -17864,17 +10074,15 @@ async def domains_get_verification_dns_records( search: Optional[str] = None, top: Optional[int] = None, skip: Optional[int] = None, + request_body: Optional[Mapping[str, Any]] = None, headers: Optional[Dict[str, str]] = None, **kwargs ) -> UsersGroupsResponse: - """Get verificationDnsRecords from domains. - Users Groups operation: GET /domains/{domain-id}/verificationDnsRecords/{domainDnsRecord-id} - Operation type: domains + """Update entity in scopedRoleMemberships. + Users Groups operation: PATCH /scopedRoleMemberships/{scopedRoleMembership-id} + Operation type: groups Args: - domain_id (str, required): Users Groups domain id identifier - domainDnsRecord_id (str, required): Users Groups domainDnsRecord id identifier - dollar_select (List[str], optional): Select properties to be returned - dollar_expand (List[str], optional): Expand related entities + scopedRoleMembership_id (str, required): Users Groups scopedRoleMembership id identifier select (optional): Select specific properties to return expand (optional): Expand related entities (e.g., manager, memberOf, directReports) filter (optional): Filter the results using OData syntax @@ -17882,6 +10090,7 @@ async def domains_get_verification_dns_records( search (optional): Search for users, groups, or directory objects by content top (optional): Limit number of results returned skip (optional): Skip number of results for pagination + request_body (optional): Request body data for Users Groups operations headers (optional): Additional headers for the request **kwargs: Additional query parameters Returns: @@ -17921,7 +10130,7 @@ async def domains_get_verification_dns_records( config.headers = {} config.headers['ConsistencyLevel'] = 'eventual' - response = await self.client.domains.by_domain_id(domain_id).verification_dns_records.by_domain_dns_record_id(domainDnsRecord_id).get(request_configuration=config) + response = await self.client.scoped_role_memberships.by_scopedRoleMembership_id(scopedRoleMembership_id).patch(body=request_body, request_configuration=config) return self._handle_users_groups_response(response) except Exception as e: return UsersGroupsResponse( @@ -17929,10 +10138,10 @@ async def domains_get_verification_dns_records( error=f"Users Groups API call failed: {str(e)}", ) - async def domains_update_verification_dns_records( + # ========== INVITATIONS OPERATIONS (2 methods) ========== + + async def invitations_invitation_create_invitation( self, - domain_id: str, - domainDnsRecord_id: str, select: Optional[List[str]] = None, expand: Optional[List[str]] = None, filter: Optional[str] = None, @@ -17944,12 +10153,10 @@ async def domains_update_verification_dns_records( headers: Optional[Dict[str, str]] = None, **kwargs ) -> UsersGroupsResponse: - """Update the navigation property verificationDnsRecords in domains. - Users Groups operation: PATCH /domains/{domain-id}/verificationDnsRecords/{domainDnsRecord-id} - Operation type: domains + """Create invitation. + Users Groups operation: POST /invitations + Operation type: invitations Args: - domain_id (str, required): Users Groups domain id identifier - domainDnsRecord_id (str, required): Users Groups domainDnsRecord id identifier select (optional): Select specific properties to return expand (optional): Expand related entities (e.g., manager, memberOf, directReports) filter (optional): Filter the results using OData syntax @@ -17997,7 +10204,7 @@ async def domains_update_verification_dns_records( config.headers = {} config.headers['ConsistencyLevel'] = 'eventual' - response = await self.client.domains.by_domain_id(domain_id).verification_dns_records.by_domain_dns_record_id(domainDnsRecord_id).patch(body=request_body, request_configuration=config) + response = await self.client.invitations.post(body=request_body, request_configuration=config) return self._handle_users_groups_response(response) except Exception as e: return UsersGroupsResponse( @@ -18005,9 +10212,11 @@ async def domains_update_verification_dns_records( error=f"Users Groups API call failed: {str(e)}", ) - async def domains_domain_verify( + async def invitations_invitation_list_invitation( self, - domain_id: str, + dollar_orderby: Optional[List[str]] = None, + dollar_select: Optional[List[str]] = None, + dollar_expand: Optional[List[str]] = None, select: Optional[List[str]] = None, expand: Optional[List[str]] = None, filter: Optional[str] = None, @@ -18018,11 +10227,13 @@ async def domains_domain_verify( headers: Optional[Dict[str, str]] = None, **kwargs ) -> UsersGroupsResponse: - """Invoke action verify. - Users Groups operation: POST /domains/{domain-id}/verify - Operation type: domains + """Get entities from invitations. + Users Groups operation: GET /invitations + Operation type: invitations Args: - domain_id (str, required): Users Groups domain id identifier + dollar_orderby (List[str], optional): Order items by property values + dollar_select (List[str], optional): Select properties to be returned + dollar_expand (List[str], optional): Expand related entities select (optional): Select specific properties to return expand (optional): Expand related entities (e.g., manager, memberOf, directReports) filter (optional): Filter the results using OData syntax @@ -18038,7 +10249,7 @@ async def domains_domain_verify( # Build query parameters including OData for Users Groups try: # Use typed query parameters - query_params = RequestConfiguration() + query_params = InvitationsRequestBuilder.InvitationsRequestBuilderGetQueryParameters() # Set query parameters using typed object properties if select: @@ -18057,7 +10268,7 @@ async def domains_domain_verify( query_params.skip = skip # Create proper typed request configuration - config = RequestConfiguration() + config = InvitationsRequestBuilder.InvitationsRequestBuilderGetRequestConfiguration() config.query_parameters = query_params if headers: @@ -18069,7 +10280,7 @@ async def domains_domain_verify( config.headers = {} config.headers['ConsistencyLevel'] = 'eventual' - response = await self.client.domains.by_domain_id(domain_id).verify.post(request_configuration=config) + response = await self.client.invitations.get(request_configuration=config) return self._handle_users_groups_response(response) except Exception as e: return UsersGroupsResponse( @@ -18077,9 +10288,9 @@ async def domains_domain_verify( error=f"Users Groups API call failed: {str(e)}", ) - # ========== SUBSCRIPTIONS OPERATIONS (6 methods) ========== + # ========== ORGANIZATION OPERATIONS (68 methods) ========== - async def subscriptions_subscription_create_subscription( + async def organization_organization_create_organization( self, select: Optional[List[str]] = None, expand: Optional[List[str]] = None, @@ -18092,9 +10303,9 @@ async def subscriptions_subscription_create_subscription( headers: Optional[Dict[str, str]] = None, **kwargs ) -> UsersGroupsResponse: - """Create subscription. - Users Groups operation: POST /subscriptions - Operation type: subscriptions + """Add new entity to organization. + Users Groups operation: POST /organization + Operation type: organization Args: select (optional): Select specific properties to return expand (optional): Expand related entities (e.g., manager, memberOf, directReports) @@ -18143,7 +10354,7 @@ async def subscriptions_subscription_create_subscription( config.headers = {} config.headers['ConsistencyLevel'] = 'eventual' - response = await self.client.subscriptions.post(body=request_body, request_configuration=config) + response = await self.client.organization.post(body=request_body, request_configuration=config) return self._handle_users_groups_response(response) except Exception as e: return UsersGroupsResponse( @@ -18151,7 +10362,7 @@ async def subscriptions_subscription_create_subscription( error=f"Users Groups API call failed: {str(e)}", ) - async def subscriptions_subscription_list_subscription( + async def organization_organization_list_organization( self, dollar_orderby: Optional[List[str]] = None, dollar_select: Optional[List[str]] = None, @@ -18166,9 +10377,9 @@ async def subscriptions_subscription_list_subscription( headers: Optional[Dict[str, str]] = None, **kwargs ) -> UsersGroupsResponse: - """List subscriptions. - Users Groups operation: GET /subscriptions - Operation type: subscriptions + """List organizations. + Users Groups operation: GET /organization + Operation type: organization Args: dollar_orderby (List[str], optional): Order items by property values dollar_select (List[str], optional): Select properties to be returned @@ -18188,7 +10399,7 @@ async def subscriptions_subscription_list_subscription( # Build query parameters including OData for Users Groups try: # Use typed query parameters - query_params = RequestConfiguration() + query_params = OrganizationRequestBuilder.OrganizationRequestBuilderGetQueryParameters() # Set query parameters using typed object properties if select: @@ -18207,7 +10418,7 @@ async def subscriptions_subscription_list_subscription( query_params.skip = skip # Create proper typed request configuration - config = RequestConfiguration() + config = OrganizationRequestBuilder.OrganizationRequestBuilderGetRequestConfiguration() config.query_parameters = query_params if headers: @@ -18219,7 +10430,7 @@ async def subscriptions_subscription_list_subscription( config.headers = {} config.headers['ConsistencyLevel'] = 'eventual' - response = await self.client.subscriptions.get(request_configuration=config) + response = await self.client.organization.get(request_configuration=config) return self._handle_users_groups_response(response) except Exception as e: return UsersGroupsResponse( @@ -18227,10 +10438,8 @@ async def subscriptions_subscription_list_subscription( error=f"Users Groups API call failed: {str(e)}", ) - async def subscriptions_subscription_delete_subscription( + async def organization_validate_properties( self, - subscription_id: str, - If_Match: Optional[str] = None, select: Optional[List[str]] = None, expand: Optional[List[str]] = None, filter: Optional[str] = None, @@ -18238,15 +10447,14 @@ async def subscriptions_subscription_delete_subscription( search: Optional[str] = None, top: Optional[int] = None, skip: Optional[int] = None, + request_body: Optional[Mapping[str, Any]] = None, headers: Optional[Dict[str, str]] = None, **kwargs ) -> UsersGroupsResponse: - """Delete subscription. - Users Groups operation: DELETE /subscriptions/{subscription-id} - Operation type: subscriptions + """Invoke action validateProperties. + Users Groups operation: POST /organization/validateProperties + Operation type: organization Args: - subscription_id (str, required): Users Groups subscription id identifier - If_Match (str, optional): ETag select (optional): Select specific properties to return expand (optional): Expand related entities (e.g., manager, memberOf, directReports) filter (optional): Filter the results using OData syntax @@ -18254,6 +10462,7 @@ async def subscriptions_subscription_delete_subscription( search (optional): Search for users, groups, or directory objects by content top (optional): Limit number of results returned skip (optional): Skip number of results for pagination + request_body (optional): Request body data for Users Groups operations headers (optional): Additional headers for the request **kwargs: Additional query parameters Returns: @@ -18293,7 +10502,7 @@ async def subscriptions_subscription_delete_subscription( config.headers = {} config.headers['ConsistencyLevel'] = 'eventual' - response = await self.client.subscriptions.by_subscription_id(subscription_id).delete(request_configuration=config) + response = await self.client.organization.validate_properties.post(body=request_body, request_configuration=config) return self._handle_users_groups_response(response) except Exception as e: return UsersGroupsResponse( @@ -18301,10 +10510,10 @@ async def subscriptions_subscription_delete_subscription( error=f"Users Groups API call failed: {str(e)}", ) - async def subscriptions_subscription_get_subscription( + async def organization_organization_delete_organization( self, - subscription_id: str, - dollar_select: Optional[List[str]] = None, + organization_id: str, + If_Match: Optional[str] = None, select: Optional[List[str]] = None, expand: Optional[List[str]] = None, filter: Optional[str] = None, @@ -18315,12 +10524,12 @@ async def subscriptions_subscription_get_subscription( headers: Optional[Dict[str, str]] = None, **kwargs ) -> UsersGroupsResponse: - """Get subscription. - Users Groups operation: GET /subscriptions/{subscription-id} - Operation type: subscriptions + """Delete entity from organization. + Users Groups operation: DELETE /organization/{organization-id} + Operation type: organization Args: - subscription_id (str, required): Users Groups subscription id identifier - dollar_select (List[str], optional): Select properties to be returned + organization_id (str, required): Users Groups organization id identifier + If_Match (str, optional): ETag select (optional): Select specific properties to return expand (optional): Expand related entities (e.g., manager, memberOf, directReports) filter (optional): Filter the results using OData syntax @@ -18367,7 +10576,7 @@ async def subscriptions_subscription_get_subscription( config.headers = {} config.headers['ConsistencyLevel'] = 'eventual' - response = await self.client.subscriptions.by_subscription_id(subscription_id).get(request_configuration=config) + response = await self.client.organization.by_organization_id(organization_id).delete(request_configuration=config) return self._handle_users_groups_response(response) except Exception as e: return UsersGroupsResponse( @@ -18375,9 +10584,9 @@ async def subscriptions_subscription_get_subscription( error=f"Users Groups API call failed: {str(e)}", ) - async def subscriptions_subscription_update_subscription( + async def organization_organization_update_organization( self, - subscription_id: str, + organization_id: str, select: Optional[List[str]] = None, expand: Optional[List[str]] = None, filter: Optional[str] = None, @@ -18389,11 +10598,11 @@ async def subscriptions_subscription_update_subscription( headers: Optional[Dict[str, str]] = None, **kwargs ) -> UsersGroupsResponse: - """Update subscription. - Users Groups operation: PATCH /subscriptions/{subscription-id} - Operation type: subscriptions + """Update organization. + Users Groups operation: PATCH /organization/{organization-id} + Operation type: organization Args: - subscription_id (str, required): Users Groups subscription id identifier + organization_id (str, required): Users Groups organization id identifier select (optional): Select specific properties to return expand (optional): Expand related entities (e.g., manager, memberOf, directReports) filter (optional): Filter the results using OData syntax @@ -18441,7 +10650,7 @@ async def subscriptions_subscription_update_subscription( config.headers = {} config.headers['ConsistencyLevel'] = 'eventual' - response = await self.client.subscriptions.by_subscription_id(subscription_id).patch(body=request_body, request_configuration=config) + response = await self.client.organization.by_organization_id(organization_id).patch(body=request_body, request_configuration=config) return self._handle_users_groups_response(response) except Exception as e: return UsersGroupsResponse( @@ -18449,9 +10658,11 @@ async def subscriptions_subscription_update_subscription( error=f"Users Groups API call failed: {str(e)}", ) - async def subscriptions_subscription_reauthorize( + + + async def organization_organization_check_member_objects( self, - subscription_id: str, + organization_id: str, select: Optional[List[str]] = None, expand: Optional[List[str]] = None, filter: Optional[str] = None, @@ -18459,14 +10670,15 @@ async def subscriptions_subscription_reauthorize( search: Optional[str] = None, top: Optional[int] = None, skip: Optional[int] = None, + request_body: Optional[Mapping[str, Any]] = None, headers: Optional[Dict[str, str]] = None, **kwargs ) -> UsersGroupsResponse: - """Invoke action reauthorize. - Users Groups operation: POST /subscriptions/{subscription-id}/reauthorize - Operation type: subscriptions + """Invoke action checkMemberObjects. + Users Groups operation: POST /organization/{organization-id}/checkMemberObjects + Operation type: organization Args: - subscription_id (str, required): Users Groups subscription id identifier + organization_id (str, required): Users Groups organization id identifier select (optional): Select specific properties to return expand (optional): Expand related entities (e.g., manager, memberOf, directReports) filter (optional): Filter the results using OData syntax @@ -18474,6 +10686,7 @@ async def subscriptions_subscription_reauthorize( search (optional): Search for users, groups, or directory objects by content top (optional): Limit number of results returned skip (optional): Skip number of results for pagination + request_body (optional): Request body data for Users Groups operations headers (optional): Additional headers for the request **kwargs: Additional query parameters Returns: @@ -18513,7 +10726,7 @@ async def subscriptions_subscription_reauthorize( config.headers = {} config.headers['ConsistencyLevel'] = 'eventual' - response = await self.client.subscriptions.by_subscription_id(subscription_id).reauthorize.post(request_configuration=config) + response = await self.client.organization.by_organization_id(organization_id).check_member_objects.post(body=request_body, request_configuration=config) return self._handle_users_groups_response(response) except Exception as e: return UsersGroupsResponse( @@ -18521,6 +10734,7 @@ async def subscriptions_subscription_reauthorize( error=f"Users Groups API call failed: {str(e)}", ) + # ========== GENERAL OPERATIONS (1 methods) ========== async def me_assign_license( diff --git a/backend/python/app/sources/external/mindtickle/example.py b/backend/python/app/sources/external/mindtickle/example.py new file mode 100644 index 000000000..c3dcd148c --- /dev/null +++ b/backend/python/app/sources/external/mindtickle/example.py @@ -0,0 +1,155 @@ +# ruff: noqa + +""" +Mindtickle API Usage Examples + +This example demonstrates how to use the Mindtickle DataSource to interact with +the Mindtickle API, covering: +- Authentication (API Key / Bearer Token) +- Initializing the Client and DataSource +- Listing Users +- Listing Courses, Modules, Quizzes, Assessments +- Fetching Content and Leaderboard +- Analytics (Completion, Engagement) +- Listing Series + +Prerequisites: +1. Obtain an API key from the Mindtickle admin panel +2. Set MINDTICKLE_API_KEY environment variable +""" + +import asyncio +import json +import os + +from app.sources.client.mindtickle.mindtickle import ( + MindtickleClient, + MindtickleTokenConfig, + MindtickleResponse, +) +from app.sources.external.mindtickle.mindtickle import MindtickleDataSource + +# --- Configuration --- +API_KEY = os.getenv("MINDTICKLE_API_KEY") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: MindtickleResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + # Handle list-type responses + for key in ("users", "courses", "modules", "quizzes", "assessments", + "content", "leaderboard", "series", "data"): + if isinstance(data, dict) and key in data: + items = data[key] + if isinstance(items, list): + print(f" Found {len(items)} {key}.") + if items: + print(f" Sample: {json.dumps(items[0], indent=2)[:400]}...") + return + # Generic response + print(f" Data: {json.dumps(data, indent=2)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + # 1. Initialize Client + print_section("Initializing Mindtickle Client") + + if not API_KEY: + print(" No valid authentication method found.") + print(" Please set MINDTICKLE_API_KEY environment variable") + return + + print(" Using API Key (Bearer Token) authentication") + config = MindtickleTokenConfig(token=API_KEY) + client = MindtickleClient.build_with_config(config) + data_source = MindtickleDataSource(client) + print("Client initialized successfully.") + + try: + # 2. Get Users + print_section("Users") + users_resp = await data_source.get_users(page_size=5) + print_result("Get Users", users_resp) + + # Get a specific user if available + if users_resp.success and users_resp.data: + users = users_resp.data.get("users", []) + if isinstance(users, list) and users: + user_id = str(users[0].get("id", "")) + if user_id: + print_section(f"User Details: {user_id}") + user_resp = await data_source.get_user(user_id=user_id) + print_result("Get User", user_resp) + + # 3. Get Courses + print_section("Courses") + courses_resp = await data_source.get_courses(page_size=5) + print_result("Get Courses", courses_resp) + + # 4. Get Modules + print_section("Modules") + modules_resp = await data_source.get_modules(page_size=5) + print_result("Get Modules", modules_resp) + + # 5. Get Quizzes + print_section("Quizzes") + quizzes_resp = await data_source.get_quizzes(page_size=5) + print_result("Get Quizzes", quizzes_resp) + + # 6. Get Assessments + print_section("Assessments") + assessments_resp = await data_source.get_assessments(page_size=5) + print_result("Get Assessments", assessments_resp) + + # 7. Get Content + print_section("Content") + content_resp = await data_source.get_content(page_size=5) + print_result("Get Content", content_resp) + + # 8. Get Leaderboard + print_section("Leaderboard") + leaderboard_resp = await data_source.get_leaderboard(page_size=5) + print_result("Get Leaderboard", leaderboard_resp) + + # 9. Get Completion Analytics + print_section("Completion Analytics") + completion_resp = await data_source.get_completion_analytics(page_size=5) + print_result("Get Completion Analytics", completion_resp) + + # 10. Get Engagement Analytics + print_section("Engagement Analytics") + engagement_resp = await data_source.get_engagement_analytics(page_size=5) + print_result("Get Engagement Analytics", engagement_resp) + + # 11. Get Series + print_section("Series") + series_resp = await data_source.get_series(page_size=5) + print_result("Get Series", series_resp) + + finally: + # Cleanup: Close the HTTP client session + print("\nClosing client connection...") + inner_client = client.get_client() + if hasattr(inner_client, "close"): + await inner_client.close() + + print("\n" + "=" * 80) + print(" All Mindtickle API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/mindtickle/mindtickle.py b/backend/python/app/sources/external/mindtickle/mindtickle.py new file mode 100644 index 000000000..7a05e3342 --- /dev/null +++ b/backend/python/app/sources/external/mindtickle/mindtickle.py @@ -0,0 +1,717 @@ +# ruff: noqa +""" +Mindtickle REST API DataSource - Auto-generated API wrapper + +Generated from Mindtickle REST API v2 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.mindtickle.mindtickle import MindtickleClient, MindtickleResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class MindtickleDataSource: + """Mindtickle REST API DataSource + + Provides async wrapper methods for Mindtickle REST API operations: + - Users management + - Courses and modules + - Quizzes and assessments + - Content management + - Leaderboard + - Analytics (completion and engagement) + - Series management + + All methods return MindtickleResponse objects. + """ + + def __init__(self, client: MindtickleClient) -> None: + """Initialize with MindtickleClient. + + Args: + client: MindtickleClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'MindtickleDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> MindtickleClient: + """Return the underlying MindtickleClient.""" + return self._client + + # ----------------------------------------------------------------------- + # Users + # ----------------------------------------------------------------------- + + async def get_users( + self, + *, + page: int | None = None, + page_size: int | None = None, + ) -> MindtickleResponse: + """Get all users + + Args: + page: Page number for pagination + page_size: Number of records per page + + Returns: + MindtickleResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if page_size is not None: + query_params['page_size'] = str(page_size) + + url = self.base_url + "/users" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MindtickleResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_users" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return MindtickleResponse(success=False, error=str(e), message="Failed to execute get_users") + + async def get_user( + self, + user_id: str, + ) -> MindtickleResponse: + """Get a specific user by ID + + Args: + user_id: The user ID + + Returns: + MindtickleResponse with operation result + """ + url = self.base_url + "/users/{user_id}".format(user_id=user_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MindtickleResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_user" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return MindtickleResponse(success=False, error=str(e), message="Failed to execute get_user") + + # ----------------------------------------------------------------------- + # Courses + # ----------------------------------------------------------------------- + + async def get_courses( + self, + *, + page: int | None = None, + page_size: int | None = None, + ) -> MindtickleResponse: + """Get all courses + + Args: + page: Page number for pagination + page_size: Number of records per page + + Returns: + MindtickleResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if page_size is not None: + query_params['page_size'] = str(page_size) + + url = self.base_url + "/courses" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MindtickleResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_courses" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return MindtickleResponse(success=False, error=str(e), message="Failed to execute get_courses") + + async def get_course( + self, + course_id: str, + ) -> MindtickleResponse: + """Get a specific course by ID + + Args: + course_id: The course ID + + Returns: + MindtickleResponse with operation result + """ + url = self.base_url + "/courses/{course_id}".format(course_id=course_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MindtickleResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_course" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return MindtickleResponse(success=False, error=str(e), message="Failed to execute get_course") + + # ----------------------------------------------------------------------- + # Modules + # ----------------------------------------------------------------------- + + async def get_modules( + self, + *, + page: int | None = None, + page_size: int | None = None, + ) -> MindtickleResponse: + """Get all modules + + Args: + page: Page number for pagination + page_size: Number of records per page + + Returns: + MindtickleResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if page_size is not None: + query_params['page_size'] = str(page_size) + + url = self.base_url + "/modules" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MindtickleResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_modules" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return MindtickleResponse(success=False, error=str(e), message="Failed to execute get_modules") + + async def get_module( + self, + module_id: str, + ) -> MindtickleResponse: + """Get a specific module by ID + + Args: + module_id: The module ID + + Returns: + MindtickleResponse with operation result + """ + url = self.base_url + "/modules/{module_id}".format(module_id=module_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MindtickleResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_module" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return MindtickleResponse(success=False, error=str(e), message="Failed to execute get_module") + + # ----------------------------------------------------------------------- + # Quizzes + # ----------------------------------------------------------------------- + + async def get_quizzes( + self, + *, + page: int | None = None, + page_size: int | None = None, + ) -> MindtickleResponse: + """Get all quizzes + + Args: + page: Page number for pagination + page_size: Number of records per page + + Returns: + MindtickleResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if page_size is not None: + query_params['page_size'] = str(page_size) + + url = self.base_url + "/quizzes" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MindtickleResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_quizzes" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return MindtickleResponse(success=False, error=str(e), message="Failed to execute get_quizzes") + + async def get_quiz( + self, + quiz_id: str, + ) -> MindtickleResponse: + """Get a specific quiz by ID + + Args: + quiz_id: The quiz ID + + Returns: + MindtickleResponse with operation result + """ + url = self.base_url + "/quizzes/{quiz_id}".format(quiz_id=quiz_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MindtickleResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_quiz" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return MindtickleResponse(success=False, error=str(e), message="Failed to execute get_quiz") + + # ----------------------------------------------------------------------- + # Assessments + # ----------------------------------------------------------------------- + + async def get_assessments( + self, + *, + page: int | None = None, + page_size: int | None = None, + ) -> MindtickleResponse: + """Get all assessments + + Args: + page: Page number for pagination + page_size: Number of records per page + + Returns: + MindtickleResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if page_size is not None: + query_params['page_size'] = str(page_size) + + url = self.base_url + "/assessments" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MindtickleResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_assessments" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return MindtickleResponse(success=False, error=str(e), message="Failed to execute get_assessments") + + async def get_assessment( + self, + assessment_id: str, + ) -> MindtickleResponse: + """Get a specific assessment by ID + + Args: + assessment_id: The assessment ID + + Returns: + MindtickleResponse with operation result + """ + url = self.base_url + "/assessments/{assessment_id}".format(assessment_id=assessment_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MindtickleResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_assessment" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return MindtickleResponse(success=False, error=str(e), message="Failed to execute get_assessment") + + # ----------------------------------------------------------------------- + # Content + # ----------------------------------------------------------------------- + + async def get_content( + self, + *, + page: int | None = None, + page_size: int | None = None, + ) -> MindtickleResponse: + """Get all content items + + Args: + page: Page number for pagination + page_size: Number of records per page + + Returns: + MindtickleResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if page_size is not None: + query_params['page_size'] = str(page_size) + + url = self.base_url + "/content" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MindtickleResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_content" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return MindtickleResponse(success=False, error=str(e), message="Failed to execute get_content") + + async def get_content_item( + self, + content_id: str, + ) -> MindtickleResponse: + """Get a specific content item by ID + + Args: + content_id: The content item ID + + Returns: + MindtickleResponse with operation result + """ + url = self.base_url + "/content/{content_id}".format(content_id=content_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MindtickleResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_content_item" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return MindtickleResponse(success=False, error=str(e), message="Failed to execute get_content_item") + + # ----------------------------------------------------------------------- + # Leaderboard + # ----------------------------------------------------------------------- + + async def get_leaderboard( + self, + *, + page: int | None = None, + page_size: int | None = None, + ) -> MindtickleResponse: + """Get the leaderboard + + Args: + page: Page number for pagination + page_size: Number of records per page + + Returns: + MindtickleResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if page_size is not None: + query_params['page_size'] = str(page_size) + + url = self.base_url + "/leaderboard" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MindtickleResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_leaderboard" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return MindtickleResponse(success=False, error=str(e), message="Failed to execute get_leaderboard") + + # ----------------------------------------------------------------------- + # Analytics + # ----------------------------------------------------------------------- + + async def get_completion_analytics( + self, + *, + page: int | None = None, + page_size: int | None = None, + start_date: str | None = None, + end_date: str | None = None, + ) -> MindtickleResponse: + """Get completion analytics + + Args: + page: Page number for pagination + page_size: Number of records per page + start_date: Start date filter (ISO 8601) + end_date: End date filter (ISO 8601) + + Returns: + MindtickleResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if page_size is not None: + query_params['page_size'] = str(page_size) + if start_date is not None: + query_params['start_date'] = start_date + if end_date is not None: + query_params['end_date'] = end_date + + url = self.base_url + "/analytics/completion" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MindtickleResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_completion_analytics" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return MindtickleResponse(success=False, error=str(e), message="Failed to execute get_completion_analytics") + + async def get_engagement_analytics( + self, + *, + page: int | None = None, + page_size: int | None = None, + start_date: str | None = None, + end_date: str | None = None, + ) -> MindtickleResponse: + """Get engagement analytics + + Args: + page: Page number for pagination + page_size: Number of records per page + start_date: Start date filter (ISO 8601) + end_date: End date filter (ISO 8601) + + Returns: + MindtickleResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if page_size is not None: + query_params['page_size'] = str(page_size) + if start_date is not None: + query_params['start_date'] = start_date + if end_date is not None: + query_params['end_date'] = end_date + + url = self.base_url + "/analytics/engagement" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MindtickleResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_engagement_analytics" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return MindtickleResponse(success=False, error=str(e), message="Failed to execute get_engagement_analytics") + + # ----------------------------------------------------------------------- + # Series + # ----------------------------------------------------------------------- + + async def get_series( + self, + *, + page: int | None = None, + page_size: int | None = None, + ) -> MindtickleResponse: + """Get all series + + Args: + page: Page number for pagination + page_size: Number of records per page + + Returns: + MindtickleResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if page_size is not None: + query_params['page_size'] = str(page_size) + + url = self.base_url + "/series" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MindtickleResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_series" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return MindtickleResponse(success=False, error=str(e), message="Failed to execute get_series") + + async def get_series_item( + self, + series_id: str, + ) -> MindtickleResponse: + """Get a specific series by ID + + Args: + series_id: The series ID + + Returns: + MindtickleResponse with operation result + """ + url = self.base_url + "/series/{series_id}".format(series_id=series_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MindtickleResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_series_item" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return MindtickleResponse(success=False, error=str(e), message="Failed to execute get_series_item") diff --git a/backend/python/app/sources/external/mindtickle/run_generator.py b/backend/python/app/sources/external/mindtickle/run_generator.py new file mode 100644 index 000000000..0e34268df --- /dev/null +++ b/backend/python/app/sources/external/mindtickle/run_generator.py @@ -0,0 +1,340 @@ +# ruff: noqa +""" +Mindtickle DataSource Code Generator + +This script generates the MindtickleDataSource class with all API endpoint +wrapper methods based on the Mindtickle REST API v2 specification. + +The generated code follows the pattern established by ClickUp and other +connectors in this project, using HTTPRequest/HTTPResponse for all API calls. + +Usage: + python -m app.sources.external.mindtickle.run_generator + +Output: + Prints the generated Python source code for the MindtickleDataSource class + to stdout. Redirect to a file to save: + + python -m app.sources.external.mindtickle.run_generator > \ + app/sources/external/mindtickle/mindtickle.py +""" + +from __future__ import annotations + +ENDPOINTS = [ + { + "name": "get_users", + "method": "GET", + "path": "/users", + "doc": "Get all users", + "path_params": [], + "query_params": [ + ("page", "int | None", "page", "Page number for pagination"), + ("page_size", "int | None", "page_size", "Number of records per page"), + ], + "body_params": [], + }, + { + "name": "get_user", + "method": "GET", + "path": "/users/{user_id}", + "doc": "Get a specific user by ID", + "path_params": [("user_id", "str", "The user ID")], + "query_params": [], + "body_params": [], + }, + { + "name": "get_courses", + "method": "GET", + "path": "/courses", + "doc": "Get all courses", + "path_params": [], + "query_params": [ + ("page", "int | None", "page", "Page number for pagination"), + ("page_size", "int | None", "page_size", "Number of records per page"), + ], + "body_params": [], + }, + { + "name": "get_course", + "method": "GET", + "path": "/courses/{course_id}", + "doc": "Get a specific course by ID", + "path_params": [("course_id", "str", "The course ID")], + "query_params": [], + "body_params": [], + }, + { + "name": "get_modules", + "method": "GET", + "path": "/modules", + "doc": "Get all modules", + "path_params": [], + "query_params": [ + ("page", "int | None", "page", "Page number for pagination"), + ("page_size", "int | None", "page_size", "Number of records per page"), + ], + "body_params": [], + }, + { + "name": "get_module", + "method": "GET", + "path": "/modules/{module_id}", + "doc": "Get a specific module by ID", + "path_params": [("module_id", "str", "The module ID")], + "query_params": [], + "body_params": [], + }, + { + "name": "get_quizzes", + "method": "GET", + "path": "/quizzes", + "doc": "Get all quizzes", + "path_params": [], + "query_params": [ + ("page", "int | None", "page", "Page number for pagination"), + ("page_size", "int | None", "page_size", "Number of records per page"), + ], + "body_params": [], + }, + { + "name": "get_quiz", + "method": "GET", + "path": "/quizzes/{quiz_id}", + "doc": "Get a specific quiz by ID", + "path_params": [("quiz_id", "str", "The quiz ID")], + "query_params": [], + "body_params": [], + }, + { + "name": "get_assessments", + "method": "GET", + "path": "/assessments", + "doc": "Get all assessments", + "path_params": [], + "query_params": [ + ("page", "int | None", "page", "Page number for pagination"), + ("page_size", "int | None", "page_size", "Number of records per page"), + ], + "body_params": [], + }, + { + "name": "get_assessment", + "method": "GET", + "path": "/assessments/{assessment_id}", + "doc": "Get a specific assessment by ID", + "path_params": [("assessment_id", "str", "The assessment ID")], + "query_params": [], + "body_params": [], + }, + { + "name": "get_content", + "method": "GET", + "path": "/content", + "doc": "Get all content items", + "path_params": [], + "query_params": [ + ("page", "int | None", "page", "Page number for pagination"), + ("page_size", "int | None", "page_size", "Number of records per page"), + ], + "body_params": [], + }, + { + "name": "get_content_item", + "method": "GET", + "path": "/content/{content_id}", + "doc": "Get a specific content item by ID", + "path_params": [("content_id", "str", "The content item ID")], + "query_params": [], + "body_params": [], + }, + { + "name": "get_leaderboard", + "method": "GET", + "path": "/leaderboard", + "doc": "Get the leaderboard", + "path_params": [], + "query_params": [ + ("page", "int | None", "page", "Page number for pagination"), + ("page_size", "int | None", "page_size", "Number of records per page"), + ], + "body_params": [], + }, + { + "name": "get_completion_analytics", + "method": "GET", + "path": "/analytics/completion", + "doc": "Get completion analytics", + "path_params": [], + "query_params": [ + ("page", "int | None", "page", "Page number for pagination"), + ("page_size", "int | None", "page_size", "Number of records per page"), + ("start_date", "str | None", "start_date", "Start date filter (ISO 8601)"), + ("end_date", "str | None", "end_date", "End date filter (ISO 8601)"), + ], + "body_params": [], + }, + { + "name": "get_engagement_analytics", + "method": "GET", + "path": "/analytics/engagement", + "doc": "Get engagement analytics", + "path_params": [], + "query_params": [ + ("page", "int | None", "page", "Page number for pagination"), + ("page_size", "int | None", "page_size", "Number of records per page"), + ("start_date", "str | None", "start_date", "Start date filter (ISO 8601)"), + ("end_date", "str | None", "end_date", "End date filter (ISO 8601)"), + ], + "body_params": [], + }, + { + "name": "get_series", + "method": "GET", + "path": "/series", + "doc": "Get all series", + "path_params": [], + "query_params": [ + ("page", "int | None", "page", "Page number for pagination"), + ("page_size", "int | None", "page_size", "Number of records per page"), + ], + "body_params": [], + }, + { + "name": "get_series_item", + "method": "GET", + "path": "/series/{series_id}", + "doc": "Get a specific series by ID", + "path_params": [("series_id", "str", "The series ID")], + "query_params": [], + "body_params": [], + }, +] + + +def generate_method(ep: dict) -> str: + """Generate a single async method for an endpoint.""" + name = ep["name"] + method = ep["method"] + path = ep["path"] + doc = ep["doc"] + path_params = ep.get("path_params", []) + query_params = ep.get("query_params", []) + + sig_parts = ["self"] + for pp_name, pp_type, _pp_doc in path_params: + sig_parts.append(f"{pp_name}: {pp_type}") + + optional_qp = [q for q in query_params if "None" in q[1]] + if optional_qp: + sig_parts.append("*") + for qp_name, qp_type, _qp_api, _qp_doc in optional_qp: + sig_parts.append(f"{qp_name}: {qp_type} = None") + + sig = ",\n ".join(sig_parts) + + doc_args = [] + for pp_name, _pp_type, pp_doc in path_params: + doc_args.append(f" {pp_name}: {pp_doc}") + for qp_name, _qp_type, _qp_api, qp_doc in query_params: + doc_args.append(f" {qp_name}: {qp_doc}") + + args_section = "" + if doc_args: + args_section = "\n\n Args:\n" + "\n".join(doc_args) + + qp_block = "" + if query_params: + qp_block = "\n query_params: dict[str, Any] = {}\n" + for qp_name, qp_type, qp_api, _ in query_params: + if "int" in qp_type: + qp_block += f" if {qp_name} is not None:\n query_params['{qp_api}'] = str({qp_name})\n" + else: + qp_block += f" if {qp_name} is not None:\n query_params['{qp_api}'] = {qp_name}\n" + + if path_params: + format_args = ", ".join(f"{p[0]}={p[0]}" for p in path_params) + url_line = f' url = self.base_url + "{path}".format({format_args})' + else: + url_line = f' url = self.base_url + "{path}"' + + req_kwargs = f'method="{method}",\n url=url,\n headers={{"Content-Type": "application/json"}}' + if query_params: + req_kwargs += ",\n query=query_params" + + return f''' async def {name}( + {sig} + ) -> MindtickleResponse: + """{doc}{args_section} + + Returns: + MindtickleResponse with operation result + """ +{qp_block}{url_line} + + try: + request = HTTPRequest( + {req_kwargs}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return MindtickleResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed {name}" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {{response.status}}" + ) + except Exception as e: + return MindtickleResponse(success=False, error=str(e), message="Failed to execute {name}") +''' + + +def generate_datasource() -> str: + """Generate the full MindtickleDataSource module.""" + header = '''# ruff: noqa +""" +Mindtickle REST API DataSource - Auto-generated API wrapper + +Generated from Mindtickle REST API v2 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.mindtickle.mindtickle import MindtickleClient, MindtickleResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class MindtickleDataSource: + """Mindtickle REST API DataSource + + Provides async wrapper methods for Mindtickle REST API operations. + All methods return MindtickleResponse objects. + """ + + def __init__(self, client: MindtickleClient) -> None: + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'MindtickleDataSource': + return self + + def get_client(self) -> MindtickleClient: + return self._client + +''' + methods = "\n".join(generate_method(ep) for ep in ENDPOINTS) + return header + methods + + +if __name__ == "__main__": + print(generate_datasource()) diff --git a/backend/python/app/sources/external/miro/example.py b/backend/python/app/sources/external/miro/example.py new file mode 100644 index 000000000..2a339d608 --- /dev/null +++ b/backend/python/app/sources/external/miro/example.py @@ -0,0 +1,183 @@ +# ruff: noqa + +""" +Miro API Usage Examples (SDK-based) + +This example demonstrates how to use the Miro DataSource backed by the +official ``miro_api`` SDK, covering: +- Authentication (OAuth2, Bearer Token) +- Initializing the Client and DataSource +- Listing Boards +- Getting Board Details +- Listing Board Items, Members, Tags, Connectors, Frames + +Prerequisites: +For OAuth2: +1. Create a Miro app at https://miro.com/app/settings/user-profile/apps +2. Set MIRO_CLIENT_ID and MIRO_CLIENT_SECRET environment variables +3. The OAuth flow will automatically open a browser for authorization + +For Bearer Token: +1. Create a Miro app and generate an access token +2. Set MIRO_ACCESS_TOKEN environment variable + +SDK Reference: https://miroapp.github.io/api-clients/python/ +""" + +import asyncio +import os + +from app.sources.client.miro.miro import ( + MiroClient, + MiroOAuthConfig, + MiroResponse, + MiroTokenConfig, +) +from app.sources.external.miro.miro import MiroDataSource +from app.sources.external.utils.oauth import perform_oauth_flow + +# --- Configuration --- +# OAuth2 credentials (highest priority) +CLIENT_ID = os.getenv("MIRO_CLIENT_ID") +CLIENT_SECRET = os.getenv("MIRO_CLIENT_SECRET") + +# Bearer Token (second priority) +ACCESS_TOKEN = os.getenv("MIRO_ACCESS_TOKEN") + +# OAuth redirect URI +REDIRECT_URI = os.getenv("MIRO_REDIRECT_URI", "http://localhost:8080/callback") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: MiroResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data is not None: + data = response.data + # SDK returns typed model objects; show their repr + print(f" Data: {repr(data)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + # 1. Initialize Client + print_section("Initializing Miro Client") + + config = None + + # Priority 1: OAuth2 + if CLIENT_ID and CLIENT_SECRET: + print(" Using OAuth2 authentication") + try: + print("Starting OAuth flow...") + token_response = perform_oauth_flow( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + auth_endpoint="https://miro.com/oauth/authorize", + token_endpoint="https://api.miro.com/v1/oauth/token", + redirect_uri=REDIRECT_URI, + scopes=[], + scope_delimiter=" ", + auth_method="body", + ) + + access_token = token_response.get("access_token") + if not access_token: + raise Exception("No access_token found in OAuth response") + + config = MiroOAuthConfig( + access_token=access_token, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + ) + print(" OAuth authentication successful") + except Exception as e: + print(f" OAuth flow failed: {e}") + print(" Falling back to other authentication methods...") + + # Priority 2: Bearer Token + if config is None and ACCESS_TOKEN: + print(" Using Bearer Token authentication") + config = MiroTokenConfig( + token=ACCESS_TOKEN, + ) + + if config is None: + print(" No valid authentication method found.") + print(" Please set one of the following:") + print(" - MIRO_CLIENT_ID and MIRO_CLIENT_SECRET (for OAuth2)") + print(" - MIRO_ACCESS_TOKEN (for Bearer Token)") + return + + client = MiroClient.build_with_config(config) + # MiroDataSource accepts MiroClient (or raw MiroApi) and wraps SDK calls + data_source = MiroDataSource(client) + print("Client initialized successfully.") + + # 2. List Boards + print_section("Boards") + boards_resp = data_source.list_boards() + print_result("List Boards", boards_resp) + + # Extract first board_id for further exploration + board_id = None + if boards_resp.success and boards_resp.data is not None: + boards_data = boards_resp.data + # SDK returns a BoardsPagedResponse with a .data attribute + board_list = getattr(boards_data, "data", None) or [] + if board_list: + first_board = board_list[0] + board_id = getattr(first_board, "id", None) + board_name = getattr(first_board, "name", "Unknown") + print(f" Using Board: {board_name} (ID: {board_id})") + + if not board_id: + print(" No boards found. Skipping further operations.") + return + + # 3. Get Board Details + print_section("Board Details") + board_resp = data_source.get_board(board_id=board_id) + print_result("Get Board", board_resp) + + # 4. List Board Items + print_section("Board Items") + items_resp = data_source.list_board_items(board_id=board_id) + print_result("List Board Items", items_resp) + + # 5. List Board Members + print_section("Board Members") + members_resp = data_source.list_board_members(board_id=board_id) + print_result("List Board Members", members_resp) + + # 6. List Board Tags + print_section("Board Tags") + tags_resp = data_source.list_board_tags(board_id=board_id) + print_result("List Board Tags", tags_resp) + + # 7. List Connectors + print_section("Board Connectors") + connectors_resp = data_source.list_connectors(board_id=board_id) + print_result("List Connectors", connectors_resp) + + # 8. List Frames (using type filter) + print_section("Board Frames") + frames_resp = data_source.list_frames(board_id=board_id, type="frame") + print_result("List Frames", frames_resp) + + print("\n" + "=" * 80) + print(" All Miro API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/miro/miro.py b/backend/python/app/sources/external/miro/miro.py new file mode 100644 index 000000000..df08f3fd6 --- /dev/null +++ b/backend/python/app/sources/external/miro/miro.py @@ -0,0 +1,127 @@ +# ruff: noqa +from __future__ import annotations + +from miro_api import MiroApi # type: ignore[reportMissingImports] +from typing import Any, Dict, Optional, Union, cast + +from app.sources.client.miro.miro import MiroResponse + + +class MiroDataSource: + """ + Strict, typed wrapper over the official miro_api SDK for common Miro + business operations. + + Accepts either a `MiroApi` instance *or* any object with + `.get_sdk() -> MiroApi`. + + All methods return `MiroResponse` for a uniform success/error envelope. + """ + + def __init__(self, client_or_sdk: Union[MiroApi, object]) -> None: # type: ignore[reportUnknownParameterType] + super().__init__() + # Support a raw SDK or a wrapper that exposes `.get_sdk()` + if hasattr(client_or_sdk, "get_sdk"): # type: ignore[reportUnknownArgumentType] + sdk_obj = getattr(client_or_sdk, "get_sdk")() # type: ignore[reportUnknownArgumentType] + self._sdk: MiroApi = cast(MiroApi, sdk_obj) # type: ignore[reportUnknownMemberType] + else: + self._sdk = cast(MiroApi, client_or_sdk) # type: ignore[reportUnknownMemberType] + + @staticmethod + def _params(**kwargs: object) -> Dict[str, object]: + """Filter out None values to avoid overriding SDK defaults.""" + out: Dict[str, object] = {} + for k, v in kwargs.items(): + if v is None: + continue + if isinstance(v, (list, dict)) and len(v) == 0: # type: ignore[reportUnknownArgumentType] + continue + out[k] = v + return out + def list_boards(self, team_id: Optional[str] = None, query: Optional[str] = None, owner: Optional[str] = None, sort: Optional[str] = None, limit: Optional[str] = None, offset: Optional[str] = None) -> MiroResponse: + """List boards accessible to the authenticated user. [boards]""" + params = self._params(team_id=team_id, query=query, owner=owner, sort=sort, limit=limit, offset=offset) + result: Any = self._sdk.get_boards(**params) # type: ignore[reportUnknownMemberType] + return MiroResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def get_board(self, board_id: str) -> MiroResponse: + """Get a single board by ID. [boards]""" + result: Any = self._sdk.get_specific_board(board_id) # type: ignore[reportUnknownMemberType] + return MiroResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def create_board(self, board_changes: object) -> MiroResponse: + """Create a new board. Pass a BoardChanges model or compatible dict. [boards]""" + result: Any = self._sdk.create_board(board_changes) # type: ignore[reportUnknownMemberType] + return MiroResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def update_board(self, board_id: str, board_changes: object) -> MiroResponse: + """Update an existing board. [boards]""" + result: Any = self._sdk.update_board(board_id, board_changes) # type: ignore[reportUnknownMemberType] + return MiroResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def delete_board(self, board_id: str) -> MiroResponse: + """Delete a board by ID. [boards]""" + self._sdk.delete_board(board_id) # type: ignore[reportUnknownMemberType] + return MiroResponse(success=True, data=True) # type: ignore[reportUnknownArgumentType] + def list_board_items(self, board_id: str, limit: Optional[str] = None, type: Optional[str] = None, cursor: Optional[str] = None) -> MiroResponse: + """List all items on a board with optional filters. [items]""" + params = self._params(limit=limit, type=type, cursor=cursor) + result: Any = self._sdk.get_items(board_id, **params) # type: ignore[reportUnknownMemberType] + return MiroResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def get_board_item(self, board_id: str, item_id: str) -> MiroResponse: + """Get a specific item on a board. [items]""" + result: Any = self._sdk.get_specific_item(board_id, item_id) # type: ignore[reportUnknownMemberType] + return MiroResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def create_sticky_note(self, board_id: str, sticky_note_create_request: object) -> MiroResponse: + """Create a sticky note on a board. Pass a StickyNoteCreateRequest model. [sticky_notes]""" + result: Any = self._sdk.create_sticky_note_item(board_id, sticky_note_create_request) # type: ignore[reportUnknownMemberType] + return MiroResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def create_card(self, board_id: str, card_create_request: object) -> MiroResponse: + """Create a card on a board. Pass a CardCreateRequest model. [cards]""" + result: Any = self._sdk.create_card_item(board_id, card_create_request) # type: ignore[reportUnknownMemberType] + return MiroResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def create_text(self, board_id: str, text_create_request: object) -> MiroResponse: + """Create a text item on a board. Pass a TextCreateRequest model. [text]""" + result: Any = self._sdk.create_text_item(board_id, text_create_request) # type: ignore[reportUnknownMemberType] + return MiroResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def create_shape(self, board_id: str, shape_create_request: object) -> MiroResponse: + """Create a shape on a board. Pass a ShapeCreateRequest model. [shapes]""" + result: Any = self._sdk.create_shape_item(board_id, shape_create_request) # type: ignore[reportUnknownMemberType] + return MiroResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def list_connectors(self, board_id: str, limit: Optional[str] = None, cursor: Optional[str] = None) -> MiroResponse: + """List all connectors on a board. [connectors]""" + params = self._params(limit=limit, cursor=cursor) + result: Any = self._sdk.get_connectors(board_id, **params) # type: ignore[reportUnknownMemberType] + return MiroResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def create_connector(self, board_id: str, connector_creation_data: object) -> MiroResponse: + """Create a connector between two items on a board. [connectors]""" + result: Any = self._sdk.create_connector(board_id, connector_creation_data) # type: ignore[reportUnknownMemberType] + return MiroResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def list_board_members(self, board_id: str, limit: Optional[str] = None, offset: Optional[str] = None) -> MiroResponse: + """List all members of a board. [members]""" + params = self._params(limit=limit, offset=offset) + result: Any = self._sdk.get_board_members(board_id, **params) # type: ignore[reportUnknownMemberType] + return MiroResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def share_board(self, board_id: str, board_members_invite: object) -> MiroResponse: + """Share a board by inviting members. Pass a BoardMembersInvite model. [members]""" + result: Any = self._sdk.share_board(board_id, board_members_invite) # type: ignore[reportUnknownMemberType] + return MiroResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def list_board_tags(self, board_id: str, limit: Optional[str] = None, offset: Optional[str] = None) -> MiroResponse: + """List all tags on a board. [tags]""" + params = self._params(limit=limit, offset=offset) + result: Any = self._sdk.get_tags_from_board(board_id, **params) # type: ignore[reportUnknownMemberType] + return MiroResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def create_tag(self, board_id: str, tag_create_request: object) -> MiroResponse: + """Create a tag on a board. Pass a TagCreateRequest model. [tags]""" + result: Any = self._sdk.create_tag(board_id, tag_create_request) # type: ignore[reportUnknownMemberType] + return MiroResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def list_frames(self, board_id: str, limit: Optional[str] = None, type: Optional[str] = None, cursor: Optional[str] = None) -> MiroResponse: + """List items on a board (use type='frame' to filter frames). [frames]""" + params = self._params(limit=limit, type=type, cursor=cursor) + result: Any = self._sdk.get_items(board_id, **params) # type: ignore[reportUnknownMemberType] + return MiroResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def list_organizations(self, org_id: str) -> MiroResponse: + """Get organization details by ID. Requires enterprise plan. [organizations]""" + result: Any = self._sdk.enterprise_get_organization(org_id) # type: ignore[reportUnknownMemberType] + return MiroResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def list_org_members(self, org_id: str, role: Optional[str] = None, limit: Optional[int] = None, cursor: Optional[str] = None) -> MiroResponse: + """List members of an organization. Requires enterprise plan. [organizations]""" + params = self._params(role=role, limit=limit, cursor=cursor) + result: Any = self._sdk.enterprise_get_organization_members(org_id, **params) # type: ignore[reportUnknownMemberType] + return MiroResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] diff --git a/backend/python/app/sources/external/netsuite/netsuite.py b/backend/python/app/sources/external/netsuite/netsuite.py new file mode 100644 index 000000000..eef1d52d7 --- /dev/null +++ b/backend/python/app/sources/external/netsuite/netsuite.py @@ -0,0 +1,999 @@ +""" +NetSuite SuiteTalk REST API DataSource - Auto-generated API wrapper + +Generated from NetSuite SuiteTalk REST API documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.http.http_request import HTTPRequest +from app.sources.client.netsuite.netsuite import NetSuiteClient, NetSuiteResponse + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class NetSuiteDataSource: + """NetSuite SuiteTalk REST API DataSource + + Provides async wrapper methods for NetSuite REST API operations: + - Customer records + - Sales orders + - Invoices + - Items + - Vendors + - Employees + - Contacts + - Opportunities + - SuiteQL queries + + The base URL is determined by the NetSuiteClient's configured + account_id. All methods return NetSuiteResponse objects. + """ + + def __init__(self, client: NetSuiteClient) -> None: + """Initialize with NetSuiteClient. + + Args: + client: NetSuiteClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip("/") + except AttributeError as exc: + raise ValueError( + "HTTP client does not have get_base_url method" + ) from exc + + def get_data_source(self) -> "NetSuiteDataSource": + """Return the data source instance.""" + return self + + def get_client(self) -> NetSuiteClient: + """Return the underlying NetSuiteClient.""" + return self._client + + # ------------------------------------------------------------------ + # Customers + # ------------------------------------------------------------------ + + async def list_customers( + self, + *, + limit: int | None = None, + offset: int | None = None, + q: str | None = None, + ) -> NetSuiteResponse: + """List customer records + + HTTP GET /record/v1/customer + + Args: + limit: Maximum number of records to return + offset: Starting index for pagination + q: Search query string + + Returns: + NetSuiteResponse with customer list + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params["limit"] = str(limit) + if offset is not None: + query_params["offset"] = str(offset) + if q is not None: + query_params["q"] = q + + url = self.base_url + "/record/v1/customer" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return NetSuiteResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_customers" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return NetSuiteResponse( + success=False, + error=str(e), + message="Failed to execute list_customers", + ) + + async def get_customer( + self, + customer_id: str, + *, + expandSubResources: bool | None = None, + fields: str | None = None, + ) -> NetSuiteResponse: + """Get a customer record by ID + + HTTP GET /record/v1/customer/{id} + + Args: + customer_id: The customer internal ID + expandSubResources: Expand sub-resources inline + fields: Comma-separated list of fields to return + + Returns: + NetSuiteResponse with customer data + """ + query_params: dict[str, Any] = {} + if expandSubResources is not None: + query_params["expandSubResources"] = str( + expandSubResources + ).lower() + if fields is not None: + query_params["fields"] = fields + + url = self.base_url + "/record/v1/customer/{customer_id}".format( + customer_id=customer_id + ) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return NetSuiteResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_customer" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return NetSuiteResponse( + success=False, + error=str(e), + message="Failed to execute get_customer", + ) + + # ------------------------------------------------------------------ + # Sales Orders + # ------------------------------------------------------------------ + + async def list_sales_orders( + self, + *, + limit: int | None = None, + offset: int | None = None, + q: str | None = None, + ) -> NetSuiteResponse: + """List sales order records + + HTTP GET /record/v1/salesOrder + + Args: + limit: Maximum number of records to return + offset: Starting index for pagination + q: Search query string + + Returns: + NetSuiteResponse with sales order list + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params["limit"] = str(limit) + if offset is not None: + query_params["offset"] = str(offset) + if q is not None: + query_params["q"] = q + + url = self.base_url + "/record/v1/salesOrder" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return NetSuiteResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_sales_orders" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return NetSuiteResponse( + success=False, + error=str(e), + message="Failed to execute list_sales_orders", + ) + + async def get_sales_order( + self, + sales_order_id: str, + *, + expandSubResources: bool | None = None, + fields: str | None = None, + ) -> NetSuiteResponse: + """Get a sales order by ID + + HTTP GET /record/v1/salesOrder/{id} + + Args: + sales_order_id: The sales order internal ID + expandSubResources: Expand sub-resources inline + fields: Comma-separated list of fields to return + + Returns: + NetSuiteResponse with sales order data + """ + query_params: dict[str, Any] = {} + if expandSubResources is not None: + query_params["expandSubResources"] = str( + expandSubResources + ).lower() + if fields is not None: + query_params["fields"] = fields + + url = self.base_url + "/record/v1/salesOrder/{sales_order_id}".format( + sales_order_id=sales_order_id + ) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return NetSuiteResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_sales_order" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return NetSuiteResponse( + success=False, + error=str(e), + message="Failed to execute get_sales_order", + ) + + # ------------------------------------------------------------------ + # Invoices + # ------------------------------------------------------------------ + + async def list_invoices( + self, + *, + limit: int | None = None, + offset: int | None = None, + q: str | None = None, + ) -> NetSuiteResponse: + """List invoice records + + HTTP GET /record/v1/invoice + + Args: + limit: Maximum number of records to return + offset: Starting index for pagination + q: Search query string + + Returns: + NetSuiteResponse with invoice list + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params["limit"] = str(limit) + if offset is not None: + query_params["offset"] = str(offset) + if q is not None: + query_params["q"] = q + + url = self.base_url + "/record/v1/invoice" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return NetSuiteResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_invoices" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return NetSuiteResponse( + success=False, + error=str(e), + message="Failed to execute list_invoices", + ) + + async def get_invoice( + self, + invoice_id: str, + *, + expandSubResources: bool | None = None, + fields: str | None = None, + ) -> NetSuiteResponse: + """Get an invoice by ID + + HTTP GET /record/v1/invoice/{id} + + Args: + invoice_id: The invoice internal ID + expandSubResources: Expand sub-resources inline + fields: Comma-separated list of fields to return + + Returns: + NetSuiteResponse with invoice data + """ + query_params: dict[str, Any] = {} + if expandSubResources is not None: + query_params["expandSubResources"] = str( + expandSubResources + ).lower() + if fields is not None: + query_params["fields"] = fields + + url = self.base_url + "/record/v1/invoice/{invoice_id}".format( + invoice_id=invoice_id + ) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return NetSuiteResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_invoice" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return NetSuiteResponse( + success=False, + error=str(e), + message="Failed to execute get_invoice", + ) + + # ------------------------------------------------------------------ + # Items + # ------------------------------------------------------------------ + + async def list_items( + self, + *, + limit: int | None = None, + offset: int | None = None, + q: str | None = None, + ) -> NetSuiteResponse: + """List item records + + HTTP GET /record/v1/item + + Args: + limit: Maximum number of records to return + offset: Starting index for pagination + q: Search query string + + Returns: + NetSuiteResponse with item list + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params["limit"] = str(limit) + if offset is not None: + query_params["offset"] = str(offset) + if q is not None: + query_params["q"] = q + + url = self.base_url + "/record/v1/item" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return NetSuiteResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_items" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return NetSuiteResponse( + success=False, + error=str(e), + message="Failed to execute list_items", + ) + + async def get_item( + self, + item_id: str, + *, + expandSubResources: bool | None = None, + fields: str | None = None, + ) -> NetSuiteResponse: + """Get an item by ID + + HTTP GET /record/v1/item/{id} + + Args: + item_id: The item internal ID + expandSubResources: Expand sub-resources inline + fields: Comma-separated list of fields to return + + Returns: + NetSuiteResponse with item data + """ + query_params: dict[str, Any] = {} + if expandSubResources is not None: + query_params["expandSubResources"] = str( + expandSubResources + ).lower() + if fields is not None: + query_params["fields"] = fields + + url = self.base_url + "/record/v1/item/{item_id}".format( + item_id=item_id + ) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return NetSuiteResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_item" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return NetSuiteResponse( + success=False, + error=str(e), + message="Failed to execute get_item", + ) + + # ------------------------------------------------------------------ + # Vendors + # ------------------------------------------------------------------ + + async def list_vendors( + self, + *, + limit: int | None = None, + offset: int | None = None, + q: str | None = None, + ) -> NetSuiteResponse: + """List vendor records + + HTTP GET /record/v1/vendor + + Args: + limit: Maximum number of records to return + offset: Starting index for pagination + q: Search query string + + Returns: + NetSuiteResponse with vendor list + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params["limit"] = str(limit) + if offset is not None: + query_params["offset"] = str(offset) + if q is not None: + query_params["q"] = q + + url = self.base_url + "/record/v1/vendor" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return NetSuiteResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_vendors" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return NetSuiteResponse( + success=False, + error=str(e), + message="Failed to execute list_vendors", + ) + + async def get_vendor( + self, + vendor_id: str, + *, + expandSubResources: bool | None = None, + fields: str | None = None, + ) -> NetSuiteResponse: + """Get a vendor by ID + + HTTP GET /record/v1/vendor/{id} + + Args: + vendor_id: The vendor internal ID + expandSubResources: Expand sub-resources inline + fields: Comma-separated list of fields to return + + Returns: + NetSuiteResponse with vendor data + """ + query_params: dict[str, Any] = {} + if expandSubResources is not None: + query_params["expandSubResources"] = str( + expandSubResources + ).lower() + if fields is not None: + query_params["fields"] = fields + + url = self.base_url + "/record/v1/vendor/{vendor_id}".format( + vendor_id=vendor_id + ) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return NetSuiteResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_vendor" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return NetSuiteResponse( + success=False, + error=str(e), + message="Failed to execute get_vendor", + ) + + # ------------------------------------------------------------------ + # Employees + # ------------------------------------------------------------------ + + async def list_employees( + self, + *, + limit: int | None = None, + offset: int | None = None, + q: str | None = None, + ) -> NetSuiteResponse: + """List employee records + + HTTP GET /record/v1/employee + + Args: + limit: Maximum number of records to return + offset: Starting index for pagination + q: Search query string + + Returns: + NetSuiteResponse with employee list + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params["limit"] = str(limit) + if offset is not None: + query_params["offset"] = str(offset) + if q is not None: + query_params["q"] = q + + url = self.base_url + "/record/v1/employee" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return NetSuiteResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_employees" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return NetSuiteResponse( + success=False, + error=str(e), + message="Failed to execute list_employees", + ) + + async def get_employee( + self, + employee_id: str, + *, + expandSubResources: bool | None = None, + fields: str | None = None, + ) -> NetSuiteResponse: + """Get an employee by ID + + HTTP GET /record/v1/employee/{id} + + Args: + employee_id: The employee internal ID + expandSubResources: Expand sub-resources inline + fields: Comma-separated list of fields to return + + Returns: + NetSuiteResponse with employee data + """ + query_params: dict[str, Any] = {} + if expandSubResources is not None: + query_params["expandSubResources"] = str( + expandSubResources + ).lower() + if fields is not None: + query_params["fields"] = fields + + url = self.base_url + "/record/v1/employee/{employee_id}".format( + employee_id=employee_id + ) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return NetSuiteResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_employee" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return NetSuiteResponse( + success=False, + error=str(e), + message="Failed to execute get_employee", + ) + + # ------------------------------------------------------------------ + # Contacts + # ------------------------------------------------------------------ + + async def list_contacts( + self, + *, + limit: int | None = None, + offset: int | None = None, + q: str | None = None, + ) -> NetSuiteResponse: + """List contact records + + HTTP GET /record/v1/contact + + Args: + limit: Maximum number of records to return + offset: Starting index for pagination + q: Search query string + + Returns: + NetSuiteResponse with contact list + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params["limit"] = str(limit) + if offset is not None: + query_params["offset"] = str(offset) + if q is not None: + query_params["q"] = q + + url = self.base_url + "/record/v1/contact" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return NetSuiteResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_contacts" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return NetSuiteResponse( + success=False, + error=str(e), + message="Failed to execute list_contacts", + ) + + async def get_contact( + self, + contact_id: str, + *, + expandSubResources: bool | None = None, + fields: str | None = None, + ) -> NetSuiteResponse: + """Get a contact by ID + + HTTP GET /record/v1/contact/{id} + + Args: + contact_id: The contact internal ID + expandSubResources: Expand sub-resources inline + fields: Comma-separated list of fields to return + + Returns: + NetSuiteResponse with contact data + """ + query_params: dict[str, Any] = {} + if expandSubResources is not None: + query_params["expandSubResources"] = str( + expandSubResources + ).lower() + if fields is not None: + query_params["fields"] = fields + + url = self.base_url + "/record/v1/contact/{contact_id}".format( + contact_id=contact_id + ) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return NetSuiteResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_contact" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return NetSuiteResponse( + success=False, + error=str(e), + message="Failed to execute get_contact", + ) + + # ------------------------------------------------------------------ + # Opportunities + # ------------------------------------------------------------------ + + async def list_opportunities( + self, + *, + limit: int | None = None, + offset: int | None = None, + q: str | None = None, + ) -> NetSuiteResponse: + """List opportunity records + + HTTP GET /record/v1/opportunity + + Args: + limit: Maximum number of records to return + offset: Starting index for pagination + q: Search query string + + Returns: + NetSuiteResponse with opportunity list + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params["limit"] = str(limit) + if offset is not None: + query_params["offset"] = str(offset) + if q is not None: + query_params["q"] = q + + url = self.base_url + "/record/v1/opportunity" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return NetSuiteResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_opportunities" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return NetSuiteResponse( + success=False, + error=str(e), + message="Failed to execute list_opportunities", + ) + + async def get_opportunity( + self, + opportunity_id: str, + *, + expandSubResources: bool | None = None, + fields: str | None = None, + ) -> NetSuiteResponse: + """Get an opportunity by ID + + HTTP GET /record/v1/opportunity/{id} + + Args: + opportunity_id: The opportunity internal ID + expandSubResources: Expand sub-resources inline + fields: Comma-separated list of fields to return + + Returns: + NetSuiteResponse with opportunity data + """ + query_params: dict[str, Any] = {} + if expandSubResources is not None: + query_params["expandSubResources"] = str( + expandSubResources + ).lower() + if fields is not None: + query_params["fields"] = fields + + url = self.base_url + "/record/v1/opportunity/{opportunity_id}".format( + opportunity_id=opportunity_id + ) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return NetSuiteResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_opportunity" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return NetSuiteResponse( + success=False, + error=str(e), + message="Failed to execute get_opportunity", + ) + + # ------------------------------------------------------------------ + # SuiteQL + # ------------------------------------------------------------------ + + async def execute_suiteql( + self, + query: str, + *, + limit: int | None = None, + offset: int | None = None, + ) -> NetSuiteResponse: + """Execute a SuiteQL query + + HTTP POST /query/v1/suiteql + + Args: + query: The SuiteQL query string + limit: Maximum number of records to return + offset: Starting index for pagination + + Returns: + NetSuiteResponse with query results + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params["limit"] = str(limit) + if offset is not None: + query_params["offset"] = str(offset) + + url = self.base_url + "/query/v1/suiteql" + + body: dict[str, Any] = {"q": query} + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={ + "Content-Type": "application/json", + "Prefer": "transient", + }, + query=query_params, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return NetSuiteResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed execute_suiteql" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}", + ) + except Exception as e: + return NetSuiteResponse( + success=False, + error=str(e), + message="Failed to execute execute_suiteql", + ) diff --git a/backend/python/app/sources/external/newrelic/example.py b/backend/python/app/sources/external/newrelic/example.py new file mode 100644 index 000000000..9e4e61629 --- /dev/null +++ b/backend/python/app/sources/external/newrelic/example.py @@ -0,0 +1,148 @@ +# ruff: noqa +""" +NewRelic NerdGraph API Usage Examples + +This example demonstrates how to use the NewRelic DataSource to interact +with the NewRelic NerdGraph (GraphQL) API, covering: +- Authentication (API Key) +- Initializing the Client and DataSource +- Listing accounts +- Executing NRQL queries +- Searching entities +- Listing dashboards, alert policies, synthetics monitors +- Getting APM application details + +Prerequisites: +1. Generate a NewRelic API key at https://one.newrelic.com/api-keys +2. Set environment variables: + - NEWRELIC_API_KEY: NewRelic API key (e.g., NRAK-XXXXX) + - NEWRELIC_ACCOUNT_ID: (optional) NewRelic account ID for NRQL queries + +NerdGraph Reference: https://docs.newrelic.com/docs/apis/nerdgraph/get-started/introduction-new-relic-nerdgraph/ +""" + +import asyncio +import json +import os + +from app.sources.client.newrelic.newrelic import NewRelicClient, NewRelicApiKeyConfig +from app.sources.external.newrelic.newrelic import NewRelicDataSource +from app.sources.client.graphql.response import GraphQLResponse + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: GraphQLResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + print(f" Data: {json.dumps(response.data, indent=2, default=str)[:500]}...") + else: + print(f" {name}: Failed") + if response.errors: + for error in response.errors: + print(f" Error: {error.message}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + """Example usage of NewRelic NerdGraph API.""" + API_KEY = os.getenv("NEWRELIC_API_KEY") + ACCOUNT_ID = os.getenv("NEWRELIC_ACCOUNT_ID") + + if not API_KEY: + print("Please set NEWRELIC_API_KEY environment variable") + print(" Get a key from https://one.newrelic.com/api-keys") + return + + # Initialize NewRelic client + print_section("Initializing NewRelic Client") + print(" Using API key authentication") + config = NewRelicApiKeyConfig(api_key=API_KEY) + client = NewRelicClient.build_with_config(config) + data_source = NewRelicDataSource(client) + print(" Client initialized successfully.") + + try: + # 1. List accounts + print_section("Accounts") + accounts_resp = await data_source.list_accounts() + print_result("List Accounts", accounts_resp) + + # Determine account ID for subsequent queries + account_id = None + if ACCOUNT_ID: + account_id = int(ACCOUNT_ID) + elif accounts_resp.success and accounts_resp.data: + accounts = accounts_resp.data.get("actor", {}).get("accounts", []) + if accounts: + account_id = accounts[0].get("id") + print(f" Using first account: {accounts[0].get('name')} (ID: {account_id})") + + # 2. Get specific account + if account_id: + print_section(f"Account Details (ID: {account_id})") + account_resp = await data_source.get_account(account_id) + print_result("Get Account", account_resp) + + # 3. Execute NRQL query + print_section("NRQL Query") + nrql_resp = await data_source.nrql_query( + account_id=account_id, + nrql_query="SELECT count(*) FROM Transaction SINCE 1 day ago", + ) + print_result("NRQL Query", nrql_resp) + + # 4. List alert policies + print_section("Alert Policies") + policies_resp = await data_source.list_alert_policies(account_id) + print_result("List Alert Policies", policies_resp) + + # 5. Search entities + print_section("Entity Search") + entities_resp = await data_source.list_entities() + print_result("List Entities", entities_resp) + + # 6. List dashboards + print_section("Dashboards") + dashboards_resp = await data_source.list_dashboards() + print_result("List Dashboards", dashboards_resp) + + # 7. List synthetics monitors + print_section("Synthetics Monitors") + monitors_resp = await data_source.list_synthetics_monitors() + print_result("List Synthetics Monitors", monitors_resp) + + # 8. Get specific entity (if we found any) + if entities_resp.success and entities_resp.data: + search_results = ( + entities_resp.data + .get("actor", {}) + .get("entitySearch", {}) + .get("results", {}) + .get("entities", []) + ) + if search_results: + first_entity = search_results[0] + entity_guid = first_entity.get("guid", "") + if entity_guid: + print_section(f"Entity Details (GUID: {entity_guid[:30]}...)") + entity_resp = await data_source.get_entity(entity_guid) + print_result("Get Entity", entity_resp) + + finally: + # Close the client + await client.get_client().close() + + print("\n" + "=" * 80) + print(" All NewRelic NerdGraph API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/newrelic/newrelic.py b/backend/python/app/sources/external/newrelic/newrelic.py new file mode 100644 index 000000000..48a2a34fe --- /dev/null +++ b/backend/python/app/sources/external/newrelic/newrelic.py @@ -0,0 +1,332 @@ +""" +NewRelic NerdGraph DataSource - GraphQL API wrapper. + +Provides typed wrapper methods for common NewRelic NerdGraph operations +including accounts, entities, NRQL queries, dashboards, alert policies, +synthetics monitors, and APM applications. + +All methods return GraphQLResponse objects. +""" + +from typing import Any + +from app.sources.client.graphql.response import GraphQLResponse +from app.sources.client.newrelic.graphql_op import NewRelicGraphQLOperations +from app.sources.client.newrelic.newrelic import NewRelicClient + + +class NewRelicDataSource: + """NewRelic NerdGraph DataSource + + Async wrapper for NewRelic NerdGraph (GraphQL) operations. + + Coverage: + - Accounts: list, get + - NRQL queries + - Entities: list (search), get + - Dashboards: list + - Alert policies: list + - Synthetics monitors: list + - APM applications: get + """ + + def __init__(self, newrelic_client: NewRelicClient) -> None: + """Initialize the NewRelic NerdGraph data source. + + Args: + newrelic_client: NewRelicClient instance + """ + self._client = newrelic_client + + def get_data_source(self) -> "NewRelicDataSource": + """Return the data source instance.""" + return self + + def get_client(self) -> NewRelicClient: + """Return the underlying NewRelicClient.""" + return self._client + + # ========================================================================= + # ACCOUNT OPERATIONS + # ========================================================================= + + async def list_accounts(self) -> GraphQLResponse: + """List all accessible accounts. + + Returns: + GraphQLResponse with account data under + actor.accounts + """ + query = NewRelicGraphQLOperations.get_operation_with_fragments( + "query", "list_accounts" + ) + variables: dict[str, Any] = {} + + try: + return await self._client.get_client().execute( + query=query, + variables=variables, + operation_name="listAccounts", + ) + except Exception as e: + return GraphQLResponse( + success=False, + message=f"Failed to list accounts: {str(e)}", + ) + + async def get_account(self, account_id: int) -> GraphQLResponse: + """Get a specific account by ID. + + Args: + account_id: NewRelic account ID + + Returns: + GraphQLResponse with account data under + actor.account + """ + query = NewRelicGraphQLOperations.get_operation_with_fragments( + "query", "get_account" + ) + variables: dict[str, Any] = {"accountId": account_id} + + try: + return await self._client.get_client().execute( + query=query, + variables=variables, + operation_name="getAccount", + ) + except Exception as e: + return GraphQLResponse( + success=False, + message=f"Failed to get account: {str(e)}", + ) + + # ========================================================================= + # NRQL QUERY OPERATIONS + # ========================================================================= + + async def nrql_query( + self, account_id: int, nrql_query: str + ) -> GraphQLResponse: + """Execute a NRQL query against an account. + + Args: + account_id: NewRelic account ID + nrql_query: NRQL query string (e.g., "SELECT count(*) FROM Transaction") + + Returns: + GraphQLResponse with query results under + actor.account.nrql.results + """ + query = NewRelicGraphQLOperations.get_operation_with_fragments( + "query", "nrql_query" + ) + variables: dict[str, Any] = { + "accountId": account_id, + "nrqlQuery": nrql_query, + } + + try: + return await self._client.get_client().execute( + query=query, + variables=variables, + operation_name="nrqlQuery", + ) + except Exception as e: + return GraphQLResponse( + success=False, + message=f"Failed to execute NRQL query: {str(e)}", + ) + + # ========================================================================= + # ENTITY OPERATIONS + # ========================================================================= + + async def list_entities( + self, + query_string: str | None = None, + entity_types: list[str] | None = None, + ) -> GraphQLResponse: + """Search for entities with optional filters. + + Args: + query_string: Search query string for entity names + entity_types: Filter by entity types + (e.g., ['APPLICATION', 'HOST', 'DASHBOARD']) + + Returns: + GraphQLResponse with entity search results under + actor.entitySearch.results.entities + """ + query = NewRelicGraphQLOperations.get_operation_with_fragments( + "query", "list_entities" + ) + variables: dict[str, Any] = {} + if query_string: + variables["queryString"] = query_string + if entity_types: + variables["entityTypes"] = entity_types + + try: + return await self._client.get_client().execute( + query=query, + variables=variables, + operation_name="listEntities", + ) + except Exception as e: + return GraphQLResponse( + success=False, + message=f"Failed to list entities: {str(e)}", + ) + + async def get_entity(self, guid: str) -> GraphQLResponse: + """Get a specific entity by GUID. + + Args: + guid: Entity GUID + + Returns: + GraphQLResponse with entity data under + actor.entity + """ + query = NewRelicGraphQLOperations.get_operation_with_fragments( + "query", "get_entity" + ) + variables: dict[str, Any] = {"guid": guid} + + try: + return await self._client.get_client().execute( + query=query, + variables=variables, + operation_name="getEntity", + ) + except Exception as e: + return GraphQLResponse( + success=False, + message=f"Failed to get entity: {str(e)}", + ) + + # ========================================================================= + # DASHBOARD OPERATIONS + # ========================================================================= + + async def list_dashboards(self) -> GraphQLResponse: + """List all dashboards. + + Returns: + GraphQLResponse with dashboard entities under + actor.entitySearch.results.entities + """ + query = NewRelicGraphQLOperations.get_operation_with_fragments( + "query", "list_dashboards" + ) + variables: dict[str, Any] = {} + + try: + return await self._client.get_client().execute( + query=query, + variables=variables, + operation_name="listDashboards", + ) + except Exception as e: + return GraphQLResponse( + success=False, + message=f"Failed to list dashboards: {str(e)}", + ) + + # ========================================================================= + # ALERT POLICY OPERATIONS + # ========================================================================= + + async def list_alert_policies( + self, + account_id: int, + cursor: str | None = None, + ) -> GraphQLResponse: + """List alert policies for an account. + + Args: + account_id: NewRelic account ID + cursor: Pagination cursor for next page + + Returns: + GraphQLResponse with alert policies under + actor.account.alerts.policiesSearch.policies + """ + query = NewRelicGraphQLOperations.get_operation_with_fragments( + "query", "list_alert_policies" + ) + variables: dict[str, Any] = {"accountId": account_id} + if cursor: + variables["cursor"] = cursor + + try: + return await self._client.get_client().execute( + query=query, + variables=variables, + operation_name="listAlertPolicies", + ) + except Exception as e: + return GraphQLResponse( + success=False, + message=f"Failed to list alert policies: {str(e)}", + ) + + # ========================================================================= + # SYNTHETICS MONITOR OPERATIONS + # ========================================================================= + + async def list_synthetics_monitors(self) -> GraphQLResponse: + """List synthetics monitors. + + Returns: + GraphQLResponse with synthetic monitor entities under + actor.entitySearch.results.entities + """ + query = NewRelicGraphQLOperations.get_operation_with_fragments( + "query", "list_synthetics_monitors" + ) + variables: dict[str, Any] = {} + + try: + return await self._client.get_client().execute( + query=query, + variables=variables, + operation_name="listSyntheticsMonitors", + ) + except Exception as e: + return GraphQLResponse( + success=False, + message=f"Failed to list synthetics monitors: {str(e)}", + ) + + # ========================================================================= + # APM APPLICATION OPERATIONS + # ========================================================================= + + async def get_application(self, guid: str) -> GraphQLResponse: + """Get APM application details by GUID. + + Args: + guid: Entity GUID for the APM application + + Returns: + GraphQLResponse with APM application data under + actor.entity + """ + query = NewRelicGraphQLOperations.get_operation_with_fragments( + "query", "get_application" + ) + variables: dict[str, Any] = {"guid": guid} + + try: + return await self._client.get_client().execute( + query=query, + variables=variables, + operation_name="getApplication", + ) + except Exception as e: + return GraphQLResponse( + success=False, + message=f"Failed to get application: {str(e)}", + ) diff --git a/backend/python/app/sources/external/nicecxone/example.py b/backend/python/app/sources/external/nicecxone/example.py new file mode 100644 index 000000000..53e5b11de --- /dev/null +++ b/backend/python/app/sources/external/nicecxone/example.py @@ -0,0 +1,185 @@ +# ruff: noqa + +""" +NICE CXone API Usage Examples + +This example demonstrates how to use the NICE CXone DataSource to interact with +the NICE CXone API, covering: +- Authentication (OAuth2 client_credentials, Bearer Token) +- Initializing the Client and DataSource +- Listing Agents and Agent States +- Listing Active Contacts +- Listing Skills, Teams, Campaigns +- Contact History Reporting + +Prerequisites: +For OAuth2 (client_credentials): +1. Register an application in the NICE CXone admin panel +2. Set NICECXONE_CLIENT_ID and NICECXONE_CLIENT_SECRET environment variables +3. Set NICECXONE_AUTH_DOMAIN (e.g., cxone.niceincontact.com) +4. Set NICECXONE_CLUSTER (e.g., c1, c2, etc.) + +For Bearer Token: +1. Set NICECXONE_ACCESS_TOKEN environment variable with your access token +2. Set NICECXONE_CLUSTER (e.g., c1, c2, etc.) +""" + +import asyncio +import json +import os + +from app.sources.client.nicecxone.nicecxone import ( + NiceCXoneClient, + NiceCXoneOAuthConfig, + NiceCXoneTokenConfig, + NiceCXoneResponse, +) +from app.sources.external.nicecxone.nicecxone import NiceCXoneDataSource + +# --- Configuration --- +# OAuth2 credentials +CLIENT_ID = os.getenv("NICECXONE_CLIENT_ID") +CLIENT_SECRET = os.getenv("NICECXONE_CLIENT_SECRET") +AUTH_DOMAIN = os.getenv("NICECXONE_AUTH_DOMAIN", "cxone.niceincontact.com") + +# Bearer Token +ACCESS_TOKEN = os.getenv("NICECXONE_ACCESS_TOKEN") + +# Cluster +CLUSTER = os.getenv("NICECXONE_CLUSTER", "c1") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: NiceCXoneResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + # Handle list-type responses + for key in ("agents", "contacts", "skills", "teams", "campaigns", + "evaluations", "contactHistory", "dialingRules"): + if isinstance(data, dict) and key in data: + items = data[key] + if isinstance(items, list): + print(f" Found {len(items)} {key}.") + if items: + print(f" Sample: {json.dumps(items[0], indent=2)[:400]}...") + return + # Generic response + print(f" Data: {json.dumps(data, indent=2)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + # 1. Initialize Client + print_section("Initializing NICE CXone Client") + + config = None + + # Priority 1: OAuth2 (client_credentials) + if CLIENT_ID and CLIENT_SECRET: + print(f" Using OAuth2 (client_credentials) authentication (cluster: {CLUSTER})") + config = NiceCXoneOAuthConfig( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + auth_domain=AUTH_DOMAIN, + cluster=CLUSTER, + ) + + # Priority 2: Bearer Token + if config is None and ACCESS_TOKEN: + print(f" Using Bearer Token authentication (cluster: {CLUSTER})") + config = NiceCXoneTokenConfig( + token=ACCESS_TOKEN, + cluster=CLUSTER, + ) + + if config is None: + print(" No valid authentication method found.") + print(" Please set one of the following:") + print(" - NICECXONE_CLIENT_ID and NICECXONE_CLIENT_SECRET (for OAuth2)") + print(" - NICECXONE_ACCESS_TOKEN (for Bearer Token)") + return + + client = NiceCXoneClient.build_with_config(config) + + # Ensure authentication for OAuth clients + inner_client = client.get_client() + if hasattr(inner_client, "ensure_authenticated"): + print(" Authenticating via OAuth...") + await inner_client.ensure_authenticated() + print(" Authentication successful.") + + data_source = NiceCXoneDataSource(client) + print("Client initialized successfully.") + + try: + # 2. Get Agents + print_section("Agents") + agents_resp = await data_source.get_agents(top=5) + print_result("Get Agents", agents_resp) + + # 3. Get Agent States + print_section("Agent States") + states_resp = await data_source.get_agent_states() + print_result("Get Agent States", states_resp) + + # 4. Get Active Contacts + print_section("Active Contacts") + contacts_resp = await data_source.get_active_contacts(top=5) + print_result("Get Active Contacts", contacts_resp) + + # 5. Get Skills + print_section("Skills") + skills_resp = await data_source.get_skills(top=5) + print_result("Get Skills", skills_resp) + + # 6. Get Teams + print_section("Teams") + teams_resp = await data_source.get_teams(top=5) + print_result("Get Teams", teams_resp) + + # 7. Get Campaigns + print_section("Campaigns") + campaigns_resp = await data_source.get_campaigns(top=5) + print_result("Get Campaigns", campaigns_resp) + + # 8. Get Contact History (last 7 days) + print_section("Contact History") + from datetime import datetime, timedelta + end_date = datetime.utcnow().isoformat() + "Z" + start_date = (datetime.utcnow() - timedelta(days=7)).isoformat() + "Z" + history_resp = await data_source.get_contact_history( + start_date=start_date, + end_date=end_date, + top=5, + ) + print_result("Get Contact History", history_resp) + + # 9. Get Dialing Rules + print_section("Dialing Rules") + rules_resp = await data_source.get_dialing_rules() + print_result("Get Dialing Rules", rules_resp) + + finally: + # Cleanup: Close the HTTP client session + print("\nClosing client connection...") + if hasattr(inner_client, "close"): + await inner_client.close() + + print("\n" + "=" * 80) + print(" All NICE CXone API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/nicecxone/nicecxone.py b/backend/python/app/sources/external/nicecxone/nicecxone.py new file mode 100644 index 000000000..1cb1f6ab4 --- /dev/null +++ b/backend/python/app/sources/external/nicecxone/nicecxone.py @@ -0,0 +1,661 @@ +# ruff: noqa +""" +NICE CXone REST API DataSource - Auto-generated API wrapper + +Generated from NICE CXone REST API v31.0 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.nicecxone.nicecxone import NiceCXoneClient, NiceCXoneResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class NiceCXoneDataSource: + """NICE CXone REST API DataSource + + Provides async wrapper methods for NICE CXone REST API operations: + - Agents management and state monitoring + - Contacts (active and history) + - Skills management + - Teams management + - Campaigns management + - Quality management evaluations + - Reporting and contact history + - Dialing rules + + The base URL is cluster-specific and determined by the NiceCXoneClient + configuration. Create a client with the desired cluster and pass it here. + + All methods return NiceCXoneResponse objects. + """ + + def __init__(self, client: NiceCXoneClient) -> None: + """Initialize with NiceCXoneClient. + + Args: + client: NiceCXoneClient instance with configured authentication and cluster + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'NiceCXoneDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> NiceCXoneClient: + """Return the underlying NiceCXoneClient.""" + return self._client + + # ----------------------------------------------------------------------- + # Agents + # ----------------------------------------------------------------------- + + async def get_agents( + self, + *, + updated_since: str | None = None, + skip: int | None = None, + top: int | None = None, + order_by: str | None = None, + is_active: bool | None = None, + ) -> NiceCXoneResponse: + """Get all agents + + Args: + updated_since: Filter agents updated since this date (ISO 8601) + skip: Number of records to skip for pagination + top: Number of records to return + order_by: Field to order results by + is_active: Filter by active status + + Returns: + NiceCXoneResponse with operation result + """ + query_params: dict[str, Any] = {} + if updated_since is not None: + query_params['updatedSince'] = updated_since + if skip is not None: + query_params['skip'] = str(skip) + if top is not None: + query_params['top'] = str(top) + if order_by is not None: + query_params['orderBy'] = order_by + if is_active is not None: + query_params['isActive'] = str(is_active).lower() + + url = self.base_url + "/agents" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return NiceCXoneResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_agents" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return NiceCXoneResponse(success=False, error=str(e), message="Failed to execute get_agents") + + async def get_agent( + self, + agent_id: str, + ) -> NiceCXoneResponse: + """Get a specific agent by ID + + Args: + agent_id: The agent ID + + Returns: + NiceCXoneResponse with operation result + """ + url = self.base_url + "/agents/{agent_id}".format(agent_id=agent_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return NiceCXoneResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_agent" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return NiceCXoneResponse(success=False, error=str(e), message="Failed to execute get_agent") + + async def get_agent_states( + self, + *, + updated_since: str | None = None, + fields: str | None = None, + ) -> NiceCXoneResponse: + """Get all agent states + + Args: + updated_since: Filter states updated since this date (ISO 8601) + fields: Comma-separated list of fields to include + + Returns: + NiceCXoneResponse with operation result + """ + query_params: dict[str, Any] = {} + if updated_since is not None: + query_params['updatedSince'] = updated_since + if fields is not None: + query_params['fields'] = fields + + url = self.base_url + "/agents/states" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return NiceCXoneResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_agent_states" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return NiceCXoneResponse(success=False, error=str(e), message="Failed to execute get_agent_states") + + # ----------------------------------------------------------------------- + # Contacts + # ----------------------------------------------------------------------- + + async def get_active_contacts( + self, + *, + updated_since: str | None = None, + skip: int | None = None, + top: int | None = None, + ) -> NiceCXoneResponse: + """Get all active contacts + + Args: + updated_since: Filter contacts updated since this date (ISO 8601) + skip: Number of records to skip for pagination + top: Number of records to return + + Returns: + NiceCXoneResponse with operation result + """ + query_params: dict[str, Any] = {} + if updated_since is not None: + query_params['updatedSince'] = updated_since + if skip is not None: + query_params['skip'] = str(skip) + if top is not None: + query_params['top'] = str(top) + + url = self.base_url + "/contacts/active" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return NiceCXoneResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_active_contacts" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return NiceCXoneResponse(success=False, error=str(e), message="Failed to execute get_active_contacts") + + async def get_contact( + self, + contact_id: str, + ) -> NiceCXoneResponse: + """Get a specific contact by ID + + Args: + contact_id: The contact ID + + Returns: + NiceCXoneResponse with operation result + """ + url = self.base_url + "/contacts/{contact_id}".format(contact_id=contact_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return NiceCXoneResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_contact" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return NiceCXoneResponse(success=False, error=str(e), message="Failed to execute get_contact") + + # ----------------------------------------------------------------------- + # Skills + # ----------------------------------------------------------------------- + + async def get_skills( + self, + *, + updated_since: str | None = None, + skip: int | None = None, + top: int | None = None, + order_by: str | None = None, + is_active: bool | None = None, + media_type_id: int | None = None, + ) -> NiceCXoneResponse: + """Get all skills + + Args: + updated_since: Filter skills updated since this date (ISO 8601) + skip: Number of records to skip for pagination + top: Number of records to return + order_by: Field to order results by + is_active: Filter by active status + media_type_id: Filter by media type ID + + Returns: + NiceCXoneResponse with operation result + """ + query_params: dict[str, Any] = {} + if updated_since is not None: + query_params['updatedSince'] = updated_since + if skip is not None: + query_params['skip'] = str(skip) + if top is not None: + query_params['top'] = str(top) + if order_by is not None: + query_params['orderBy'] = order_by + if is_active is not None: + query_params['isActive'] = str(is_active).lower() + if media_type_id is not None: + query_params['mediaTypeId'] = str(media_type_id) + + url = self.base_url + "/skills" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return NiceCXoneResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_skills" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return NiceCXoneResponse(success=False, error=str(e), message="Failed to execute get_skills") + + async def get_skill( + self, + skill_id: str, + ) -> NiceCXoneResponse: + """Get a specific skill by ID + + Args: + skill_id: The skill ID + + Returns: + NiceCXoneResponse with operation result + """ + url = self.base_url + "/skills/{skill_id}".format(skill_id=skill_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return NiceCXoneResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_skill" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return NiceCXoneResponse(success=False, error=str(e), message="Failed to execute get_skill") + + # ----------------------------------------------------------------------- + # Teams + # ----------------------------------------------------------------------- + + async def get_teams( + self, + *, + updated_since: str | None = None, + skip: int | None = None, + top: int | None = None, + order_by: str | None = None, + is_active: bool | None = None, + ) -> NiceCXoneResponse: + """Get all teams + + Args: + updated_since: Filter teams updated since this date (ISO 8601) + skip: Number of records to skip for pagination + top: Number of records to return + order_by: Field to order results by + is_active: Filter by active status + + Returns: + NiceCXoneResponse with operation result + """ + query_params: dict[str, Any] = {} + if updated_since is not None: + query_params['updatedSince'] = updated_since + if skip is not None: + query_params['skip'] = str(skip) + if top is not None: + query_params['top'] = str(top) + if order_by is not None: + query_params['orderBy'] = order_by + if is_active is not None: + query_params['isActive'] = str(is_active).lower() + + url = self.base_url + "/teams" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return NiceCXoneResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_teams" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return NiceCXoneResponse(success=False, error=str(e), message="Failed to execute get_teams") + + async def get_team( + self, + team_id: str, + ) -> NiceCXoneResponse: + """Get a specific team by ID + + Args: + team_id: The team ID + + Returns: + NiceCXoneResponse with operation result + """ + url = self.base_url + "/teams/{team_id}".format(team_id=team_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return NiceCXoneResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_team" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return NiceCXoneResponse(success=False, error=str(e), message="Failed to execute get_team") + + # ----------------------------------------------------------------------- + # Campaigns + # ----------------------------------------------------------------------- + + async def get_campaigns( + self, + *, + updated_since: str | None = None, + skip: int | None = None, + top: int | None = None, + order_by: str | None = None, + is_active: bool | None = None, + ) -> NiceCXoneResponse: + """Get all campaigns + + Args: + updated_since: Filter campaigns updated since this date (ISO 8601) + skip: Number of records to skip for pagination + top: Number of records to return + order_by: Field to order results by + is_active: Filter by active status + + Returns: + NiceCXoneResponse with operation result + """ + query_params: dict[str, Any] = {} + if updated_since is not None: + query_params['updatedSince'] = updated_since + if skip is not None: + query_params['skip'] = str(skip) + if top is not None: + query_params['top'] = str(top) + if order_by is not None: + query_params['orderBy'] = order_by + if is_active is not None: + query_params['isActive'] = str(is_active).lower() + + url = self.base_url + "/campaigns" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return NiceCXoneResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_campaigns" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return NiceCXoneResponse(success=False, error=str(e), message="Failed to execute get_campaigns") + + async def get_campaign( + self, + campaign_id: str, + ) -> NiceCXoneResponse: + """Get a specific campaign by ID + + Args: + campaign_id: The campaign ID + + Returns: + NiceCXoneResponse with operation result + """ + url = self.base_url + "/campaigns/{campaign_id}".format(campaign_id=campaign_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return NiceCXoneResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_campaign" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return NiceCXoneResponse(success=False, error=str(e), message="Failed to execute get_campaign") + + # ----------------------------------------------------------------------- + # Quality Management Evaluations + # ----------------------------------------------------------------------- + + async def get_quality_management_evaluations( + self, + *, + start_date: str | None = None, + end_date: str | None = None, + skip: int | None = None, + top: int | None = None, + ) -> NiceCXoneResponse: + """Get quality management evaluations + + Args: + start_date: Start date filter (ISO 8601) + end_date: End date filter (ISO 8601) + skip: Number of records to skip for pagination + top: Number of records to return + + Returns: + NiceCXoneResponse with operation result + """ + query_params: dict[str, Any] = {} + if start_date is not None: + query_params['startDate'] = start_date + if end_date is not None: + query_params['endDate'] = end_date + if skip is not None: + query_params['skip'] = str(skip) + if top is not None: + query_params['top'] = str(top) + + url = self.base_url + "/wfo-data/quality-management/evaluations" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return NiceCXoneResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_quality_management_evaluations" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return NiceCXoneResponse(success=False, error=str(e), message="Failed to execute get_quality_management_evaluations") + + # ----------------------------------------------------------------------- + # Reporting + # ----------------------------------------------------------------------- + + async def get_contact_history( + self, + *, + start_date: str, + end_date: str, + skip: int | None = None, + top: int | None = None, + order_by: str | None = None, + ) -> NiceCXoneResponse: + """Get contact history report + + Args: + start_date: Start date for the report (ISO 8601, required) + end_date: End date for the report (ISO 8601, required) + skip: Number of records to skip for pagination + top: Number of records to return + order_by: Field to order results by + + Returns: + NiceCXoneResponse with operation result + """ + query_params: dict[str, Any] = {} + query_params['startDate'] = start_date + query_params['endDate'] = end_date + if skip is not None: + query_params['skip'] = str(skip) + if top is not None: + query_params['top'] = str(top) + if order_by is not None: + query_params['orderBy'] = order_by + + url = self.base_url + "/reporting/contact-history" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return NiceCXoneResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_contact_history" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return NiceCXoneResponse(success=False, error=str(e), message="Failed to execute get_contact_history") + + # ----------------------------------------------------------------------- + # Dialing Rules + # ----------------------------------------------------------------------- + + async def get_dialing_rules( + self, + ) -> NiceCXoneResponse: + """Get all dialing rules + + Returns: + NiceCXoneResponse with operation result + """ + url = self.base_url + "/dialing-rules" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return NiceCXoneResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_dialing_rules" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return NiceCXoneResponse(success=False, error=str(e), message="Failed to execute get_dialing_rules") diff --git a/backend/python/app/sources/external/nicecxone/run_generator.py b/backend/python/app/sources/external/nicecxone/run_generator.py new file mode 100644 index 000000000..85abca1ce --- /dev/null +++ b/backend/python/app/sources/external/nicecxone/run_generator.py @@ -0,0 +1,356 @@ +# ruff: noqa +""" +NICE CXone DataSource Code Generator + +This script generates the NiceCXoneDataSource class with all API endpoint +wrapper methods based on the NICE CXone REST API v31.0 specification. + +The generated code follows the pattern established by ClickUp and other +connectors in this project, using HTTPRequest/HTTPResponse for all API calls. + +Usage: + python -m app.sources.external.nicecxone.run_generator + +Output: + Prints the generated Python source code for the NiceCXoneDataSource class + to stdout. Redirect to a file to save: + + python -m app.sources.external.nicecxone.run_generator > \ + app/sources/external/nicecxone/nicecxone.py +""" + +from __future__ import annotations + +ENDPOINTS = [ + { + "name": "get_agents", + "method": "GET", + "path": "/agents", + "doc": "Get all agents", + "path_params": [], + "query_params": [ + ("updated_since", "str | None", "updatedSince", "Filter agents updated since this date (ISO 8601)"), + ("skip", "int | None", "skip", "Number of records to skip for pagination"), + ("top", "int | None", "top", "Number of records to return"), + ("order_by", "str | None", "orderBy", "Field to order results by"), + ("is_active", "bool | None", "isActive", "Filter by active status"), + ], + "body_params": [], + }, + { + "name": "get_agent", + "method": "GET", + "path": "/agents/{agent_id}", + "doc": "Get a specific agent by ID", + "path_params": [("agent_id", "str", "The agent ID")], + "query_params": [], + "body_params": [], + }, + { + "name": "get_agent_states", + "method": "GET", + "path": "/agents/states", + "doc": "Get all agent states", + "path_params": [], + "query_params": [ + ("updated_since", "str | None", "updatedSince", "Filter states updated since this date (ISO 8601)"), + ("fields", "str | None", "fields", "Comma-separated list of fields to include"), + ], + "body_params": [], + }, + { + "name": "get_active_contacts", + "method": "GET", + "path": "/contacts/active", + "doc": "Get all active contacts", + "path_params": [], + "query_params": [ + ("updated_since", "str | None", "updatedSince", "Filter contacts updated since this date (ISO 8601)"), + ("skip", "int | None", "skip", "Number of records to skip for pagination"), + ("top", "int | None", "top", "Number of records to return"), + ], + "body_params": [], + }, + { + "name": "get_contact", + "method": "GET", + "path": "/contacts/{contact_id}", + "doc": "Get a specific contact by ID", + "path_params": [("contact_id", "str", "The contact ID")], + "query_params": [], + "body_params": [], + }, + { + "name": "get_skills", + "method": "GET", + "path": "/skills", + "doc": "Get all skills", + "path_params": [], + "query_params": [ + ("updated_since", "str | None", "updatedSince", "Filter skills updated since this date (ISO 8601)"), + ("skip", "int | None", "skip", "Number of records to skip for pagination"), + ("top", "int | None", "top", "Number of records to return"), + ("order_by", "str | None", "orderBy", "Field to order results by"), + ("is_active", "bool | None", "isActive", "Filter by active status"), + ("media_type_id", "int | None", "mediaTypeId", "Filter by media type ID"), + ], + "body_params": [], + }, + { + "name": "get_skill", + "method": "GET", + "path": "/skills/{skill_id}", + "doc": "Get a specific skill by ID", + "path_params": [("skill_id", "str", "The skill ID")], + "query_params": [], + "body_params": [], + }, + { + "name": "get_teams", + "method": "GET", + "path": "/teams", + "doc": "Get all teams", + "path_params": [], + "query_params": [ + ("updated_since", "str | None", "updatedSince", "Filter teams updated since this date (ISO 8601)"), + ("skip", "int | None", "skip", "Number of records to skip for pagination"), + ("top", "int | None", "top", "Number of records to return"), + ("order_by", "str | None", "orderBy", "Field to order results by"), + ("is_active", "bool | None", "isActive", "Filter by active status"), + ], + "body_params": [], + }, + { + "name": "get_team", + "method": "GET", + "path": "/teams/{team_id}", + "doc": "Get a specific team by ID", + "path_params": [("team_id", "str", "The team ID")], + "query_params": [], + "body_params": [], + }, + { + "name": "get_campaigns", + "method": "GET", + "path": "/campaigns", + "doc": "Get all campaigns", + "path_params": [], + "query_params": [ + ("updated_since", "str | None", "updatedSince", "Filter campaigns updated since this date (ISO 8601)"), + ("skip", "int | None", "skip", "Number of records to skip for pagination"), + ("top", "int | None", "top", "Number of records to return"), + ("order_by", "str | None", "orderBy", "Field to order results by"), + ("is_active", "bool | None", "isActive", "Filter by active status"), + ], + "body_params": [], + }, + { + "name": "get_campaign", + "method": "GET", + "path": "/campaigns/{campaign_id}", + "doc": "Get a specific campaign by ID", + "path_params": [("campaign_id", "str", "The campaign ID")], + "query_params": [], + "body_params": [], + }, + { + "name": "get_quality_management_evaluations", + "method": "GET", + "path": "/wfo-data/quality-management/evaluations", + "doc": "Get quality management evaluations", + "path_params": [], + "query_params": [ + ("start_date", "str | None", "startDate", "Start date filter (ISO 8601)"), + ("end_date", "str | None", "endDate", "End date filter (ISO 8601)"), + ("skip", "int | None", "skip", "Number of records to skip for pagination"), + ("top", "int | None", "top", "Number of records to return"), + ], + "body_params": [], + }, + { + "name": "get_contact_history", + "method": "GET", + "path": "/reporting/contact-history", + "doc": "Get contact history report", + "path_params": [], + "query_params": [ + ("start_date", "str", "startDate", "Start date for the report (ISO 8601, required)"), + ("end_date", "str", "endDate", "End date for the report (ISO 8601, required)"), + ("skip", "int | None", "skip", "Number of records to skip for pagination"), + ("top", "int | None", "top", "Number of records to return"), + ("order_by", "str | None", "orderBy", "Field to order results by"), + ], + "body_params": [], + }, + { + "name": "get_dialing_rules", + "method": "GET", + "path": "/dialing-rules", + "doc": "Get all dialing rules", + "path_params": [], + "query_params": [], + "body_params": [], + }, +] + + +def generate_method(ep: dict) -> str: + """Generate a single async method for an endpoint.""" + name = ep["name"] + method = ep["method"] + path = ep["path"] + doc = ep["doc"] + path_params = ep.get("path_params", []) + query_params = ep.get("query_params", []) + body_params = ep.get("body_params", []) + + # Build signature + sig_parts = ["self"] + for pp_name, pp_type, _pp_doc in path_params: + sig_parts.append(f"{pp_name}: {pp_type}") + + # Required query params (no None default) + required_qp = [q for q in query_params if "None" not in q[1]] + optional_qp = [q for q in query_params if "None" in q[1]] + + if required_qp or optional_qp or body_params: + sig_parts.append("*") + for qp_name, qp_type, _qp_api, _qp_doc in required_qp: + sig_parts.append(f"{qp_name}: {qp_type}") + for qp_name, qp_type, _qp_api, _qp_doc in optional_qp: + sig_parts.append(f"{qp_name}: {qp_type} = None") + for bp_name, bp_type, _bp_doc in body_params: + sig_parts.append(f"{bp_name}: {bp_type} = None") + + sig = ",\n ".join(sig_parts) + + # Build docstring args + doc_args = [] + for pp_name, _pp_type, pp_doc in path_params: + doc_args.append(f" {pp_name}: {pp_doc}") + for qp_name, _qp_type, _qp_api, qp_doc in query_params: + doc_args.append(f" {qp_name}: {qp_doc}") + for bp_name, _bp_type, bp_doc in body_params: + doc_args.append(f" {bp_name}: {bp_doc}") + + args_section = "" + if doc_args: + args_section = "\n\n Args:\n" + "\n".join(doc_args) + + # Build query params block + qp_block = "" + if query_params: + qp_block = "\n query_params: dict[str, Any] = {}\n" + for qp_name, qp_type, qp_api, _ in query_params: + if "None" not in qp_type: + if "bool" in qp_type: + qp_block += f" query_params['{qp_api}'] = str({qp_name}).lower()\n" + elif "int" in qp_type: + qp_block += f" query_params['{qp_api}'] = str({qp_name})\n" + else: + qp_block += f" query_params['{qp_api}'] = {qp_name}\n" + else: + if "bool" in qp_type: + qp_block += f" if {qp_name} is not None:\n query_params['{qp_api}'] = str({qp_name}).lower()\n" + elif "int" in qp_type: + qp_block += f" if {qp_name} is not None:\n query_params['{qp_api}'] = str({qp_name})\n" + else: + qp_block += f" if {qp_name} is not None:\n query_params['{qp_api}'] = {qp_name}\n" + + # Build URL + if path_params: + format_args = ", ".join(f"{p[0]}={p[0]}" for p in path_params) + url_line = f' url = self.base_url + "{path}".format({format_args})' + else: + url_line = f' url = self.base_url + "{path}"' + + # Build request kwargs + req_kwargs = f'method="{method}",\n url=url,\n headers={{"Content-Type": "application/json"}}' + if query_params: + req_kwargs += ",\n query=query_params" + if body_params: + req_kwargs += ",\n body=body" + + # Body block + body_block = "" + if body_params: + body_block = "\n body: dict[str, Any] = {}\n" + for bp_name, _bp_type, _ in body_params: + body_block += f" if {bp_name} is not None:\n body['{bp_name}'] = {bp_name}\n" + + return f''' async def {name}( + {sig} + ) -> NiceCXoneResponse: + """{doc}{args_section} + + Returns: + NiceCXoneResponse with operation result + """ +{qp_block}{url_line} +{body_block} + try: + request = HTTPRequest( + {req_kwargs}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return NiceCXoneResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed {name}" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {{response.status}}" + ) + except Exception as e: + return NiceCXoneResponse(success=False, error=str(e), message="Failed to execute {name}") +''' + + +def generate_datasource() -> str: + """Generate the full NiceCXoneDataSource module.""" + header = '''# ruff: noqa +""" +NICE CXone REST API DataSource - Auto-generated API wrapper + +Generated from NICE CXone REST API v31.0 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.nicecxone.nicecxone import NiceCXoneClient, NiceCXoneResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class NiceCXoneDataSource: + """NICE CXone REST API DataSource + + Provides async wrapper methods for NICE CXone REST API operations. + All methods return NiceCXoneResponse objects. + """ + + def __init__(self, client: NiceCXoneClient) -> None: + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'NiceCXoneDataSource': + return self + + def get_client(self) -> NiceCXoneClient: + return self._client + +''' + methods = "\n".join(generate_method(ep) for ep in ENDPOINTS) + return header + methods + + +if __name__ == "__main__": + print(generate_datasource()) diff --git a/backend/python/app/sources/external/okta/example.py b/backend/python/app/sources/external/okta/example.py new file mode 100644 index 000000000..514c0c184 --- /dev/null +++ b/backend/python/app/sources/external/okta/example.py @@ -0,0 +1,119 @@ +# ruff: noqa + +""" +Okta API Usage Examples (SDK-backed) + +This example demonstrates how to use the Okta DataSource backed by the +official ``okta`` Python SDK, covering: +- Authentication (API Token) +- Initialising the Client and DataSource +- Listing users, groups, and applications +- Getting system logs +- Listing authorization servers and policies + +Prerequisites: +For API Token: +1. Log in to Okta Admin Console +2. Go to Security > API > Tokens > Create Token +3. Set OKTA_API_TOKEN and OKTA_DOMAIN environment variables + +OKTA_DOMAIN should be the full org URL (e.g. "https://dev-123456.okta.com") +or just the subdomain portion (e.g. "dev-123456"). +""" + +import asyncio +import os + +from app.sources.client.okta.okta import ( + OktaApiTokenConfig, + OktaClient, + OktaResponse, +) +from app.sources.external.okta.okta import OktaDataSource + +# --- Configuration --- +API_TOKEN = os.getenv("OKTA_API_TOKEN") +DOMAIN = os.getenv("OKTA_DOMAIN") # e.g. "dev-123456" or "https://dev-123456.okta.com" + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: OktaResponse): + if response.success: + print(f" {name}: Success") + if response.data: + data = response.data + if isinstance(data, list): + print(f" Found {len(data)} items.") + else: + print(f" Data type: {type(data).__name__}") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + print_section("Initialising Okta Client (SDK-backed)") + + if not DOMAIN: + print(" OKTA_DOMAIN is required.") + return + + if not API_TOKEN: + print(" OKTA_API_TOKEN is required.") + return + + print(" Using API Token authentication") + config = OktaApiTokenConfig(api_token=API_TOKEN, domain=DOMAIN) + client = OktaClient.build_with_config(config) + data_source = OktaDataSource(client) + print(f" Client initialised for {DOMAIN}") + + # 1. List Users + print_section("Users") + users_resp = await data_source.list_users(limit=5) + print_result("List Users", users_resp) + + # 2. Get Current User + print_section("Current User") + me_resp = await data_source.get_current_user() + print_result("Get Current User", me_resp) + + # 3. List Groups + print_section("Groups") + groups_resp = await data_source.list_groups(limit=5) + print_result("List Groups", groups_resp) + + # 4. List Applications + print_section("Applications") + apps_resp = await data_source.list_applications(limit=5) + print_result("List Applications", apps_resp) + + # 5. System Logs + print_section("System Logs (Recent)") + logs_resp = await data_source.get_system_logs(limit=5) + print_result("Get System Logs", logs_resp) + + # 6. Authorization Servers + print_section("Authorization Servers") + auth_servers_resp = await data_source.list_authorization_servers() + print_result("List Authorization Servers", auth_servers_resp) + + # 7. Policies + print_section("Policies") + policies_resp = await data_source.list_policies() + print_result("List Policies", policies_resp) + + print("\n" + "=" * 80) + print(" All Okta API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/okta/okta.py b/backend/python/app/sources/external/okta/okta.py new file mode 100644 index 000000000..d9dc2cd82 --- /dev/null +++ b/backend/python/app/sources/external/okta/okta.py @@ -0,0 +1,133 @@ +# ruff: noqa +from __future__ import annotations + +from typing import Dict, Optional, Union, cast + +from okta.client import Client as OktaSDKClient # type: ignore[reportMissingImports] + +from app.sources.client.okta.okta import OktaResponse + +class OktaDataSource: + """ + Strict, typed async wrapper over okta-sdk-python for common Okta business operations. + + Accepts either an okta SDK `Client` instance *or* any object with `.get_sdk() -> Client`. + All methods are async because the okta SDK is natively async. + """ + + def __init__(self, client_or_sdk: Union[OktaSDKClient, object]) -> None: # type: ignore[reportUnknownParameterType] + super().__init__() + if hasattr(client_or_sdk, "get_sdk"): # type: ignore[reportUnknownArgumentType] + sdk_obj = getattr(client_or_sdk, "get_sdk")() # type: ignore[reportUnknownArgumentType] + self._sdk: OktaSDKClient = cast(OktaSDKClient, sdk_obj) # type: ignore[reportUnknownMemberType] + else: + self._sdk = cast(OktaSDKClient, client_or_sdk) # type: ignore[reportUnknownMemberType] + + # ---- helpers ---- + @staticmethod + def _params(**kwargs: object) -> Dict[str, object]: + out: Dict[str, object] = {} + for k, v in kwargs.items(): + if v is None: + continue + if isinstance(v, (list, dict)) and len(v) == 0: # type: ignore[reportUnknownArgumentType] + continue + out[k] = v + return out + async def list_users(self, q: Optional[str] = None, filter_expr: Optional[str] = None, search: Optional[str] = None, limit: Optional[int] = None, after: Optional[str] = None) -> OktaResponse: + """List users with optional search/filter. [users]""" + query_params = self._params(q=q, filter=filter_expr, search=search, limit=limit, after=after) + users, resp, err = await self._sdk.list_users(query_params=query_params) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + if err: + return OktaResponse(success=False, error=str(err), message='Failed to list users') # type: ignore[reportUnknownArgumentType] + return OktaResponse(success=True, data=users) # type: ignore[reportUnknownArgumentType] + async def get_user(self, user_id: str) -> OktaResponse: + """Get a single user by ID or login. [users]""" + user, resp, err = await self._sdk.get_user(user_id) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + if err: + return OktaResponse(success=False, error=str(err), message='Failed to get user') # type: ignore[reportUnknownArgumentType] + return OktaResponse(success=True, data=user) # type: ignore[reportUnknownArgumentType] + async def get_current_user(self) -> OktaResponse: + """Get the current authenticated user (me). [users]""" + user, resp, err = await self._sdk.get_user('me') # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + if err: + return OktaResponse(success=False, error=str(err), message='Failed to get current user') # type: ignore[reportUnknownArgumentType] + return OktaResponse(success=True, data=user) # type: ignore[reportUnknownArgumentType] + async def list_groups(self, q: Optional[str] = None, filter_expr: Optional[str] = None, limit: Optional[int] = None, after: Optional[str] = None) -> OktaResponse: + """List groups with optional search/filter. [groups]""" + query_params = self._params(q=q, filter=filter_expr, limit=limit, after=after) + groups, resp, err = await self._sdk.list_groups(query_params=query_params) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + if err: + return OktaResponse(success=False, error=str(err), message='Failed to list groups') # type: ignore[reportUnknownArgumentType] + return OktaResponse(success=True, data=groups) # type: ignore[reportUnknownArgumentType] + async def get_group(self, group_id: str) -> OktaResponse: + """Get a single group by ID. [groups]""" + group, resp, err = await self._sdk.get_group(group_id) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + if err: + return OktaResponse(success=False, error=str(err), message='Failed to get group') # type: ignore[reportUnknownArgumentType] + return OktaResponse(success=True, data=group) # type: ignore[reportUnknownArgumentType] + async def list_group_members(self, group_id: str, limit: Optional[int] = None, after: Optional[str] = None) -> OktaResponse: + """List members of a group. [groups]""" + query_params = self._params(limit=limit, after=after) + users, resp, err = await self._sdk.list_group_users(group_id, query_params=query_params) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + if err: + return OktaResponse(success=False, error=str(err), message='Failed to list group members') # type: ignore[reportUnknownArgumentType] + return OktaResponse(success=True, data=users) # type: ignore[reportUnknownArgumentType] + async def list_applications(self, q: Optional[str] = None, limit: Optional[int] = None, after: Optional[str] = None) -> OktaResponse: + """List applications. [apps]""" + query_params = self._params(q=q, limit=limit, after=after) + apps, resp, err = await self._sdk.list_applications(query_params=query_params) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + if err: + return OktaResponse(success=False, error=str(err), message='Failed to list applications') # type: ignore[reportUnknownArgumentType] + return OktaResponse(success=True, data=apps) # type: ignore[reportUnknownArgumentType] + async def get_application(self, app_id: str) -> OktaResponse: + """Get a specific application by ID. [apps]""" + app, resp, err = await self._sdk.get_application(app_id) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + if err: + return OktaResponse(success=False, error=str(err), message='Failed to get application') # type: ignore[reportUnknownArgumentType] + return OktaResponse(success=True, data=app) # type: ignore[reportUnknownArgumentType] + async def list_application_users(self, app_id: str, limit: Optional[int] = None, after: Optional[str] = None) -> OktaResponse: + """List users assigned to an application. [apps]""" + query_params = self._params(limit=limit, after=after) + users, resp, err = await self._sdk.list_application_users(app_id, query_params=query_params) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + if err: + return OktaResponse(success=False, error=str(err), message='Failed to list application users') # type: ignore[reportUnknownArgumentType] + return OktaResponse(success=True, data=users) # type: ignore[reportUnknownArgumentType] + async def get_system_logs(self, since: Optional[str] = None, until: Optional[str] = None, filter_expr: Optional[str] = None, q: Optional[str] = None, limit: Optional[int] = None, after: Optional[str] = None) -> OktaResponse: + """Get system log events with optional filters. [logs]""" + query_params = self._params(since=since, until=until, filter=filter_expr, q=q, limit=limit, after=after) + logs, resp, err = await self._sdk.get_logs(query_params=query_params) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + if err: + return OktaResponse(success=False, error=str(err), message='Failed to get system logs') # type: ignore[reportUnknownArgumentType] + return OktaResponse(success=True, data=logs) # type: ignore[reportUnknownArgumentType] + async def list_authorization_servers(self) -> OktaResponse: + """List authorization servers. [auth_servers]""" + servers, resp, err = await self._sdk.list_authorization_servers() # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + if err: + return OktaResponse(success=False, error=str(err), message='Failed to list authorization servers') # type: ignore[reportUnknownArgumentType] + return OktaResponse(success=True, data=servers) # type: ignore[reportUnknownArgumentType] + async def get_authorization_server(self, auth_server_id: str) -> OktaResponse: + """Get a specific authorization server. [auth_servers]""" + server, resp, err = await self._sdk.get_authorization_server(auth_server_id) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + if err: + return OktaResponse(success=False, error=str(err), message='Failed to get authorization server') # type: ignore[reportUnknownArgumentType] + return OktaResponse(success=True, data=server) # type: ignore[reportUnknownArgumentType] + async def list_policies(self, type_filter: Optional[str] = None) -> OktaResponse: + """List policies with optional type filter. [policies]""" + query_params = self._params(type=type_filter) + policies, resp, err = await self._sdk.list_policies(query_params=query_params) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + if err: + return OktaResponse(success=False, error=str(err), message='Failed to list policies') # type: ignore[reportUnknownArgumentType] + return OktaResponse(success=True, data=policies) # type: ignore[reportUnknownArgumentType] + async def get_policy(self, policy_id: str) -> OktaResponse: + """Get a specific policy by ID. [policies]""" + policy, resp, err = await self._sdk.get_policy(policy_id) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + if err: + return OktaResponse(success=False, error=str(err), message='Failed to get policy') # type: ignore[reportUnknownArgumentType] + return OktaResponse(success=True, data=policy) # type: ignore[reportUnknownArgumentType] + async def list_assigned_roles_for_user(self, user_id: str) -> OktaResponse: + """List roles assigned to a user. [roles]""" + roles, resp, err = await self._sdk.list_assigned_roles_for_user(user_id) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + if err: + return OktaResponse(success=False, error=str(err), message='Failed to list roles for user') # type: ignore[reportUnknownArgumentType] + return OktaResponse(success=True, data=roles) # type: ignore[reportUnknownArgumentType] diff --git a/backend/python/app/sources/external/onelogin/example.py b/backend/python/app/sources/external/onelogin/example.py new file mode 100644 index 000000000..67a259bf8 --- /dev/null +++ b/backend/python/app/sources/external/onelogin/example.py @@ -0,0 +1,122 @@ +# ruff: noqa + +""" +OneLogin API Usage Examples (SDK-backed) + +This example demonstrates how to use the OneLogin DataSource backed by the +official ``onelogin`` Python SDK, covering: +- Authentication (OAuth2 client_credentials) +- Initialising the Client and DataSource +- Listing Users, Groups, Roles, Apps +- Fetching Events and Privileges + +Prerequisites: +1. Create an API credential pair in OneLogin Admin portal + (Developers > API Credentials) +2. Set ONELOGIN_CLIENT_ID and ONELOGIN_CLIENT_SECRET environment variables +3. Optionally set ONELOGIN_REGION (default: "us", options: "us", "eu") +""" + +import os + +from app.sources.client.onelogin.onelogin import ( + OneLoginClient, + OneLoginClientCredentialsConfig, + OneLoginResponse, +) +from app.sources.external.onelogin.onelogin import OneLoginDataSource + +# --- Configuration --- +CLIENT_ID = os.getenv("ONELOGIN_CLIENT_ID") +CLIENT_SECRET = os.getenv("ONELOGIN_CLIENT_SECRET") +REGION = os.getenv("ONELOGIN_REGION", "us") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: OneLoginResponse): + if response.success: + print(f" {name}: Success") + if response.data: + data = response.data + if isinstance(data, list): + print(f" Found {len(data)} items.") + else: + print(f" Data type: {type(data).__name__}") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +def main() -> None: + print_section("Initialising OneLogin Client (SDK-backed)") + + if not (CLIENT_ID and CLIENT_SECRET): + print(" No valid authentication method found.") + print(" Please set ONELOGIN_CLIENT_ID and ONELOGIN_CLIENT_SECRET") + return + + print(f" Using OAuth2 client_credentials authentication (region: {REGION})") + config = OneLoginClientCredentialsConfig( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + region=REGION, + ) + + client = OneLoginClient.build_with_config(config) + data_source = OneLoginDataSource(client) + print(" Client initialised successfully.") + + # 1. List Users + print_section("Users") + users_resp = data_source.list_users(limit=10) + print_result("List Users", users_resp) + + # 2. List Groups + print_section("Groups") + groups_resp = data_source.list_groups() + print_result("List Groups", groups_resp) + + # 3. List Roles + print_section("Roles") + roles_resp = data_source.list_roles() + print_result("List Roles", roles_resp) + + # 4. List Apps + print_section("Apps") + apps_resp = data_source.list_apps(limit=10) + print_result("List Apps", apps_resp) + + # 5. List Events + print_section("Events") + events_resp = data_source.list_events(limit=10) + print_result("List Events", events_resp) + + # 6. List Privileges + print_section("Privileges") + privs_resp = data_source.list_privileges() + print_result("List Privileges", privs_resp) + + # 7. List Mappings + print_section("Mappings") + mappings_resp = data_source.list_mappings() + print_result("List Mappings", mappings_resp) + + # 8. List Brands + print_section("Brands") + brands_resp = data_source.list_brands() + print_result("List Brands", brands_resp) + + print("\n" + "=" * 80) + print(" All OneLogin API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + main() diff --git a/backend/python/app/sources/external/onelogin/onelogin.py b/backend/python/app/sources/external/onelogin/onelogin.py new file mode 100644 index 000000000..315ba696e --- /dev/null +++ b/backend/python/app/sources/external/onelogin/onelogin.py @@ -0,0 +1,114 @@ +# ruff: noqa +from __future__ import annotations + +from typing import Any, Dict, Optional, Union, cast + +import onelogin # type: ignore[reportMissingImports] + +from app.sources.client.onelogin.onelogin import OneLoginResponse + +class OneLoginDataSource: + """ + Strict, typed wrapper over onelogin-python-sdk for common OneLogin business operations. + + Accepts either a onelogin `ApiClient` instance *or* any object with `.get_sdk() -> ApiClient`. + """ + + def __init__(self, client_or_sdk: Union[onelogin.ApiClient, object]) -> None: # type: ignore[reportUnknownMemberType] + super().__init__() + if hasattr(client_or_sdk, "get_sdk"): # type: ignore[reportUnknownArgumentType] + sdk_obj = getattr(client_or_sdk, "get_sdk")() # type: ignore[reportUnknownArgumentType] + self._sdk: onelogin.ApiClient = cast(onelogin.ApiClient, sdk_obj) # type: ignore[reportUnknownMemberType] + else: + self._sdk = cast(onelogin.ApiClient, client_or_sdk) # type: ignore[reportUnknownMemberType] + + # ---- helpers ---- + @staticmethod + def _params(**kwargs: object) -> Dict[str, object]: + out: Dict[str, object] = {} + for k, v in kwargs.items(): + if v is None: + continue + if isinstance(v, (list, dict)) and len(v) == 0: # type: ignore[reportUnknownArgumentType] + continue + out[k] = v + return out + def list_users(self, limit: Optional[int] = None, page: Optional[int] = None, search: Optional[str] = None) -> OneLoginResponse: + """List all users. [users]""" + api = onelogin.UsersV2Api(self._sdk) # type: ignore[reportUnknownMemberType] + params = self._params(limit=limit, page=page, search=search) + users: Any = api.list_users2(**params) # type: ignore[reportUnknownMemberType] + return OneLoginResponse(success=True, data=users) # type: ignore[reportUnknownArgumentType] + def get_user(self, user_id: int) -> OneLoginResponse: + """Get a specific user by ID. [users]""" + api = onelogin.UsersV2Api(self._sdk) # type: ignore[reportUnknownMemberType] + user: Any = api.get_user_by_id2(user_id) # type: ignore[reportUnknownMemberType] + return OneLoginResponse(success=True, data=user) # type: ignore[reportUnknownArgumentType] + def list_groups(self) -> OneLoginResponse: + """List all groups. [groups]""" + api = onelogin.GroupsApi(self._sdk) # type: ignore[reportUnknownMemberType] + groups: Any = api.get_groups() # type: ignore[reportUnknownMemberType] + return OneLoginResponse(success=True, data=groups) # type: ignore[reportUnknownArgumentType] + def get_group(self, group_id: int) -> OneLoginResponse: + """Get a specific group by ID. [groups]""" + api = onelogin.GroupsApi(self._sdk) # type: ignore[reportUnknownMemberType] + group: Any = api.get_group_by_id(str(group_id)) # type: ignore[reportUnknownMemberType] + return OneLoginResponse(success=True, data=group) # type: ignore[reportUnknownArgumentType] + def list_roles(self) -> OneLoginResponse: + """List all roles. [roles]""" + api = onelogin.RolesApi(self._sdk) # type: ignore[reportUnknownMemberType] + roles: Any = api.list_roles() # type: ignore[reportUnknownMemberType] + return OneLoginResponse(success=True, data=roles) # type: ignore[reportUnknownArgumentType] + def get_role(self, role_id: int) -> OneLoginResponse: + """Get a specific role by ID. [roles]""" + api = onelogin.RolesApi(self._sdk) # type: ignore[reportUnknownMemberType] + role: Any = api.get_role_by_id(str(role_id)) # type: ignore[reportUnknownMemberType] + return OneLoginResponse(success=True, data=role) # type: ignore[reportUnknownArgumentType] + def list_apps(self, limit: Optional[int] = None, page: Optional[int] = None) -> OneLoginResponse: + """List all apps. [apps]""" + api = onelogin.AppsApi(self._sdk) # type: ignore[reportUnknownMemberType] + params = self._params(limit=limit, page=page) + apps: Any = api.list_apps(**params) # type: ignore[reportUnknownMemberType] + return OneLoginResponse(success=True, data=apps) # type: ignore[reportUnknownArgumentType] + def get_app(self, app_id: int) -> OneLoginResponse: + """Get a specific app by ID. [apps]""" + api = onelogin.AppsApi(self._sdk) # type: ignore[reportUnknownMemberType] + app: Any = api.get_app(app_id) # type: ignore[reportUnknownMemberType] + return OneLoginResponse(success=True, data=app) # type: ignore[reportUnknownArgumentType] + def get_app_users(self, app_id: int, limit: Optional[int] = None, page: Optional[int] = None) -> OneLoginResponse: + """Get users assigned to a specific app. [apps]""" + api = onelogin.AppsApi(self._sdk) # type: ignore[reportUnknownMemberType] + params = self._params(limit=limit, page=page) + users: Any = api.list_app_users(app_id, **params) # type: ignore[reportUnknownMemberType] + return OneLoginResponse(success=True, data=users) # type: ignore[reportUnknownArgumentType] + def list_events(self, limit: Optional[int] = None, page: Optional[int] = None) -> OneLoginResponse: + """List all events. [events]""" + api = onelogin.EventsApi(self._sdk) # type: ignore[reportUnknownMemberType] + params = self._params(limit=limit, page=page) + events: Any = api.get_events(**params) # type: ignore[reportUnknownMemberType] + return OneLoginResponse(success=True, data=events) # type: ignore[reportUnknownArgumentType] + def get_event(self, event_id: int) -> OneLoginResponse: + """Get a specific event by ID. [events]""" + api = onelogin.EventsApi(self._sdk) # type: ignore[reportUnknownMemberType] + event: Any = api.get_event_by_id(event_id) # type: ignore[reportUnknownMemberType] + return OneLoginResponse(success=True, data=event) # type: ignore[reportUnknownArgumentType] + def list_privileges(self) -> OneLoginResponse: + """List all privileges. [privileges]""" + api = onelogin.PrivilegesApi(self._sdk) # type: ignore[reportUnknownMemberType] + privileges: Any = api.list_privileges() # type: ignore[reportUnknownMemberType] + return OneLoginResponse(success=True, data=privileges) # type: ignore[reportUnknownArgumentType] + def get_privilege(self, privilege_id: str) -> OneLoginResponse: + """Get a specific privilege by ID. [privileges]""" + api = onelogin.PrivilegesApi(self._sdk) # type: ignore[reportUnknownMemberType] + privilege: Any = api.get_privilege(privilege_id) # type: ignore[reportUnknownMemberType] + return OneLoginResponse(success=True, data=privilege) # type: ignore[reportUnknownArgumentType] + def list_mappings(self) -> OneLoginResponse: + """List all user mappings. [mappings]""" + api = onelogin.MappingsApi(self._sdk) # type: ignore[reportUnknownMemberType] + mappings: Any = api.list_mappings() # type: ignore[reportUnknownMemberType] + return OneLoginResponse(success=True, data=mappings) # type: ignore[reportUnknownArgumentType] + def list_brands(self) -> OneLoginResponse: + """List all brands. [brands]""" + api = onelogin.BrandsApi(self._sdk) # type: ignore[reportUnknownMemberType] + brands: Any = api.list_brands() # type: ignore[reportUnknownMemberType] + return OneLoginResponse(success=True, data=brands) # type: ignore[reportUnknownArgumentType] diff --git a/backend/python/app/sources/external/onetrust/code_generator.py b/backend/python/app/sources/external/onetrust/code_generator.py new file mode 100644 index 000000000..2b0318108 --- /dev/null +++ b/backend/python/app/sources/external/onetrust/code_generator.py @@ -0,0 +1,242 @@ +# ruff: noqa +""" +OneTrust DataSource Code Generator + +Defines OneTrust API endpoint specifications and generates the DataSource +wrapper class (onetrust.py) from them. + +Endpoints: + /datasubject/v3/requestqueues, /datasubject/v3/requestqueues/{id}, + /privacynotices/v3/notices, /privacynotices/v3/notices/{id}, + /consent/v1/consentreceipts, /consent/v1/consentreceipts/{id}, + /assessment/v2/assessments, /assessment/v2/assessments/{id}, + /dataInventory/v2/dataElements, + /riskmanagement/v2/risks, /riskmanagement/v2/risks/{id}, + /vendormanagement/v2/vendors, /vendormanagement/v2/vendors/{id} + +Note: For OAuth clients, ensure_token() is called if available. +""" + +from __future__ import annotations + +ENDPOINTS = [ + # Data Subject Request Queues + {"method": "GET", "path": "/datasubject/v3/requestqueues", "name": "list_request_queues", + "section": "Data Subject Requests", "doc": "List all data subject request queues", + "query_params": [("limit", "int", "Maximum number of results"), ("offset", "int", "Offset for pagination")]}, + {"method": "GET", "path": "/datasubject/v3/requestqueues/{request_id}", "name": "get_request_queue", + "section": "Data Subject Requests", "doc": "Get a specific data subject request queue by ID", + "path_params": ["request_id"]}, + # Privacy Notices + {"method": "GET", "path": "/privacynotices/v3/notices", "name": "list_privacy_notices", + "section": "Privacy Notices", "doc": "List all privacy notices", + "query_params": [("limit", "int", "Maximum number of results"), ("offset", "int", "Offset for pagination")]}, + {"method": "GET", "path": "/privacynotices/v3/notices/{notice_id}", "name": "get_privacy_notice", + "section": "Privacy Notices", "doc": "Get a specific privacy notice by ID", + "path_params": ["notice_id"]}, + # Consent Receipts + {"method": "GET", "path": "/consent/v1/consentreceipts", "name": "list_consent_receipts", + "section": "Consent Receipts", "doc": "List all consent receipts", + "query_params": [("limit", "int", "Maximum number of results"), ("offset", "int", "Offset for pagination")]}, + {"method": "GET", "path": "/consent/v1/consentreceipts/{receipt_id}", "name": "get_consent_receipt", + "section": "Consent Receipts", "doc": "Get a specific consent receipt by ID", + "path_params": ["receipt_id"]}, + # Assessments + {"method": "GET", "path": "/assessment/v2/assessments", "name": "list_assessments", + "section": "Assessments", "doc": "List all assessments", + "query_params": [("limit", "int", "Maximum number of results"), ("offset", "int", "Offset for pagination")]}, + {"method": "GET", "path": "/assessment/v2/assessments/{assessment_id}", "name": "get_assessment", + "section": "Assessments", "doc": "Get a specific assessment by ID", + "path_params": ["assessment_id"]}, + # Data Inventory + {"method": "GET", "path": "/dataInventory/v2/dataElements", "name": "list_data_elements", + "section": "Data Inventory", "doc": "List all data elements in the data inventory", + "query_params": [("limit", "int", "Maximum number of results"), ("offset", "int", "Offset for pagination")]}, + # Risk Management + {"method": "GET", "path": "/riskmanagement/v2/risks", "name": "list_risks", + "section": "Risk Management", "doc": "List all risks", + "query_params": [("limit", "int", "Maximum number of results"), ("offset", "int", "Offset for pagination")]}, + {"method": "GET", "path": "/riskmanagement/v2/risks/{risk_id}", "name": "get_risk", + "section": "Risk Management", "doc": "Get a specific risk by ID", + "path_params": ["risk_id"]}, + # Vendor Management + {"method": "GET", "path": "/vendormanagement/v2/vendors", "name": "list_vendors", + "section": "Vendor Management", "doc": "List all vendors", + "query_params": [("limit", "int", "Maximum number of results"), ("offset", "int", "Offset for pagination")]}, + {"method": "GET", "path": "/vendormanagement/v2/vendors/{vendor_id}", "name": "get_vendor", + "section": "Vendor Management", "doc": "Get a specific vendor by ID", + "path_params": ["vendor_id"]}, +] + + +def _gen_method(ep: dict) -> str: + """Generate a single async method from an endpoint spec.""" + name = ep["name"] + method = ep["method"] + path = ep["path"] + doc = ep["doc"] + path_params = ep.get("path_params", []) + query_params = ep.get("query_params", []) + body_params = ep.get("body_params", []) + + sig_parts = ["self"] + for p in path_params: + sig_parts.append(f"{p}: str") + for bp in body_params: + sig_parts.append(f"{bp[0]}: {bp[2]}") + if query_params: + sig_parts.append("*") + for qp in query_params: + sig_parts.append(f"{qp[0]}: {qp[1]} | None = None") + + sig = ",\n ".join(sig_parts) + + doc_args = "" + if path_params or query_params or body_params: + doc_args = "\n Args:\n" + for p in path_params: + doc_args += f" {p}: The {p.replace('_', ' ')}\n" + for bp in body_params: + doc_args += f" {bp[0]}: {bp[3]}\n" + for qp in query_params: + doc_args += f" {qp[0]}: {qp[2]}\n" + + query_block = "" + if query_params: + lines = ["\n query_params: dict[str, Any] = {}"] + for qp in query_params: + lines.append(f" if {qp[0]} is not None:") + lines.append(f" query_params['{qp[0]}'] = str({qp[0]})") + query_block = "\n".join(lines) + "\n" + + if path_params: + fmt_args = ", ".join(f"{p}={p}" for p in path_params) + url_line = f' url = self.base_url + "{path}".format({fmt_args})' + else: + url_line = f' url = self.base_url + "{path}"' + + body_block = "" + if body_params: + lines = ["\n body: dict[str, Any] = {}"] + for bp in body_params: + lines.append(f' if {bp[0]} is not None:') + lines.append(f' body["{bp[1]}"] = {bp[0]}') + body_block = "\n".join(lines) + + req_extra = "" + if query_params: + req_extra += "\n query=query_params," + if body_params: + req_extra += "\n body=body," + + return f''' + async def {name}( + {sig} + ) -> OneTrustResponse: + """{doc} + + HTTP {method} {path} +{doc_args} + Returns: + OneTrustResponse with operation result + """ + if hasattr(self.http, 'ensure_token'): + await self.http.ensure_token() +{query_block} +{url_line} +{body_block} + + try: + request = HTTPRequest( + method="{method}", + url=url, + headers={{"Content-Type": "application/json"}},{req_extra} + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return OneTrustResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed {name}" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {{response.status}}" + ) + except Exception as e: + return OneTrustResponse(success=False, error=str(e), message="Failed to execute {name}") +''' + + +def generate_datasource() -> str: + """Generate the full OneTrust DataSource module code.""" + header = '''# ruff: noqa +""" +OneTrust REST API DataSource - Auto-generated API wrapper + +Generated from OneTrust REST API documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. + +Note: For OAuth clients, ensure_token() is called before each request + to auto-fetch a client_credentials OAuth token. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.onetrust.onetrust import OneTrustClient, OneTrustResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class OneTrustDataSource: + """OneTrust REST API DataSource + + Provides async wrapper methods for OneTrust REST API operations: + - Data Subject Requests management + - Privacy Notices management + - Consent Receipts management + - Assessments management + - Data Inventory management + - Risk Management + - Vendor Management + + All methods return OneTrustResponse objects. + """ + + def __init__(self, client: OneTrustClient) -> None: + """Initialize with OneTrustClient. + + Args: + client: OneTrustClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'OneTrustDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> OneTrustClient: + """Return the underlying OneTrustClient.""" + return self._client +''' + + methods = [] + current_section = None + for ep in ENDPOINTS: + section = ep.get("section", "") + if section and section != current_section: + current_section = section + methods.append(f"\n # {'-' * 71}\n # {section}\n # {'-' * 71}") + methods.append(_gen_method(ep)) + + return header + "\n".join(methods) + "\n" + + +if __name__ == "__main__": + print(generate_datasource()) diff --git a/backend/python/app/sources/external/onetrust/example.py b/backend/python/app/sources/external/onetrust/example.py new file mode 100644 index 000000000..9e08fd605 --- /dev/null +++ b/backend/python/app/sources/external/onetrust/example.py @@ -0,0 +1,164 @@ +# ruff: noqa + +""" +OneTrust API Usage Examples + +This example demonstrates how to use the OneTrust DataSource to interact with +the OneTrust API, covering: +- Authentication (OAuth2 client_credentials, Bearer Token) +- Initializing the Client and DataSource +- Listing Data Subject Requests, Privacy Notices, Consent Receipts +- Getting Assessments, Data Elements, Risks, Vendors + +Prerequisites: +For OAuth2: +1. Get your OneTrust API client_id and client_secret +2. Set ONETRUST_CLIENT_ID, ONETRUST_CLIENT_SECRET, ONETRUST_HOSTNAME + +For Bearer Token: +1. Get your OneTrust API token +2. Set ONETRUST_TOKEN and ONETRUST_HOSTNAME +""" + +import asyncio +import json +import os + +from app.sources.client.onetrust.onetrust import ( + OneTrustClient, + OneTrustOAuthConfig, + OneTrustResponse, + OneTrustTokenConfig, +) +from app.sources.external.onetrust.onetrust import OneTrustDataSource + +# --- Configuration --- +# OAuth2 credentials +CLIENT_ID = os.getenv("ONETRUST_CLIENT_ID") +CLIENT_SECRET = os.getenv("ONETRUST_CLIENT_SECRET") + +# Bearer Token +TOKEN = os.getenv("ONETRUST_TOKEN") + +# Hostname (required for both auth methods) +HOSTNAME = os.getenv("ONETRUST_HOSTNAME") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: OneTrustResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + for key in ("requestQueues", "notices", "consentReceipts", + "assessments", "dataElements", "risks", "vendors", + "content", "results", "items"): + if isinstance(data, dict) and key in data: + items = data[key] + print(f" Found {len(items)} {key}.") + if items: + print(f" Sample: {json.dumps(items[0], indent=2)[:400]}...") + return + if isinstance(data, list): + print(f" Found {len(data)} items.") + if data: + print(f" Sample: {json.dumps(data[0], indent=2)[:400]}...") + return + print(f" Data: {json.dumps(data, indent=2)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + # 1. Initialize Client + print_section("Initializing OneTrust Client") + + if not HOSTNAME: + print(" ONETRUST_HOSTNAME is required.") + print(" Please set ONETRUST_HOSTNAME environment variable.") + return + + config = None + + # Priority 1: OAuth2 + if CLIENT_ID and CLIENT_SECRET: + print(" Using OAuth2 client_credentials authentication") + config = OneTrustOAuthConfig( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + hostname=HOSTNAME, + ) + + # Priority 2: Bearer Token + if config is None and TOKEN: + print(" Using Bearer Token authentication") + config = OneTrustTokenConfig(token=TOKEN, hostname=HOSTNAME) + + if config is None: + print(" No valid authentication method found.") + print(" Please set one of the following:") + print(" - ONETRUST_CLIENT_ID and ONETRUST_CLIENT_SECRET (for OAuth2)") + print(" - ONETRUST_TOKEN (for Bearer Token)") + return + + client = OneTrustClient.build_with_config(config) + data_source = OneTrustDataSource(client) + print("Client initialized successfully.") + + try: + # 2. List Data Subject Request Queues + print_section("Data Subject Request Queues") + requests_resp = await data_source.list_request_queues(limit=10) + print_result("List Request Queues", requests_resp) + + # 3. List Privacy Notices + print_section("Privacy Notices") + notices_resp = await data_source.list_privacy_notices(limit=10) + print_result("List Privacy Notices", notices_resp) + + # 4. List Consent Receipts + print_section("Consent Receipts") + receipts_resp = await data_source.list_consent_receipts(limit=10) + print_result("List Consent Receipts", receipts_resp) + + # 5. List Assessments + print_section("Assessments") + assessments_resp = await data_source.list_assessments(limit=10) + print_result("List Assessments", assessments_resp) + + # 6. List Data Elements + print_section("Data Elements") + elements_resp = await data_source.list_data_elements(limit=10) + print_result("List Data Elements", elements_resp) + + # 7. List Risks + print_section("Risks") + risks_resp = await data_source.list_risks(limit=10) + print_result("List Risks", risks_resp) + + # 8. List Vendors + print_section("Vendors") + vendors_resp = await data_source.list_vendors(limit=10) + print_result("List Vendors", vendors_resp) + + finally: + print("\nClosing client connection...") + inner_client = client.get_client() + if hasattr(inner_client, "close"): + await inner_client.close() + + print("\n" + "=" * 80) + print(" All OneTrust API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/onetrust/onetrust.py b/backend/python/app/sources/external/onetrust/onetrust.py new file mode 100644 index 000000000..21289ceba --- /dev/null +++ b/backend/python/app/sources/external/onetrust/onetrust.py @@ -0,0 +1,637 @@ +# ruff: noqa +""" +OneTrust REST API DataSource - Auto-generated API wrapper + +Generated from OneTrust REST API documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. + +Note: For OAuth clients, ensure_token() is called before each request + to auto-fetch a client_credentials OAuth token. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.onetrust.onetrust import OneTrustClient, OneTrustResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class OneTrustDataSource: + """OneTrust REST API DataSource + + Provides async wrapper methods for OneTrust REST API operations: + - Data Subject Requests management + - Privacy Notices management + - Consent Receipts management + - Assessments management + - Data Inventory management + - Risk Management + - Vendor Management + + All methods return OneTrustResponse objects. + """ + + def __init__(self, client: OneTrustClient) -> None: + """Initialize with OneTrustClient. + + Args: + client: OneTrustClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'OneTrustDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> OneTrustClient: + """Return the underlying OneTrustClient.""" + return self._client + + # ----------------------------------------------------------------------- + # Data Subject Requests + # ----------------------------------------------------------------------- + + async def list_request_queues( + self, + *, + limit: int | None = None, + offset: int | None = None + ) -> OneTrustResponse: + """List all data subject request queues + + HTTP GET /datasubject/v3/requestqueues + + Args: + limit: Maximum number of results + offset: Offset for pagination + + Returns: + OneTrustResponse with operation result + """ + if hasattr(self.http, 'ensure_token'): + await self.http.ensure_token() + + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/datasubject/v3/requestqueues" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return OneTrustResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_request_queues" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return OneTrustResponse(success=False, error=str(e), message="Failed to execute list_request_queues") + + + async def get_request_queue( + self, + request_id: str + ) -> OneTrustResponse: + """Get a specific data subject request queue by ID + + HTTP GET /datasubject/v3/requestqueues/{request_id} + + Args: + request_id: The request id + + Returns: + OneTrustResponse with operation result + """ + if hasattr(self.http, 'ensure_token'): + await self.http.ensure_token() + + url = self.base_url + "/datasubject/v3/requestqueues/{request_id}".format(request_id=request_id) + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return OneTrustResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_request_queue" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return OneTrustResponse(success=False, error=str(e), message="Failed to execute get_request_queue") + + + # ----------------------------------------------------------------------- + # Privacy Notices + # ----------------------------------------------------------------------- + + async def list_privacy_notices( + self, + *, + limit: int | None = None, + offset: int | None = None + ) -> OneTrustResponse: + """List all privacy notices + + HTTP GET /privacynotices/v3/notices + + Args: + limit: Maximum number of results + offset: Offset for pagination + + Returns: + OneTrustResponse with operation result + """ + if hasattr(self.http, 'ensure_token'): + await self.http.ensure_token() + + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/privacynotices/v3/notices" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return OneTrustResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_privacy_notices" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return OneTrustResponse(success=False, error=str(e), message="Failed to execute list_privacy_notices") + + + async def get_privacy_notice( + self, + notice_id: str + ) -> OneTrustResponse: + """Get a specific privacy notice by ID + + HTTP GET /privacynotices/v3/notices/{notice_id} + + Args: + notice_id: The notice id + + Returns: + OneTrustResponse with operation result + """ + if hasattr(self.http, 'ensure_token'): + await self.http.ensure_token() + + url = self.base_url + "/privacynotices/v3/notices/{notice_id}".format(notice_id=notice_id) + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return OneTrustResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_privacy_notice" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return OneTrustResponse(success=False, error=str(e), message="Failed to execute get_privacy_notice") + + + # ----------------------------------------------------------------------- + # Consent Receipts + # ----------------------------------------------------------------------- + + async def list_consent_receipts( + self, + *, + limit: int | None = None, + offset: int | None = None + ) -> OneTrustResponse: + """List all consent receipts + + HTTP GET /consent/v1/consentreceipts + + Args: + limit: Maximum number of results + offset: Offset for pagination + + Returns: + OneTrustResponse with operation result + """ + if hasattr(self.http, 'ensure_token'): + await self.http.ensure_token() + + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/consent/v1/consentreceipts" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return OneTrustResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_consent_receipts" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return OneTrustResponse(success=False, error=str(e), message="Failed to execute list_consent_receipts") + + + async def get_consent_receipt( + self, + receipt_id: str + ) -> OneTrustResponse: + """Get a specific consent receipt by ID + + HTTP GET /consent/v1/consentreceipts/{receipt_id} + + Args: + receipt_id: The receipt id + + Returns: + OneTrustResponse with operation result + """ + if hasattr(self.http, 'ensure_token'): + await self.http.ensure_token() + + url = self.base_url + "/consent/v1/consentreceipts/{receipt_id}".format(receipt_id=receipt_id) + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return OneTrustResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_consent_receipt" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return OneTrustResponse(success=False, error=str(e), message="Failed to execute get_consent_receipt") + + + # ----------------------------------------------------------------------- + # Assessments + # ----------------------------------------------------------------------- + + async def list_assessments( + self, + *, + limit: int | None = None, + offset: int | None = None + ) -> OneTrustResponse: + """List all assessments + + HTTP GET /assessment/v2/assessments + + Args: + limit: Maximum number of results + offset: Offset for pagination + + Returns: + OneTrustResponse with operation result + """ + if hasattr(self.http, 'ensure_token'): + await self.http.ensure_token() + + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/assessment/v2/assessments" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return OneTrustResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_assessments" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return OneTrustResponse(success=False, error=str(e), message="Failed to execute list_assessments") + + + async def get_assessment( + self, + assessment_id: str + ) -> OneTrustResponse: + """Get a specific assessment by ID + + HTTP GET /assessment/v2/assessments/{assessment_id} + + Args: + assessment_id: The assessment id + + Returns: + OneTrustResponse with operation result + """ + if hasattr(self.http, 'ensure_token'): + await self.http.ensure_token() + + url = self.base_url + "/assessment/v2/assessments/{assessment_id}".format(assessment_id=assessment_id) + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return OneTrustResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_assessment" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return OneTrustResponse(success=False, error=str(e), message="Failed to execute get_assessment") + + + # ----------------------------------------------------------------------- + # Data Inventory + # ----------------------------------------------------------------------- + + async def list_data_elements( + self, + *, + limit: int | None = None, + offset: int | None = None + ) -> OneTrustResponse: + """List all data elements in the data inventory + + HTTP GET /dataInventory/v2/dataElements + + Args: + limit: Maximum number of results + offset: Offset for pagination + + Returns: + OneTrustResponse with operation result + """ + if hasattr(self.http, 'ensure_token'): + await self.http.ensure_token() + + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/dataInventory/v2/dataElements" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return OneTrustResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_data_elements" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return OneTrustResponse(success=False, error=str(e), message="Failed to execute list_data_elements") + + + # ----------------------------------------------------------------------- + # Risk Management + # ----------------------------------------------------------------------- + + async def list_risks( + self, + *, + limit: int | None = None, + offset: int | None = None + ) -> OneTrustResponse: + """List all risks + + HTTP GET /riskmanagement/v2/risks + + Args: + limit: Maximum number of results + offset: Offset for pagination + + Returns: + OneTrustResponse with operation result + """ + if hasattr(self.http, 'ensure_token'): + await self.http.ensure_token() + + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/riskmanagement/v2/risks" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return OneTrustResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_risks" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return OneTrustResponse(success=False, error=str(e), message="Failed to execute list_risks") + + + async def get_risk( + self, + risk_id: str + ) -> OneTrustResponse: + """Get a specific risk by ID + + HTTP GET /riskmanagement/v2/risks/{risk_id} + + Args: + risk_id: The risk id + + Returns: + OneTrustResponse with operation result + """ + if hasattr(self.http, 'ensure_token'): + await self.http.ensure_token() + + url = self.base_url + "/riskmanagement/v2/risks/{risk_id}".format(risk_id=risk_id) + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return OneTrustResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_risk" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return OneTrustResponse(success=False, error=str(e), message="Failed to execute get_risk") + + + # ----------------------------------------------------------------------- + # Vendor Management + # ----------------------------------------------------------------------- + + async def list_vendors( + self, + *, + limit: int | None = None, + offset: int | None = None + ) -> OneTrustResponse: + """List all vendors + + HTTP GET /vendormanagement/v2/vendors + + Args: + limit: Maximum number of results + offset: Offset for pagination + + Returns: + OneTrustResponse with operation result + """ + if hasattr(self.http, 'ensure_token'): + await self.http.ensure_token() + + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/vendormanagement/v2/vendors" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return OneTrustResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_vendors" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return OneTrustResponse(success=False, error=str(e), message="Failed to execute list_vendors") + + + async def get_vendor( + self, + vendor_id: str + ) -> OneTrustResponse: + """Get a specific vendor by ID + + HTTP GET /vendormanagement/v2/vendors/{vendor_id} + + Args: + vendor_id: The vendor id + + Returns: + OneTrustResponse with operation result + """ + if hasattr(self.http, 'ensure_token'): + await self.http.ensure_token() + + url = self.base_url + "/vendormanagement/v2/vendors/{vendor_id}".format(vendor_id=vendor_id) + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return OneTrustResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_vendor" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return OneTrustResponse(success=False, error=str(e), message="Failed to execute get_vendor") + diff --git a/backend/python/app/sources/external/onetrust/run_generator.py b/backend/python/app/sources/external/onetrust/run_generator.py new file mode 100644 index 000000000..12b290ae5 --- /dev/null +++ b/backend/python/app/sources/external/onetrust/run_generator.py @@ -0,0 +1,24 @@ +"""Runner script to generate the OneTrust DataSource wrapper. + +Execute this script to regenerate onetrust.py from the endpoint definitions +in code_generator.py. + +Usage: + python -m app.sources.external.onetrust.run_generator +""" + +from app.sources.external.onetrust.code_generator import generate_datasource + + +def main() -> None: + """Generate the OneTrust DataSource file.""" + code = generate_datasource() + output_path = __file__.replace("run_generator.py", "onetrust.py") + with open(output_path, "w") as f: + f.write(code) + print(f"Generated OneTrust DataSource -> {output_path}") + print(f" Total characters: {len(code)}") + + +if __name__ == "__main__": + main() diff --git a/backend/python/app/sources/external/opsgenie/example.py b/backend/python/app/sources/external/opsgenie/example.py new file mode 100644 index 000000000..f7f03bdcc --- /dev/null +++ b/backend/python/app/sources/external/opsgenie/example.py @@ -0,0 +1,108 @@ +# ruff: noqa + +""" +Opsgenie API Usage Examples (SDK-backed) + +This example demonstrates how to use the Opsgenie DataSource backed by the +official ``opsgenie-sdk`` Python package, covering: +- Authentication (API Key via GenieKey header) +- Initialising the Client and DataSource +- Listing alerts, incidents, schedules +- Teams, users, services, heartbeats + +Prerequisites: +1. Set OPSGENIE_API_KEY environment variable with your API integration key + +You can obtain an API key from: +Opsgenie > Settings > Integrations > API Integration +""" + +import os + +from app.sources.client.opsgenie.opsgenie import ( + OpsgenieApiKeyConfig, + OpsgenieClient, + OpsgenieResponse, +) +from app.sources.external.opsgenie.opsgenie import OpsgenieDataSource + +# --- Configuration --- +API_KEY = os.getenv("OPSGENIE_API_KEY") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: OpsgenieResponse): + if response.success: + print(f" {name}: Success") + if response.data: + data = response.data + print(f" Data type: {type(data).__name__}") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +def main() -> None: + if not API_KEY: + print("OPSGENIE_API_KEY is not set") + print(" Set it with your Opsgenie API integration key") + return + + # 1. Initialise Client + print_section("Initialising Opsgenie Client (SDK-backed)") + client = OpsgenieClient.build_with_config( + OpsgenieApiKeyConfig(api_key=API_KEY) + ) + print(f" Base URL: {client.get_base_url()}") + + data_source = OpsgenieDataSource(client) + + # 2. List Alerts + print_section("Alerts") + alerts_resp = data_source.list_alerts(limit=5) + print_result("List Alerts", alerts_resp) + + # 3. List Incidents + print_section("Incidents") + incidents_resp = data_source.list_incidents(limit=5) + print_result("List Incidents", incidents_resp) + + # 4. List Schedules + print_section("Schedules") + schedules_resp = data_source.list_schedules() + print_result("List Schedules", schedules_resp) + + # 5. List Teams + print_section("Teams") + teams_resp = data_source.list_teams() + print_result("List Teams", teams_resp) + + # 6. List Users + print_section("Users") + users_resp = data_source.list_users(limit=5) + print_result("List Users", users_resp) + + # 7. List Services + print_section("Services") + services_resp = data_source.list_services(limit=5) + print_result("List Services", services_resp) + + # 8. List Heartbeats + print_section("Heartbeats") + heartbeats_resp = data_source.list_heartbeats() + print_result("List Heartbeats", heartbeats_resp) + + print("\n" + "=" * 80) + print(" All Opsgenie API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + main() diff --git a/backend/python/app/sources/external/opsgenie/opsgenie.py b/backend/python/app/sources/external/opsgenie/opsgenie.py new file mode 100644 index 000000000..b38392048 --- /dev/null +++ b/backend/python/app/sources/external/opsgenie/opsgenie.py @@ -0,0 +1,145 @@ +# ruff: noqa +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Union, cast + +import opsgenie_sdk # type: ignore[reportMissingImports] + +from app.sources.client.opsgenie.opsgenie import OpsgenieResponse + +class OpsgenieDataSource: + """ + Strict, typed wrapper over opsgenie-sdk for common Opsgenie business operations. + + Accepts either an opsgenie_sdk `ApiClient` instance *or* any object with `.get_sdk() -> ApiClient`. + """ + + def __init__(self, client_or_sdk: Union[opsgenie_sdk.ApiClient, object]) -> None: # type: ignore[reportUnknownMemberType] + super().__init__() + if hasattr(client_or_sdk, "get_sdk"): # type: ignore[reportUnknownArgumentType] + sdk_obj = getattr(client_or_sdk, "get_sdk")() # type: ignore[reportUnknownArgumentType] + self._sdk: opsgenie_sdk.ApiClient = cast(opsgenie_sdk.ApiClient, sdk_obj) # type: ignore[reportUnknownMemberType] + else: + self._sdk = cast(opsgenie_sdk.ApiClient, client_or_sdk) # type: ignore[reportUnknownMemberType] + + # ---- helpers ---- + @staticmethod + def _params(**kwargs: object) -> Dict[str, object]: + out: Dict[str, object] = {} + for k, v in kwargs.items(): + if v is None: + continue + if isinstance(v, (list, dict)) and len(v) == 0: # type: ignore[reportUnknownArgumentType] + continue + out[k] = v + return out + def list_alerts(self, limit: Optional[int] = None, offset: Optional[int] = None, sort: Optional[str] = None, order: Optional[str] = None, search_identifier: Optional[str] = None, search_identifier_type: Optional[str] = None, query: Optional[str] = None) -> OpsgenieResponse: + """List all alerts with optional filters. [alerts]""" + api = opsgenie_sdk.AlertApi(self._sdk) # type: ignore[reportUnknownMemberType] + params = self._params(limit=limit, offset=offset, sort=sort, order=order, search_identifier=search_identifier, search_identifier_type=search_identifier_type, query=query) + result: Any = api.list_alerts(**params) # type: ignore[reportUnknownMemberType] + return OpsgenieResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def get_alert(self, identifier: str) -> OpsgenieResponse: + """Get a specific alert. [alerts]""" + api = opsgenie_sdk.AlertApi(self._sdk) # type: ignore[reportUnknownMemberType] + result: Any = api.get_alert(identifier=identifier) # type: ignore[reportUnknownMemberType] + return OpsgenieResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def create_alert(self, message: str, alias: Optional[str] = None, description: Optional[str] = None, responders: Optional[List[Dict[str, str]]] = None, tags: Optional[List[str]] = None, entity: Optional[str] = None, source: Optional[str] = None, priority: Optional[str] = None, user: Optional[str] = None, note: Optional[str] = None) -> OpsgenieResponse: + """Create a new alert. [alerts]""" + api = opsgenie_sdk.AlertApi(self._sdk) # type: ignore[reportUnknownMemberType] + payload_kwargs = self._params(message=message, alias=alias, description=description, responders=responders, tags=tags, entity=entity, source=source, priority=priority, user=user, note=note) + body = opsgenie_sdk.CreateAlertPayload(**payload_kwargs) # type: ignore[reportUnknownMemberType] + result: Any = api.create_alert(body=body) # type: ignore[reportUnknownMemberType] + return OpsgenieResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def close_alert(self, identifier: str, user: Optional[str] = None, source: Optional[str] = None, note: Optional[str] = None) -> OpsgenieResponse: + """Close an alert. [alerts]""" + api = opsgenie_sdk.AlertApi(self._sdk) # type: ignore[reportUnknownMemberType] + payload_kwargs = self._params(user=user, source=source, note=note) + body = opsgenie_sdk.CloseAlertPayload(**payload_kwargs) # type: ignore[reportUnknownMemberType] + result: Any = api.close_alert(identifier=identifier, body=body) # type: ignore[reportUnknownMemberType] + return OpsgenieResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def acknowledge_alert(self, identifier: str, user: Optional[str] = None, source: Optional[str] = None, note: Optional[str] = None) -> OpsgenieResponse: + """Acknowledge an alert. [alerts]""" + api = opsgenie_sdk.AlertApi(self._sdk) # type: ignore[reportUnknownMemberType] + payload_kwargs = self._params(user=user, source=source, note=note) + body = opsgenie_sdk.AcknowledgeAlertPayload(**payload_kwargs) # type: ignore[reportUnknownMemberType] + result: Any = api.acknowledge_alert(identifier=identifier, body=body) # type: ignore[reportUnknownMemberType] + return OpsgenieResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def add_note_to_alert(self, identifier: str, note: str, user: Optional[str] = None, source: Optional[str] = None) -> OpsgenieResponse: + """Add a note to an alert. [alerts]""" + api = opsgenie_sdk.AlertApi(self._sdk) # type: ignore[reportUnknownMemberType] + payload_kwargs = self._params(note=note, user=user, source=source) + body = opsgenie_sdk.AddNoteToAlertPayload(**payload_kwargs) # type: ignore[reportUnknownMemberType] + result: Any = api.add_note(identifier=identifier, body=body) # type: ignore[reportUnknownMemberType] + return OpsgenieResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def list_alert_notes(self, identifier: str, limit: Optional[int] = None, offset: Optional[int] = None, order: Optional[str] = None, direction: Optional[str] = None) -> OpsgenieResponse: + """List notes of an alert. [alerts]""" + api = opsgenie_sdk.AlertApi(self._sdk) # type: ignore[reportUnknownMemberType] + params = self._params(limit=limit, offset=offset, order=order, direction=direction) + result: Any = api.list_notes(identifier=identifier, **params) # type: ignore[reportUnknownMemberType] + return OpsgenieResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def list_incidents(self, limit: Optional[int] = None, offset: Optional[int] = None, sort: Optional[str] = None, order: Optional[str] = None, query: Optional[str] = None) -> OpsgenieResponse: + """List all incidents. [incidents]""" + api = opsgenie_sdk.IncidentApi(self._sdk) # type: ignore[reportUnknownMemberType] + params = self._params(limit=limit, offset=offset, sort=sort, order=order, query=query) + result: Any = api.list_incidents(**params) # type: ignore[reportUnknownMemberType] + return OpsgenieResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def get_incident(self, identifier: str) -> OpsgenieResponse: + """Get a specific incident. [incidents]""" + api = opsgenie_sdk.IncidentApi(self._sdk) # type: ignore[reportUnknownMemberType] + result: Any = api.get_incident(identifier=identifier) # type: ignore[reportUnknownMemberType] + return OpsgenieResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def create_incident(self, message: str, description: Optional[str] = None, responders: Optional[List[Dict[str, str]]] = None, tags: Optional[List[str]] = None, details: Optional[Dict[str, str]] = None, priority: Optional[str] = None, note: Optional[str] = None, service_id: Optional[str] = None, notify_stakeholders: Optional[bool] = None) -> OpsgenieResponse: + """Create a new incident. [incidents]""" + api = opsgenie_sdk.IncidentApi(self._sdk) # type: ignore[reportUnknownMemberType] + payload_kwargs = self._params(message=message, description=description, responders=responders, tags=tags, details=details, priority=priority, note=note, serviceId=service_id, notifyStakeholders=notify_stakeholders) + body = opsgenie_sdk.CreateIncidentPayload(**payload_kwargs) # type: ignore[reportUnknownMemberType] + result: Any = api.create_incident(body=body) # type: ignore[reportUnknownMemberType] + return OpsgenieResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def list_schedules(self) -> OpsgenieResponse: + """List all schedules. [schedules]""" + api = opsgenie_sdk.ScheduleApi(self._sdk) # type: ignore[reportUnknownMemberType] + result: Any = api.list_schedules() # type: ignore[reportUnknownMemberType] + return OpsgenieResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def get_schedule(self, identifier: str) -> OpsgenieResponse: + """Get a specific schedule. [schedules]""" + api = opsgenie_sdk.ScheduleApi(self._sdk) # type: ignore[reportUnknownMemberType] + result: Any = api.get_schedule(identifier=identifier) # type: ignore[reportUnknownMemberType] + return OpsgenieResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def list_teams(self) -> OpsgenieResponse: + """List all teams. [teams]""" + api = opsgenie_sdk.TeamApi(self._sdk) # type: ignore[reportUnknownMemberType] + result: Any = api.list_teams() # type: ignore[reportUnknownMemberType] + return OpsgenieResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def get_team(self, identifier: str) -> OpsgenieResponse: + """Get a specific team. [teams]""" + api = opsgenie_sdk.TeamApi(self._sdk) # type: ignore[reportUnknownMemberType] + result: Any = api.get_team(identifier=identifier) # type: ignore[reportUnknownMemberType] + return OpsgenieResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def list_users(self, limit: Optional[int] = None, offset: Optional[int] = None, sort: Optional[str] = None, order: Optional[str] = None, query: Optional[str] = None) -> OpsgenieResponse: + """List all users. [users]""" + api = opsgenie_sdk.UserApi(self._sdk) # type: ignore[reportUnknownMemberType] + params = self._params(limit=limit, offset=offset, sort=sort, order=order, query=query) + result: Any = api.list_users(**params) # type: ignore[reportUnknownMemberType] + return OpsgenieResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def get_user(self, identifier: str) -> OpsgenieResponse: + """Get a specific user. [users]""" + api = opsgenie_sdk.UserApi(self._sdk) # type: ignore[reportUnknownMemberType] + result: Any = api.get_user(identifier=identifier) # type: ignore[reportUnknownMemberType] + return OpsgenieResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def list_services(self, limit: Optional[int] = None, offset: Optional[int] = None) -> OpsgenieResponse: + """List all services. [services]""" + api = opsgenie_sdk.ServiceApi(self._sdk) # type: ignore[reportUnknownMemberType] + params = self._params(limit=limit, offset=offset) + result: Any = api.list_services(**params) # type: ignore[reportUnknownMemberType] + return OpsgenieResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def get_service(self, identifier: str) -> OpsgenieResponse: + """Get a specific service. [services]""" + api = opsgenie_sdk.ServiceApi(self._sdk) # type: ignore[reportUnknownMemberType] + result: Any = api.get_service(identifier=identifier) # type: ignore[reportUnknownMemberType] + return OpsgenieResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def list_heartbeats(self) -> OpsgenieResponse: + """List all heartbeats. [heartbeats]""" + api = opsgenie_sdk.HeartbeatApi(self._sdk) # type: ignore[reportUnknownMemberType] + result: Any = api.list_heart_beats() # type: ignore[reportUnknownMemberType] + return OpsgenieResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] diff --git a/backend/python/app/sources/external/panopto/example.py b/backend/python/app/sources/external/panopto/example.py new file mode 100644 index 000000000..d88fe570d --- /dev/null +++ b/backend/python/app/sources/external/panopto/example.py @@ -0,0 +1,234 @@ +# ruff: noqa + +""" +Panopto API Usage Examples + +This example demonstrates how to use the Panopto DataSource to interact with +the Panopto API, covering: +- Authentication (OAuth2, API Key / Bearer Token) +- Initializing the Client and DataSource +- Listing Sessions (Recordings) +- Listing Folders and Folder Sessions +- Listing Users and Groups +- Searching for Content +- Getting View Statistics + +Prerequisites: +For OAuth2: +1. Create a Panopto OAuth app in your Panopto instance admin panel +2. Set PANOPTO_CLIENT_ID and PANOPTO_CLIENT_SECRET environment variables +3. Set PANOPTO_DOMAIN (e.g., "mycompany" for mycompany.hosted.panopto.com) + +For Bearer Token: +1. Set PANOPTO_ACCESS_TOKEN environment variable with your API key / access token +2. Set PANOPTO_DOMAIN (e.g., "mycompany" for mycompany.hosted.panopto.com) +""" + +import asyncio +import json +import os + +from app.sources.client.panopto.panopto import ( + PanoptoClient, + PanoptoOAuthConfig, + PanoptoTokenConfig, + PanoptoResponse, +) +from app.sources.external.panopto.panopto import PanoptoDataSource +from app.sources.external.utils.oauth import perform_oauth_flow + +# --- Configuration --- +# OAuth2 credentials +CLIENT_ID = os.getenv("PANOPTO_CLIENT_ID") +CLIENT_SECRET = os.getenv("PANOPTO_CLIENT_SECRET") + +# Bearer Token +ACCESS_TOKEN = os.getenv("PANOPTO_ACCESS_TOKEN") + +# Domain (required for both auth methods) +DOMAIN = os.getenv("PANOPTO_DOMAIN", "") + +# OAuth redirect URI +REDIRECT_URI = os.getenv("PANOPTO_REDIRECT_URI", "http://localhost:8080/callback") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: PanoptoResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + # Handle list-type responses + for key in ("Results", "sessions", "folders", "users", "groups", + "viewers", "SearchResults"): + if isinstance(data, dict) and key in data: + items = data[key] + if isinstance(items, list): + print(f" Found {len(items)} {key}.") + if items: + print(f" Sample: {json.dumps(items[0], indent=2)[:400]}...") + return + # If data is a list itself + if isinstance(data, list): + print(f" Found {len(data)} items.") + if data: + print(f" Sample: {json.dumps(data[0], indent=2)[:400]}...") + return + # Generic response + print(f" Data: {json.dumps(data, indent=2)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + # 1. Initialize Client + print_section("Initializing Panopto Client") + + if not DOMAIN: + print(" Error: PANOPTO_DOMAIN is required.") + print(" Set PANOPTO_DOMAIN environment variable (e.g., 'mycompany')") + return + + config = None + + # Priority 1: OAuth2 + if CLIENT_ID and CLIENT_SECRET: + print(f" Using OAuth2 authentication (domain: {DOMAIN})") + try: + print("Starting OAuth flow...") + auth_endpoint = f"https://{DOMAIN}.hosted.panopto.com/Panopto/oauth2/connect/authorize" + token_endpoint = f"https://{DOMAIN}.hosted.panopto.com/Panopto/oauth2/connect/token" + + token_response = perform_oauth_flow( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + auth_endpoint=auth_endpoint, + token_endpoint=token_endpoint, + redirect_uri=REDIRECT_URI, + scopes=["api"], + scope_delimiter=" ", + auth_method="body", + ) + + access_token = token_response.get("access_token") + if not access_token: + raise Exception("No access_token found in OAuth response") + + config = PanoptoOAuthConfig( + access_token=access_token, + domain=DOMAIN, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + ) + print(" OAuth authentication successful") + except Exception as e: + print(f" OAuth flow failed: {e}") + print(" Falling back to other authentication methods...") + + # Priority 2: Bearer Token + if config is None and ACCESS_TOKEN: + print(f" Using Bearer Token authentication (domain: {DOMAIN})") + config = PanoptoTokenConfig( + token=ACCESS_TOKEN, + domain=DOMAIN, + ) + + if config is None: + print(" No valid authentication method found.") + print(" Please set one of the following:") + print(" - PANOPTO_CLIENT_ID and PANOPTO_CLIENT_SECRET (for OAuth2)") + print(" - PANOPTO_ACCESS_TOKEN (for Bearer Token)") + return + + client = PanoptoClient.build_with_config(config) + data_source = PanoptoDataSource(client) + print("Client initialized successfully.") + + try: + # 2. Get Sessions + print_section("Sessions (Recordings)") + sessions_resp = await data_source.get_sessions(max_number_results=5) + print_result("Get Sessions", sessions_resp) + + # Get specific session if available + session_id = None + if sessions_resp.success and sessions_resp.data: + results = sessions_resp.data.get("Results", []) + if isinstance(results, list) and results: + session_id = str(results[0].get("Id", "")) + print(f" Using Session: {results[0].get('Name', 'N/A')} (ID: {session_id})") + + if session_id: + print_section(f"Session Details: {session_id}") + session_resp = await data_source.get_session(session_id=session_id) + print_result("Get Session", session_resp) + + # Get Session Viewers + print_section("Session Viewers") + viewers_resp = await data_source.get_session_viewers( + session_id=session_id, max_number_results=5 + ) + print_result("Get Session Viewers", viewers_resp) + + # Get View Stats + print_section("View Statistics") + stats_resp = await data_source.get_view_stats(session_id=session_id) + print_result("Get View Stats", stats_resp) + + # 3. Get Folders + print_section("Folders") + folders_resp = await data_source.get_folders(max_number_results=5) + print_result("Get Folders", folders_resp) + + # Get folder sessions if a folder is available + folder_id = None + if folders_resp.success and folders_resp.data: + results = folders_resp.data.get("Results", []) + if isinstance(results, list) and results: + folder_id = str(results[0].get("Id", "")) + print(f" Using Folder: {results[0].get('Name', 'N/A')} (ID: {folder_id})") + + if folder_id: + print_section("Folder Sessions") + folder_sessions_resp = await data_source.get_folder_sessions( + folder_id=folder_id, max_number_results=5 + ) + print_result("Get Folder Sessions", folder_sessions_resp) + + # 4. Get Users + print_section("Users") + users_resp = await data_source.get_users(max_number_results=5) + print_result("Get Users", users_resp) + + # 5. Get Groups + print_section("Groups") + groups_resp = await data_source.get_groups(max_number_results=5) + print_result("Get Groups", groups_resp) + + # 6. Search + print_section("Search") + search_resp = await data_source.search(query="test", max_number_results=5) + print_result("Search", search_resp) + + finally: + # Cleanup: Close the HTTP client session + print("\nClosing client connection...") + inner_client = client.get_client() + if hasattr(inner_client, "close"): + await inner_client.close() + + print("\n" + "=" * 80) + print(" All Panopto API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/panopto/panopto.py b/backend/python/app/sources/external/panopto/panopto.py new file mode 100644 index 000000000..72328bb93 --- /dev/null +++ b/backend/python/app/sources/external/panopto/panopto.py @@ -0,0 +1,585 @@ +# ruff: noqa +""" +Panopto REST API DataSource - Auto-generated API wrapper + +Generated from Panopto REST API v1 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.panopto.panopto import PanoptoClient, PanoptoResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class PanoptoDataSource: + """Panopto REST API DataSource + + Provides async wrapper methods for Panopto REST API operations: + - Sessions (recordings) management + - Session viewers + - Folders management + - Folder sessions + - Users management + - Groups management + - Search + - View statistics + + The base URL is domain-specific and determined by the PanoptoClient + configuration. Create a client with the desired domain and pass it here. + + All methods return PanoptoResponse objects. + """ + + def __init__(self, client: PanoptoClient) -> None: + """Initialize with PanoptoClient. + + Args: + client: PanoptoClient instance with configured authentication and domain + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'PanoptoDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> PanoptoClient: + """Return the underlying PanoptoClient.""" + return self._client + + # ----------------------------------------------------------------------- + # Sessions (Recordings) + # ----------------------------------------------------------------------- + + async def get_sessions( + self, + *, + folder_id: str | None = None, + search_query: str | None = None, + sort_field: str | None = None, + sort_order: str | None = None, + page_number: int | None = None, + max_number_results: int | None = None, + ) -> PanoptoResponse: + """Get all sessions (recordings) + + Args: + folder_id: Filter by folder ID + search_query: Search query string + sort_field: Field to sort by + sort_order: Sort order (Asc or Desc) + page_number: Page number for pagination + max_number_results: Maximum number of results per page + + Returns: + PanoptoResponse with operation result + """ + query_params: dict[str, Any] = {} + if folder_id is not None: + query_params['folderId'] = folder_id + if search_query is not None: + query_params['searchQuery'] = search_query + if sort_field is not None: + query_params['sortField'] = sort_field + if sort_order is not None: + query_params['sortOrder'] = sort_order + if page_number is not None: + query_params['pageNumber'] = str(page_number) + if max_number_results is not None: + query_params['maxNumberResults'] = str(max_number_results) + + url = self.base_url + "/sessions" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PanoptoResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_sessions" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PanoptoResponse(success=False, error=str(e), message="Failed to execute get_sessions") + + async def get_session( + self, + session_id: str, + ) -> PanoptoResponse: + """Get a specific session by ID + + Args: + session_id: The session (recording) ID + + Returns: + PanoptoResponse with operation result + """ + url = self.base_url + "/sessions/{session_id}".format(session_id=session_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PanoptoResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_session" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PanoptoResponse(success=False, error=str(e), message="Failed to execute get_session") + + async def get_session_viewers( + self, + session_id: str, + *, + page_number: int | None = None, + max_number_results: int | None = None, + ) -> PanoptoResponse: + """Get viewers of a specific session + + Args: + session_id: The session (recording) ID + page_number: Page number for pagination + max_number_results: Maximum number of results per page + + Returns: + PanoptoResponse with operation result + """ + query_params: dict[str, Any] = {} + if page_number is not None: + query_params['pageNumber'] = str(page_number) + if max_number_results is not None: + query_params['maxNumberResults'] = str(max_number_results) + + url = self.base_url + "/sessions/{session_id}/viewers".format(session_id=session_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PanoptoResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_session_viewers" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PanoptoResponse(success=False, error=str(e), message="Failed to execute get_session_viewers") + + # ----------------------------------------------------------------------- + # Folders + # ----------------------------------------------------------------------- + + async def get_folders( + self, + *, + parent_folder_id: str | None = None, + search_query: str | None = None, + sort_field: str | None = None, + sort_order: str | None = None, + page_number: int | None = None, + max_number_results: int | None = None, + ) -> PanoptoResponse: + """Get all folders + + Args: + parent_folder_id: Filter by parent folder ID + search_query: Search query string + sort_field: Field to sort by + sort_order: Sort order (Asc or Desc) + page_number: Page number for pagination + max_number_results: Maximum number of results per page + + Returns: + PanoptoResponse with operation result + """ + query_params: dict[str, Any] = {} + if parent_folder_id is not None: + query_params['parentFolderId'] = parent_folder_id + if search_query is not None: + query_params['searchQuery'] = search_query + if sort_field is not None: + query_params['sortField'] = sort_field + if sort_order is not None: + query_params['sortOrder'] = sort_order + if page_number is not None: + query_params['pageNumber'] = str(page_number) + if max_number_results is not None: + query_params['maxNumberResults'] = str(max_number_results) + + url = self.base_url + "/folders" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PanoptoResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_folders" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PanoptoResponse(success=False, error=str(e), message="Failed to execute get_folders") + + async def get_folder( + self, + folder_id: str, + ) -> PanoptoResponse: + """Get a specific folder by ID + + Args: + folder_id: The folder ID + + Returns: + PanoptoResponse with operation result + """ + url = self.base_url + "/folders/{folder_id}".format(folder_id=folder_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PanoptoResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_folder" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PanoptoResponse(success=False, error=str(e), message="Failed to execute get_folder") + + async def get_folder_sessions( + self, + folder_id: str, + *, + sort_field: str | None = None, + sort_order: str | None = None, + page_number: int | None = None, + max_number_results: int | None = None, + ) -> PanoptoResponse: + """Get sessions in a specific folder + + Args: + folder_id: The folder ID + sort_field: Field to sort by + sort_order: Sort order (Asc or Desc) + page_number: Page number for pagination + max_number_results: Maximum number of results per page + + Returns: + PanoptoResponse with operation result + """ + query_params: dict[str, Any] = {} + if sort_field is not None: + query_params['sortField'] = sort_field + if sort_order is not None: + query_params['sortOrder'] = sort_order + if page_number is not None: + query_params['pageNumber'] = str(page_number) + if max_number_results is not None: + query_params['maxNumberResults'] = str(max_number_results) + + url = self.base_url + "/folders/{folder_id}/sessions".format(folder_id=folder_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PanoptoResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_folder_sessions" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PanoptoResponse(success=False, error=str(e), message="Failed to execute get_folder_sessions") + + # ----------------------------------------------------------------------- + # Users + # ----------------------------------------------------------------------- + + async def get_users( + self, + *, + search_query: str | None = None, + sort_field: str | None = None, + sort_order: str | None = None, + page_number: int | None = None, + max_number_results: int | None = None, + ) -> PanoptoResponse: + """Get all users + + Args: + search_query: Search query string + sort_field: Field to sort by + sort_order: Sort order (Asc or Desc) + page_number: Page number for pagination + max_number_results: Maximum number of results per page + + Returns: + PanoptoResponse with operation result + """ + query_params: dict[str, Any] = {} + if search_query is not None: + query_params['searchQuery'] = search_query + if sort_field is not None: + query_params['sortField'] = sort_field + if sort_order is not None: + query_params['sortOrder'] = sort_order + if page_number is not None: + query_params['pageNumber'] = str(page_number) + if max_number_results is not None: + query_params['maxNumberResults'] = str(max_number_results) + + url = self.base_url + "/users" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PanoptoResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_users" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PanoptoResponse(success=False, error=str(e), message="Failed to execute get_users") + + async def get_user( + self, + user_id: str, + ) -> PanoptoResponse: + """Get a specific user by ID + + Args: + user_id: The user ID + + Returns: + PanoptoResponse with operation result + """ + url = self.base_url + "/users/{user_id}".format(user_id=user_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PanoptoResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_user" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PanoptoResponse(success=False, error=str(e), message="Failed to execute get_user") + + # ----------------------------------------------------------------------- + # Groups + # ----------------------------------------------------------------------- + + async def get_groups( + self, + *, + page_number: int | None = None, + max_number_results: int | None = None, + ) -> PanoptoResponse: + """Get all groups + + Args: + page_number: Page number for pagination + max_number_results: Maximum number of results per page + + Returns: + PanoptoResponse with operation result + """ + query_params: dict[str, Any] = {} + if page_number is not None: + query_params['pageNumber'] = str(page_number) + if max_number_results is not None: + query_params['maxNumberResults'] = str(max_number_results) + + url = self.base_url + "/groups" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PanoptoResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_groups" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PanoptoResponse(success=False, error=str(e), message="Failed to execute get_groups") + + async def get_group( + self, + group_id: str, + ) -> PanoptoResponse: + """Get a specific group by ID + + Args: + group_id: The group ID + + Returns: + PanoptoResponse with operation result + """ + url = self.base_url + "/groups/{group_id}".format(group_id=group_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PanoptoResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_group" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PanoptoResponse(success=False, error=str(e), message="Failed to execute get_group") + + # ----------------------------------------------------------------------- + # Search + # ----------------------------------------------------------------------- + + async def search( + self, + *, + query: str, + page_number: int | None = None, + max_number_results: int | None = None, + ) -> PanoptoResponse: + """Search for sessions and folders + + Args: + query: Search query string (required) + page_number: Page number for pagination + max_number_results: Maximum number of results per page + + Returns: + PanoptoResponse with operation result + """ + query_params: dict[str, Any] = {} + query_params['query'] = query + if page_number is not None: + query_params['pageNumber'] = str(page_number) + if max_number_results is not None: + query_params['maxNumberResults'] = str(max_number_results) + + url = self.base_url + "/search" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PanoptoResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed search" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PanoptoResponse(success=False, error=str(e), message="Failed to execute search") + + # ----------------------------------------------------------------------- + # Statistics + # ----------------------------------------------------------------------- + + async def get_view_stats( + self, + *, + session_id: str, + page_number: int | None = None, + max_number_results: int | None = None, + ) -> PanoptoResponse: + """Get view statistics for a session + + Args: + session_id: The session ID to get stats for (required) + page_number: Page number for pagination + max_number_results: Maximum number of results per page + + Returns: + PanoptoResponse with operation result + """ + query_params: dict[str, Any] = {} + query_params['sessionId'] = session_id + if page_number is not None: + query_params['pageNumber'] = str(page_number) + if max_number_results is not None: + query_params['maxNumberResults'] = str(max_number_results) + + url = self.base_url + "/stats/views" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PanoptoResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_view_stats" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PanoptoResponse(success=False, error=str(e), message="Failed to execute get_view_stats") diff --git a/backend/python/app/sources/external/panopto/run_generator.py b/backend/python/app/sources/external/panopto/run_generator.py new file mode 100644 index 000000000..ee7e424eb --- /dev/null +++ b/backend/python/app/sources/external/panopto/run_generator.py @@ -0,0 +1,310 @@ +# ruff: noqa +""" +Panopto DataSource Code Generator + +This script generates the PanoptoDataSource class with all API endpoint +wrapper methods based on the Panopto REST API v1 specification. + +The generated code follows the pattern established by ClickUp and other +connectors in this project, using HTTPRequest/HTTPResponse for all API calls. + +Usage: + python -m app.sources.external.panopto.run_generator + +Output: + Prints the generated Python source code for the PanoptoDataSource class + to stdout. Redirect to a file to save: + + python -m app.sources.external.panopto.run_generator > \ + app/sources/external/panopto/panopto.py +""" + +from __future__ import annotations + +ENDPOINTS = [ + { + "name": "get_sessions", + "method": "GET", + "path": "/sessions", + "doc": "Get all sessions (recordings)", + "path_params": [], + "query_params": [ + ("folder_id", "str | None", "folderId", "Filter by folder ID"), + ("search_query", "str | None", "searchQuery", "Search query string"), + ("sort_field", "str | None", "sortField", "Field to sort by"), + ("sort_order", "str | None", "sortOrder", "Sort order (Asc or Desc)"), + ("page_number", "int | None", "pageNumber", "Page number for pagination"), + ("max_number_results", "int | None", "maxNumberResults", "Maximum number of results per page"), + ], + "body_params": [], + }, + { + "name": "get_session", + "method": "GET", + "path": "/sessions/{session_id}", + "doc": "Get a specific session by ID", + "path_params": [("session_id", "str", "The session (recording) ID")], + "query_params": [], + "body_params": [], + }, + { + "name": "get_session_viewers", + "method": "GET", + "path": "/sessions/{session_id}/viewers", + "doc": "Get viewers of a specific session", + "path_params": [("session_id", "str", "The session (recording) ID")], + "query_params": [ + ("page_number", "int | None", "pageNumber", "Page number for pagination"), + ("max_number_results", "int | None", "maxNumberResults", "Maximum number of results per page"), + ], + "body_params": [], + }, + { + "name": "get_folders", + "method": "GET", + "path": "/folders", + "doc": "Get all folders", + "path_params": [], + "query_params": [ + ("parent_folder_id", "str | None", "parentFolderId", "Filter by parent folder ID"), + ("search_query", "str | None", "searchQuery", "Search query string"), + ("sort_field", "str | None", "sortField", "Field to sort by"), + ("sort_order", "str | None", "sortOrder", "Sort order (Asc or Desc)"), + ("page_number", "int | None", "pageNumber", "Page number for pagination"), + ("max_number_results", "int | None", "maxNumberResults", "Maximum number of results per page"), + ], + "body_params": [], + }, + { + "name": "get_folder", + "method": "GET", + "path": "/folders/{folder_id}", + "doc": "Get a specific folder by ID", + "path_params": [("folder_id", "str", "The folder ID")], + "query_params": [], + "body_params": [], + }, + { + "name": "get_folder_sessions", + "method": "GET", + "path": "/folders/{folder_id}/sessions", + "doc": "Get sessions in a specific folder", + "path_params": [("folder_id", "str", "The folder ID")], + "query_params": [ + ("sort_field", "str | None", "sortField", "Field to sort by"), + ("sort_order", "str | None", "sortOrder", "Sort order (Asc or Desc)"), + ("page_number", "int | None", "pageNumber", "Page number for pagination"), + ("max_number_results", "int | None", "maxNumberResults", "Maximum number of results per page"), + ], + "body_params": [], + }, + { + "name": "get_users", + "method": "GET", + "path": "/users", + "doc": "Get all users", + "path_params": [], + "query_params": [ + ("search_query", "str | None", "searchQuery", "Search query string"), + ("sort_field", "str | None", "sortField", "Field to sort by"), + ("sort_order", "str | None", "sortOrder", "Sort order (Asc or Desc)"), + ("page_number", "int | None", "pageNumber", "Page number for pagination"), + ("max_number_results", "int | None", "maxNumberResults", "Maximum number of results per page"), + ], + "body_params": [], + }, + { + "name": "get_user", + "method": "GET", + "path": "/users/{user_id}", + "doc": "Get a specific user by ID", + "path_params": [("user_id", "str", "The user ID")], + "query_params": [], + "body_params": [], + }, + { + "name": "get_groups", + "method": "GET", + "path": "/groups", + "doc": "Get all groups", + "path_params": [], + "query_params": [ + ("page_number", "int | None", "pageNumber", "Page number for pagination"), + ("max_number_results", "int | None", "maxNumberResults", "Maximum number of results per page"), + ], + "body_params": [], + }, + { + "name": "get_group", + "method": "GET", + "path": "/groups/{group_id}", + "doc": "Get a specific group by ID", + "path_params": [("group_id", "str", "The group ID")], + "query_params": [], + "body_params": [], + }, + { + "name": "search", + "method": "GET", + "path": "/search", + "doc": "Search for sessions and folders", + "path_params": [], + "query_params": [ + ("query", "str", "query", "Search query string (required)"), + ("page_number", "int | None", "pageNumber", "Page number for pagination"), + ("max_number_results", "int | None", "maxNumberResults", "Maximum number of results per page"), + ], + "body_params": [], + }, + { + "name": "get_view_stats", + "method": "GET", + "path": "/stats/views", + "doc": "Get view statistics for a session", + "path_params": [], + "query_params": [ + ("session_id", "str", "sessionId", "The session ID to get stats for (required)"), + ("page_number", "int | None", "pageNumber", "Page number for pagination"), + ("max_number_results", "int | None", "maxNumberResults", "Maximum number of results per page"), + ], + "body_params": [], + }, +] + + +def generate_method(ep: dict) -> str: + """Generate a single async method for an endpoint.""" + name = ep["name"] + method = ep["method"] + path = ep["path"] + doc = ep["doc"] + path_params = ep.get("path_params", []) + query_params = ep.get("query_params", []) + + sig_parts = ["self"] + for pp_name, pp_type, _pp_doc in path_params: + sig_parts.append(f"{pp_name}: {pp_type}") + + required_qp = [q for q in query_params if "None" not in q[1]] + optional_qp = [q for q in query_params if "None" in q[1]] + + if required_qp or optional_qp: + sig_parts.append("*") + for qp_name, qp_type, _qp_api, _qp_doc in required_qp: + sig_parts.append(f"{qp_name}: {qp_type}") + for qp_name, qp_type, _qp_api, _qp_doc in optional_qp: + sig_parts.append(f"{qp_name}: {qp_type} = None") + + sig = ",\n ".join(sig_parts) + + doc_args = [] + for pp_name, _pp_type, pp_doc in path_params: + doc_args.append(f" {pp_name}: {pp_doc}") + for qp_name, _qp_type, _qp_api, qp_doc in query_params: + doc_args.append(f" {qp_name}: {qp_doc}") + + args_section = "" + if doc_args: + args_section = "\n\n Args:\n" + "\n".join(doc_args) + + qp_block = "" + if query_params: + qp_block = "\n query_params: dict[str, Any] = {}\n" + for qp_name, qp_type, qp_api, _ in query_params: + if "None" not in qp_type: + if "int" in qp_type: + qp_block += f" query_params['{qp_api}'] = str({qp_name})\n" + else: + qp_block += f" query_params['{qp_api}'] = {qp_name}\n" + else: + if "int" in qp_type: + qp_block += f" if {qp_name} is not None:\n query_params['{qp_api}'] = str({qp_name})\n" + else: + qp_block += f" if {qp_name} is not None:\n query_params['{qp_api}'] = {qp_name}\n" + + if path_params: + format_args = ", ".join(f"{p[0]}={p[0]}" for p in path_params) + url_line = f' url = self.base_url + "{path}".format({format_args})' + else: + url_line = f' url = self.base_url + "{path}"' + + req_kwargs = f'method="{method}",\n url=url,\n headers={{"Content-Type": "application/json"}}' + if query_params: + req_kwargs += ",\n query=query_params" + + return f''' async def {name}( + {sig} + ) -> PanoptoResponse: + """{doc}{args_section} + + Returns: + PanoptoResponse with operation result + """ +{qp_block}{url_line} + + try: + request = HTTPRequest( + {req_kwargs}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PanoptoResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed {name}" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {{response.status}}" + ) + except Exception as e: + return PanoptoResponse(success=False, error=str(e), message="Failed to execute {name}") +''' + + +def generate_datasource() -> str: + """Generate the full PanoptoDataSource module.""" + header = '''# ruff: noqa +""" +Panopto REST API DataSource - Auto-generated API wrapper + +Generated from Panopto REST API v1 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.panopto.panopto import PanoptoClient, PanoptoResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class PanoptoDataSource: + """Panopto REST API DataSource + + Provides async wrapper methods for Panopto REST API operations. + All methods return PanoptoResponse objects. + """ + + def __init__(self, client: PanoptoClient) -> None: + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'PanoptoDataSource': + return self + + def get_client(self) -> PanoptoClient: + return self._client + +''' + methods = "\n".join(generate_method(ep) for ep in ENDPOINTS) + return header + methods + + +if __name__ == "__main__": + print(generate_datasource()) diff --git a/backend/python/app/sources/external/phabricator/example.py b/backend/python/app/sources/external/phabricator/example.py new file mode 100644 index 000000000..9fd69fb27 --- /dev/null +++ b/backend/python/app/sources/external/phabricator/example.py @@ -0,0 +1,160 @@ +# ruff: noqa + +""" +Phabricator API Usage Examples + +This example demonstrates how to use the Phabricator DataSource to interact +with the Phabricator Conduit API, covering: +- Authentication (API Token / Conduit Token) +- Initializing the Client and DataSource +- Searching Maniphest tasks +- Searching Differential revisions +- Searching projects and users +- Looking up PHIDs +- Querying the activity feed + +Prerequisites: +1. Have access to a Phabricator instance +2. Generate an API token at https://{instance}/settings/user/{username}/page/apitokens/ +3. Set PHABRICATOR_API_TOKEN and PHABRICATOR_INSTANCE environment variables +""" + +import asyncio +import json +import os + +from app.sources.client.phabricator.phabricator import ( + PhabricatorClient, + PhabricatorResponse, + PhabricatorTokenConfig, +) +from app.sources.external.phabricator.phabricator import PhabricatorDataSource + +# --- Configuration --- +API_TOKEN = os.getenv("PHABRICATOR_API_TOKEN") +INSTANCE = os.getenv("PHABRICATOR_INSTANCE") # e.g. "phabricator.example.com" + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: PhabricatorResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + if isinstance(data, dict) and "result" in data: + result = data["result"] + if isinstance(result, dict) and "data" in result: + items = result["data"] + print(f" Found {len(items)} items.") + if items: + print(f" Sample: {json.dumps(items[0], indent=2)[:400]}...") + return + print(f" Data: {json.dumps(data, indent=2)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + # 1. Initialize Client + print_section("Initializing Phabricator Client") + + if not API_TOKEN or not INSTANCE: + print(" No valid authentication method found.") + print(" Please set the following environment variables:") + print(" - PHABRICATOR_API_TOKEN (Conduit API token)") + print(" - PHABRICATOR_INSTANCE (hostname, e.g. phabricator.example.com)") + return + + print(" Using API Token authentication") + config = PhabricatorTokenConfig( + token=API_TOKEN, + instance=INSTANCE, + ) + + client = PhabricatorClient.build_with_config(config) + data_source = PhabricatorDataSource(client) + print(f" Client initialized for {INSTANCE}") + + try: + # 2. Search Maniphest Tasks + print_section("Maniphest Tasks (Open)") + tasks_resp = await data_source.search_maniphest_tasks( + constraints={"statuses": ["open"]}, + limit=5, + ) + print_result("Search Open Tasks", tasks_resp) + + # 3. Search Differential Revisions + print_section("Differential Revisions") + revisions_resp = await data_source.search_differential_revisions( + limit=5, + ) + print_result("Search Revisions", revisions_resp) + + # 4. Search Projects + print_section("Projects") + projects_resp = await data_source.search_projects( + limit=5, + ) + print_result("Search Projects", projects_resp) + + # 5. Search Users + print_section("Users") + users_resp = await data_source.search_users( + limit=5, + ) + print_result("Search Users", users_resp) + + # 6. Search Pastes + print_section("Pastes") + pastes_resp = await data_source.search_pastes( + limit=5, + ) + print_result("Search Pastes", pastes_resp) + + # 7. Search Repositories + print_section("Diffusion Repositories") + repos_resp = await data_source.search_repositories( + limit=5, + ) + print_result("Search Repositories", repos_resp) + + # 8. PHID Lookup (if tasks found) + if tasks_resp.success and tasks_resp.data: + result_data = tasks_resp.data.get("result", {}) + if isinstance(result_data, dict): + items = result_data.get("data", []) + if items: + phid = items[0].get("phid", "") + if phid: + print_section(f"PHID Lookup: {phid}") + phid_resp = await data_source.lookup_phids(names=[phid]) + print_result("Lookup PHID", phid_resp) + + # 9. Query Feed + print_section("Activity Feed") + feed_resp = await data_source.query_feed(limit=5) + print_result("Query Feed", feed_resp) + + finally: + # Cleanup: Close the HTTP client session + print("\nClosing client connection...") + inner_client = client.get_client() + if hasattr(inner_client, "close"): + await inner_client.close() + + print("\n" + "=" * 80) + print(" All Phabricator API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/phabricator/phabricator.py b/backend/python/app/sources/external/phabricator/phabricator.py new file mode 100644 index 000000000..06d64f89e --- /dev/null +++ b/backend/python/app/sources/external/phabricator/phabricator.py @@ -0,0 +1,482 @@ +# ruff: noqa +""" +Phabricator Conduit API DataSource - Auto-generated API wrapper + +Generated from Phabricator Conduit API documentation. +Uses HTTP client for direct REST API interactions. +All Phabricator API calls are POST with form-encoded body including api.token. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.phabricator.phabricator import PhabricatorClient, PhabricatorResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class PhabricatorDataSource: + """Phabricator Conduit API DataSource + + Provides async wrapper methods for Phabricator Conduit API operations: + - Maniphest (Tasks) search + - Differential (Code Review) revision search + - Project search + - User search + - Paste search + - Diffusion (Repository) search + - PHID lookup + - Feed (Activity) query + + All Phabricator API calls are POST requests with form-encoded body. + The api.token is automatically injected into each request body. + + All methods return PhabricatorResponse objects. + """ + + def __init__(self, client: PhabricatorClient) -> None: + """Initialize with PhabricatorClient. + + Args: + client: PhabricatorClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + self.api_token = self.http.get_api_token() + + def get_data_source(self) -> 'PhabricatorDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> PhabricatorClient: + """Return the underlying PhabricatorClient.""" + return self._client + + def _build_form_body(self, params: dict[str, Any] | None = None) -> dict[str, Any]: + """Build form-encoded body with api.token included. + + Args: + params: Additional parameters to include in the body + + Returns: + Dict with api.token and all additional parameters + """ + body: dict[str, Any] = {"api.token": self.api_token} + if params: + body.update(params) + return body + + async def search_maniphest_tasks( + self, + *, + constraints: dict[str, Any] | None = None, + limit: int | None = None, + after: str | None = None, + before: str | None = None, + order: str | None = None, + ) -> PhabricatorResponse: + """Search Maniphest tasks (POST /maniphest.search) + + Args: + constraints: Search constraints (e.g. {"statuses": ["open"]}) + limit: Maximum number of results to return + after: Cursor for forward pagination + before: Cursor for backward pagination + order: Result ordering + + Returns: + PhabricatorResponse with operation result + """ + url = self.base_url + "/maniphest.search" + + params: dict[str, Any] = {} + if constraints is not None: + params['constraints'] = constraints + if limit is not None: + params['limit'] = limit + if after is not None: + params['after'] = after + if before is not None: + params['before'] = before + if order is not None: + params['order'] = order + + body = self._build_form_body(params) + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PhabricatorResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed search_maniphest_tasks" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PhabricatorResponse(success=False, error=str(e), message="Failed to execute search_maniphest_tasks") + + async def search_differential_revisions( + self, + *, + constraints: dict[str, Any] | None = None, + limit: int | None = None, + after: str | None = None, + before: str | None = None, + order: str | None = None, + ) -> PhabricatorResponse: + """Search Differential revisions (POST /differential.revision.search) + + Args: + constraints: Search constraints (e.g. {"statuses": ["needs-review"]}) + limit: Maximum number of results to return + after: Cursor for forward pagination + before: Cursor for backward pagination + order: Result ordering + + Returns: + PhabricatorResponse with operation result + """ + url = self.base_url + "/differential.revision.search" + + params: dict[str, Any] = {} + if constraints is not None: + params['constraints'] = constraints + if limit is not None: + params['limit'] = limit + if after is not None: + params['after'] = after + if before is not None: + params['before'] = before + if order is not None: + params['order'] = order + + body = self._build_form_body(params) + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PhabricatorResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed search_differential_revisions" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PhabricatorResponse(success=False, error=str(e), message="Failed to execute search_differential_revisions") + + async def search_projects( + self, + *, + constraints: dict[str, Any] | None = None, + limit: int | None = None, + after: str | None = None, + before: str | None = None, + order: str | None = None, + ) -> PhabricatorResponse: + """Search projects (POST /project.search) + + Args: + constraints: Search constraints (e.g. {"name": "Backend"}) + limit: Maximum number of results to return + after: Cursor for forward pagination + before: Cursor for backward pagination + order: Result ordering + + Returns: + PhabricatorResponse with operation result + """ + url = self.base_url + "/project.search" + + params: dict[str, Any] = {} + if constraints is not None: + params['constraints'] = constraints + if limit is not None: + params['limit'] = limit + if after is not None: + params['after'] = after + if before is not None: + params['before'] = before + if order is not None: + params['order'] = order + + body = self._build_form_body(params) + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PhabricatorResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed search_projects" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PhabricatorResponse(success=False, error=str(e), message="Failed to execute search_projects") + + async def search_users( + self, + *, + constraints: dict[str, Any] | None = None, + limit: int | None = None, + after: str | None = None, + before: str | None = None, + order: str | None = None, + ) -> PhabricatorResponse: + """Search users (POST /user.search) + + Args: + constraints: Search constraints (e.g. {"usernames": ["admin"]}) + limit: Maximum number of results to return + after: Cursor for forward pagination + before: Cursor for backward pagination + order: Result ordering + + Returns: + PhabricatorResponse with operation result + """ + url = self.base_url + "/user.search" + + params: dict[str, Any] = {} + if constraints is not None: + params['constraints'] = constraints + if limit is not None: + params['limit'] = limit + if after is not None: + params['after'] = after + if before is not None: + params['before'] = before + if order is not None: + params['order'] = order + + body = self._build_form_body(params) + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PhabricatorResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed search_users" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PhabricatorResponse(success=False, error=str(e), message="Failed to execute search_users") + + async def search_pastes( + self, + *, + constraints: dict[str, Any] | None = None, + limit: int | None = None, + after: str | None = None, + before: str | None = None, + order: str | None = None, + ) -> PhabricatorResponse: + """Search pastes (POST /paste.search) + + Args: + constraints: Search constraints + limit: Maximum number of results to return + after: Cursor for forward pagination + before: Cursor for backward pagination + order: Result ordering + + Returns: + PhabricatorResponse with operation result + """ + url = self.base_url + "/paste.search" + + params: dict[str, Any] = {} + if constraints is not None: + params['constraints'] = constraints + if limit is not None: + params['limit'] = limit + if after is not None: + params['after'] = after + if before is not None: + params['before'] = before + if order is not None: + params['order'] = order + + body = self._build_form_body(params) + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PhabricatorResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed search_pastes" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PhabricatorResponse(success=False, error=str(e), message="Failed to execute search_pastes") + + async def search_repositories( + self, + *, + constraints: dict[str, Any] | None = None, + limit: int | None = None, + after: str | None = None, + before: str | None = None, + order: str | None = None, + ) -> PhabricatorResponse: + """Search Diffusion repositories (POST /diffusion.repository.search) + + Args: + constraints: Search constraints + limit: Maximum number of results to return + after: Cursor for forward pagination + before: Cursor for backward pagination + order: Result ordering + + Returns: + PhabricatorResponse with operation result + """ + url = self.base_url + "/diffusion.repository.search" + + params: dict[str, Any] = {} + if constraints is not None: + params['constraints'] = constraints + if limit is not None: + params['limit'] = limit + if after is not None: + params['after'] = after + if before is not None: + params['before'] = before + if order is not None: + params['order'] = order + + body = self._build_form_body(params) + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PhabricatorResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed search_repositories" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PhabricatorResponse(success=False, error=str(e), message="Failed to execute search_repositories") + + async def lookup_phids( + self, + names: list[str], + ) -> PhabricatorResponse: + """Look up PHIDs by name (POST /phid.lookup) + + Args: + names: List of PHID names to look up (e.g. ["T123", "D456"]) + + Returns: + PhabricatorResponse with operation result + """ + url = self.base_url + "/phid.lookup" + + params: dict[str, Any] = {} + for i, name in enumerate(names): + params[f'names[{i}]'] = name + + body = self._build_form_body(params) + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PhabricatorResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed lookup_phids" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PhabricatorResponse(success=False, error=str(e), message="Failed to execute lookup_phids") + + async def query_feed( + self, + *, + limit: int | None = None, + after: str | None = None, + before: str | None = None, + ) -> PhabricatorResponse: + """Query the activity feed (POST /feed.query) + + Args: + limit: Maximum number of feed items to return + after: Cursor for forward pagination + before: Cursor for backward pagination + + Returns: + PhabricatorResponse with operation result + """ + url = self.base_url + "/feed.query" + + params: dict[str, Any] = {} + if limit is not None: + params['limit'] = limit + if after is not None: + params['after'] = after + if before is not None: + params['before'] = before + + body = self._build_form_body(params) + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PhabricatorResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed query_feed" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PhabricatorResponse(success=False, error=str(e), message="Failed to execute query_feed") diff --git a/backend/python/app/sources/external/pingidentity/code_generator.py b/backend/python/app/sources/external/pingidentity/code_generator.py new file mode 100644 index 000000000..78d7b037a --- /dev/null +++ b/backend/python/app/sources/external/pingidentity/code_generator.py @@ -0,0 +1,241 @@ +# ruff: noqa +""" +Ping Identity (PingOne) DataSource Code Generator + +Defines PingOne API endpoint specifications and generates the DataSource +wrapper class (pingidentity.py) from them. + +Endpoints: + /users, /users/{userId}, /groups, /groups/{groupId}, + /populations, /populations/{populationId}, + /applications, /applications/{applicationId}, + /signOnPolicies, /signOnPolicies/{policyId}, + /schemas, /passwordPolicies, /identityProviders, /gateways + +Note: For OAuth clients, ensure_authenticated() is called if available. +""" + +from __future__ import annotations + +ENDPOINTS = [ + # Users + {"method": "GET", "path": "/users", "name": "list_users", "section": "Users", + "doc": "List all users in the environment", + "query_params": [("limit", "int", "Maximum number of results"), ("filter", "str", "SCIM filter expression")]}, + {"method": "GET", "path": "/users/{user_id}", "name": "get_user", "section": "Users", + "doc": "Get a specific user by ID", "path_params": ["user_id"]}, + # Groups + {"method": "GET", "path": "/groups", "name": "list_groups", "section": "Groups", + "doc": "List all groups in the environment", + "query_params": [("limit", "int", "Maximum number of results"), ("filter", "str", "SCIM filter expression")]}, + {"method": "GET", "path": "/groups/{group_id}", "name": "get_group", "section": "Groups", + "doc": "Get a specific group by ID", "path_params": ["group_id"]}, + # Populations + {"method": "GET", "path": "/populations", "name": "list_populations", "section": "Populations", + "doc": "List all populations in the environment", + "query_params": [("limit", "int", "Maximum number of results")]}, + {"method": "GET", "path": "/populations/{population_id}", "name": "get_population", "section": "Populations", + "doc": "Get a specific population by ID", "path_params": ["population_id"]}, + # Applications + {"method": "GET", "path": "/applications", "name": "list_applications", "section": "Applications", + "doc": "List all applications in the environment", + "query_params": [("limit", "int", "Maximum number of results")]}, + {"method": "GET", "path": "/applications/{application_id}", "name": "get_application", "section": "Applications", + "doc": "Get a specific application by ID", "path_params": ["application_id"]}, + # Sign-On Policies + {"method": "GET", "path": "/signOnPolicies", "name": "list_sign_on_policies", "section": "Sign-On Policies", + "doc": "List all sign-on policies in the environment", + "query_params": [("limit", "int", "Maximum number of results")]}, + {"method": "GET", "path": "/signOnPolicies/{policy_id}", "name": "get_sign_on_policy", + "section": "Sign-On Policies", + "doc": "Get a specific sign-on policy by ID", "path_params": ["policy_id"]}, + # Schemas + {"method": "GET", "path": "/schemas", "name": "list_schemas", "section": "Schemas", + "doc": "List all schemas in the environment"}, + # Password Policies + {"method": "GET", "path": "/passwordPolicies", "name": "list_password_policies", + "section": "Password Policies", + "doc": "List all password policies in the environment"}, + # Identity Providers + {"method": "GET", "path": "/identityProviders", "name": "list_identity_providers", + "section": "Identity Providers", + "doc": "List all identity providers in the environment"}, + # Gateways + {"method": "GET", "path": "/gateways", "name": "list_gateways", "section": "Gateways", + "doc": "List all gateways in the environment"}, +] + + +def _gen_method(ep: dict) -> str: + """Generate a single async method from an endpoint spec.""" + name = ep["name"] + method = ep["method"] + path = ep["path"] + doc = ep["doc"] + path_params = ep.get("path_params", []) + query_params = ep.get("query_params", []) + body_params = ep.get("body_params", []) + + sig_parts = ["self"] + for p in path_params: + sig_parts.append(f"{p}: str") + for bp in body_params: + sig_parts.append(f"{bp[0]}: {bp[2]}") + if query_params: + sig_parts.append("*") + for qp in query_params: + sig_parts.append(f"{qp[0]}: {qp[1]} | None = None") + + sig = ",\n ".join(sig_parts) + + doc_args = "" + if path_params or query_params or body_params: + doc_args = "\n Args:\n" + for p in path_params: + doc_args += f" {p}: The {p.replace('_', ' ')}\n" + for bp in body_params: + doc_args += f" {bp[0]}: {bp[3]}\n" + for qp in query_params: + doc_args += f" {qp[0]}: {qp[2]}\n" + + query_block = "" + if query_params: + lines = ["\n query_params: dict[str, Any] = {}"] + for qp in query_params: + lines.append(f" if {qp[0]} is not None:") + lines.append(f" query_params['{qp[0]}'] = str({qp[0]})") + query_block = "\n".join(lines) + "\n" + + if path_params: + fmt_args = ", ".join(f"{p}={p}" for p in path_params) + url_line = f' url = self.base_url + "{path}".format({fmt_args})' + else: + url_line = f' url = self.base_url + "{path}"' + + body_block = "" + if body_params: + lines = ["\n body: dict[str, Any] = {}"] + for bp in body_params: + lines.append(f' if {bp[0]} is not None:') + lines.append(f' body["{bp[1]}"] = {bp[0]}') + body_block = "\n".join(lines) + + req_extra = "" + if query_params: + req_extra += "\n query=query_params," + if body_params: + req_extra += "\n body=body," + + return f''' + async def {name}( + {sig} + ) -> PingIdentityResponse: + """{doc} + + HTTP {method} {path} +{doc_args} + Returns: + PingIdentityResponse with operation result + """ + if hasattr(self.http, 'ensure_authenticated'): + await self.http.ensure_authenticated() +{query_block} +{url_line} +{body_block} + + try: + request = HTTPRequest( + method="{method}", + url=url, + headers={{"Content-Type": "application/json"}},{req_extra} + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PingIdentityResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed {name}" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {{response.status}}" + ) + except Exception as e: + return PingIdentityResponse(success=False, error=str(e), message="Failed to execute {name}") +''' + + +def generate_datasource() -> str: + """Generate the full PingIdentity DataSource module code.""" + header = '''# ruff: noqa +""" +Ping Identity (PingOne) REST API DataSource - Auto-generated API wrapper + +Generated from PingOne Platform API v1 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. + +Note: For OAuth clients, ensure_authenticated() is called before each + request to auto-fetch a client_credentials OAuth token. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.pingidentity.pingidentity import PingIdentityClient, PingIdentityResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class PingIdentityDataSource: + """PingOne REST API DataSource + + Provides async wrapper methods for PingOne REST API operations: + - Users management + - Groups management + - Populations management + - Applications management + - Sign-On Policies management + - Schemas management + - Password Policies management + - Identity Providers management + - Gateways management + + All methods return PingIdentityResponse objects. + """ + + def __init__(self, client: PingIdentityClient) -> None: + """Initialize with PingIdentityClient. + + Args: + client: PingIdentityClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'PingIdentityDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> PingIdentityClient: + """Return the underlying PingIdentityClient.""" + return self._client +''' + + methods = [] + current_section = None + for ep in ENDPOINTS: + section = ep.get("section", "") + if section and section != current_section: + current_section = section + methods.append(f"\n # {'-' * 71}\n # {section}\n # {'-' * 71}") + methods.append(_gen_method(ep)) + + return header + "\n".join(methods) + "\n" + + +if __name__ == "__main__": + print(generate_datasource()) diff --git a/backend/python/app/sources/external/pingidentity/example.py b/backend/python/app/sources/external/pingidentity/example.py new file mode 100644 index 000000000..58aa2261f --- /dev/null +++ b/backend/python/app/sources/external/pingidentity/example.py @@ -0,0 +1,188 @@ +# ruff: noqa + +""" +Ping Identity (PingOne) API Usage Examples + +This example demonstrates how to use the PingIdentity DataSource to interact +with the PingOne Platform API, covering: +- Authentication (OAuth2 client_credentials, Bearer Token) +- Initializing the Client and DataSource +- Listing Users, Groups, Populations, Applications +- Fetching Sign-On Policies, Schemas, Identity Providers + +Prerequisites: +For OAuth2 (client_credentials): +1. Create a worker application in PingOne Admin console +2. Set PINGONE_ENVIRONMENT_ID, PINGONE_CLIENT_ID, and + PINGONE_CLIENT_SECRET environment variables + +For Bearer Token: +1. Obtain a token via PingOne token endpoint +2. Set PINGONE_ENVIRONMENT_ID and PINGONE_TOKEN environment variables +""" + +import asyncio +import json +import os + +from app.sources.client.pingidentity.pingidentity import ( + PingIdentityClient, + PingIdentityOAuthConfig, + PingIdentityResponse, + PingIdentityTokenConfig, +) +from app.sources.external.pingidentity.pingidentity import PingIdentityDataSource + +# --- Configuration --- +ENVIRONMENT_ID = os.getenv("PINGONE_ENVIRONMENT_ID") + +# OAuth2 credentials +CLIENT_ID = os.getenv("PINGONE_CLIENT_ID") +CLIENT_SECRET = os.getenv("PINGONE_CLIENT_SECRET") + +# Bearer Token +TOKEN = os.getenv("PINGONE_TOKEN") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: PingIdentityResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + if isinstance(data, dict): + # PingOne uses _embedded for collections + embedded = data.get("_embedded", {}) + for key in embedded: + items = embedded[key] + if isinstance(items, list): + print(f" Found {len(items)} {key}.") + if items: + print(f" Sample: {json.dumps(items[0], indent=2)[:400]}...") + return + print(f" Data: {json.dumps(data, indent=2)[:500]}...") + elif isinstance(data, list): + print(f" Found {len(data)} items.") + if data: + print(f" Sample: {json.dumps(data[0], indent=2)[:400]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + # 1. Initialize Client + print_section("Initializing PingIdentity (PingOne) Client") + + if not ENVIRONMENT_ID: + print(" PINGONE_ENVIRONMENT_ID is required.") + return + + config = None + + # Priority 1: OAuth2 client_credentials + if CLIENT_ID and CLIENT_SECRET: + print(" Using OAuth2 client_credentials authentication") + config = PingIdentityOAuthConfig( + environment_id=ENVIRONMENT_ID, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + ) + + # Priority 2: Bearer Token + if config is None and TOKEN: + print(" Using Bearer Token authentication") + config = PingIdentityTokenConfig( + token=TOKEN, + environment_id=ENVIRONMENT_ID, + ) + + if config is None: + print(" No valid authentication method found.") + print(" Please set one of the following:") + print(" - PINGONE_CLIENT_ID and PINGONE_CLIENT_SECRET (for OAuth2)") + print(" - PINGONE_TOKEN (for Bearer Token)") + return + + client = PingIdentityClient.build_with_config(config) + data_source = PingIdentityDataSource(client) + print("Client initialized successfully.") + + try: + # 2. List Users + print_section("Users") + users_resp = await data_source.list_users(limit=10) + print_result("List Users", users_resp) + + # 3. Get a specific user if available + if users_resp.success and isinstance(users_resp.data, dict): + embedded = users_resp.data.get("_embedded", {}) + users = embedded.get("users", []) if isinstance(embedded, dict) else [] + if isinstance(users, list) and users: + user_id = str(users[0].get("id", "")) + if user_id: + print_section(f"User Details: {user_id}") + user_resp = await data_source.get_user(user_id=user_id) + print_result("Get User", user_resp) + + # 4. List Groups + print_section("Groups") + groups_resp = await data_source.list_groups(limit=10) + print_result("List Groups", groups_resp) + + # 5. List Populations + print_section("Populations") + pop_resp = await data_source.list_populations(limit=10) + print_result("List Populations", pop_resp) + + # 6. List Applications + print_section("Applications") + apps_resp = await data_source.list_applications(limit=10) + print_result("List Applications", apps_resp) + + # 7. List Sign-On Policies + print_section("Sign-On Policies") + policies_resp = await data_source.list_sign_on_policies(limit=10) + print_result("List Sign-On Policies", policies_resp) + + # 8. List Schemas + print_section("Schemas") + schemas_resp = await data_source.list_schemas() + print_result("List Schemas", schemas_resp) + + # 9. List Password Policies + print_section("Password Policies") + pw_resp = await data_source.list_password_policies() + print_result("List Password Policies", pw_resp) + + # 10. List Identity Providers + print_section("Identity Providers") + idp_resp = await data_source.list_identity_providers() + print_result("List Identity Providers", idp_resp) + + # 11. List Gateways + print_section("Gateways") + gw_resp = await data_source.list_gateways() + print_result("List Gateways", gw_resp) + + finally: + # Cleanup: Close the HTTP client session + print("\nClosing client connection...") + inner_client = client.get_client() + if hasattr(inner_client, "close"): + await inner_client.close() + + print("\n" + "=" * 80) + print(" All PingIdentity API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/pingidentity/pingidentity.py b/backend/python/app/sources/external/pingidentity/pingidentity.py new file mode 100644 index 000000000..aab59c5dc --- /dev/null +++ b/backend/python/app/sources/external/pingidentity/pingidentity.py @@ -0,0 +1,637 @@ +# ruff: noqa +""" +Ping Identity (PingOne) REST API DataSource - Auto-generated API wrapper + +Generated from PingOne Platform API v1 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. + +Note: For OAuth clients, ensure_authenticated() is called before each + request to auto-fetch a client_credentials OAuth token. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.pingidentity.pingidentity import PingIdentityClient, PingIdentityResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class PingIdentityDataSource: + """PingOne REST API DataSource + + Provides async wrapper methods for PingOne REST API operations: + - Users management + - Groups management + - Populations management + - Applications management + - Sign-On Policies management + - Schemas management + - Password Policies management + - Identity Providers management + - Gateways management + + All methods return PingIdentityResponse objects. + """ + + def __init__(self, client: PingIdentityClient) -> None: + """Initialize with PingIdentityClient. + + Args: + client: PingIdentityClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'PingIdentityDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> PingIdentityClient: + """Return the underlying PingIdentityClient.""" + return self._client + + # ----------------------------------------------------------------------- + # Users + # ----------------------------------------------------------------------- + + async def list_users( + self, + *, + limit: int | None = None, + filter: str | None = None + ) -> PingIdentityResponse: + """List all users in the environment + + HTTP GET /users + + Args: + limit: Maximum number of results + filter: SCIM filter expression + + Returns: + PingIdentityResponse with operation result + """ + if hasattr(self.http, 'ensure_authenticated'): + await self.http.ensure_authenticated() + + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if filter is not None: + query_params['filter'] = str(filter) + + url = self.base_url + "/users" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PingIdentityResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_users" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PingIdentityResponse(success=False, error=str(e), message="Failed to execute list_users") + + + async def get_user( + self, + user_id: str + ) -> PingIdentityResponse: + """Get a specific user by ID + + HTTP GET /users/{user_id} + + Args: + user_id: The user id + + Returns: + PingIdentityResponse with operation result + """ + if hasattr(self.http, 'ensure_authenticated'): + await self.http.ensure_authenticated() + + url = self.base_url + "/users/{user_id}".format(user_id=user_id) + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PingIdentityResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_user" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PingIdentityResponse(success=False, error=str(e), message="Failed to execute get_user") + + + # ----------------------------------------------------------------------- + # Groups + # ----------------------------------------------------------------------- + + async def list_groups( + self, + *, + limit: int | None = None, + filter: str | None = None + ) -> PingIdentityResponse: + """List all groups in the environment + + HTTP GET /groups + + Args: + limit: Maximum number of results + filter: SCIM filter expression + + Returns: + PingIdentityResponse with operation result + """ + if hasattr(self.http, 'ensure_authenticated'): + await self.http.ensure_authenticated() + + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if filter is not None: + query_params['filter'] = str(filter) + + url = self.base_url + "/groups" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PingIdentityResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_groups" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PingIdentityResponse(success=False, error=str(e), message="Failed to execute list_groups") + + + async def get_group( + self, + group_id: str + ) -> PingIdentityResponse: + """Get a specific group by ID + + HTTP GET /groups/{group_id} + + Args: + group_id: The group id + + Returns: + PingIdentityResponse with operation result + """ + if hasattr(self.http, 'ensure_authenticated'): + await self.http.ensure_authenticated() + + url = self.base_url + "/groups/{group_id}".format(group_id=group_id) + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PingIdentityResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_group" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PingIdentityResponse(success=False, error=str(e), message="Failed to execute get_group") + + + # ----------------------------------------------------------------------- + # Populations + # ----------------------------------------------------------------------- + + async def list_populations( + self, + *, + limit: int | None = None + ) -> PingIdentityResponse: + """List all populations in the environment + + HTTP GET /populations + + Args: + limit: Maximum number of results + + Returns: + PingIdentityResponse with operation result + """ + if hasattr(self.http, 'ensure_authenticated'): + await self.http.ensure_authenticated() + + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + + url = self.base_url + "/populations" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PingIdentityResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_populations" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PingIdentityResponse(success=False, error=str(e), message="Failed to execute list_populations") + + + async def get_population( + self, + population_id: str + ) -> PingIdentityResponse: + """Get a specific population by ID + + HTTP GET /populations/{population_id} + + Args: + population_id: The population id + + Returns: + PingIdentityResponse with operation result + """ + if hasattr(self.http, 'ensure_authenticated'): + await self.http.ensure_authenticated() + + url = self.base_url + "/populations/{population_id}".format(population_id=population_id) + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PingIdentityResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_population" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PingIdentityResponse(success=False, error=str(e), message="Failed to execute get_population") + + + # ----------------------------------------------------------------------- + # Applications + # ----------------------------------------------------------------------- + + async def list_applications( + self, + *, + limit: int | None = None + ) -> PingIdentityResponse: + """List all applications in the environment + + HTTP GET /applications + + Args: + limit: Maximum number of results + + Returns: + PingIdentityResponse with operation result + """ + if hasattr(self.http, 'ensure_authenticated'): + await self.http.ensure_authenticated() + + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + + url = self.base_url + "/applications" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PingIdentityResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_applications" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PingIdentityResponse(success=False, error=str(e), message="Failed to execute list_applications") + + + async def get_application( + self, + application_id: str + ) -> PingIdentityResponse: + """Get a specific application by ID + + HTTP GET /applications/{application_id} + + Args: + application_id: The application id + + Returns: + PingIdentityResponse with operation result + """ + if hasattr(self.http, 'ensure_authenticated'): + await self.http.ensure_authenticated() + + url = self.base_url + "/applications/{application_id}".format(application_id=application_id) + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PingIdentityResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_application" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PingIdentityResponse(success=False, error=str(e), message="Failed to execute get_application") + + + # ----------------------------------------------------------------------- + # Sign-On Policies + # ----------------------------------------------------------------------- + + async def list_sign_on_policies( + self, + *, + limit: int | None = None + ) -> PingIdentityResponse: + """List all sign-on policies in the environment + + HTTP GET /signOnPolicies + + Args: + limit: Maximum number of results + + Returns: + PingIdentityResponse with operation result + """ + if hasattr(self.http, 'ensure_authenticated'): + await self.http.ensure_authenticated() + + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + + url = self.base_url + "/signOnPolicies" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PingIdentityResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_sign_on_policies" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PingIdentityResponse(success=False, error=str(e), message="Failed to execute list_sign_on_policies") + + + async def get_sign_on_policy( + self, + policy_id: str + ) -> PingIdentityResponse: + """Get a specific sign-on policy by ID + + HTTP GET /signOnPolicies/{policy_id} + + Args: + policy_id: The policy id + + Returns: + PingIdentityResponse with operation result + """ + if hasattr(self.http, 'ensure_authenticated'): + await self.http.ensure_authenticated() + + url = self.base_url + "/signOnPolicies/{policy_id}".format(policy_id=policy_id) + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PingIdentityResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_sign_on_policy" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PingIdentityResponse(success=False, error=str(e), message="Failed to execute get_sign_on_policy") + + + # ----------------------------------------------------------------------- + # Schemas + # ----------------------------------------------------------------------- + + async def list_schemas( + self + ) -> PingIdentityResponse: + """List all schemas in the environment + + HTTP GET /schemas + + Returns: + PingIdentityResponse with operation result + """ + if hasattr(self.http, 'ensure_authenticated'): + await self.http.ensure_authenticated() + + url = self.base_url + "/schemas" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PingIdentityResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_schemas" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PingIdentityResponse(success=False, error=str(e), message="Failed to execute list_schemas") + + + # ----------------------------------------------------------------------- + # Password Policies + # ----------------------------------------------------------------------- + + async def list_password_policies( + self + ) -> PingIdentityResponse: + """List all password policies in the environment + + HTTP GET /passwordPolicies + + Returns: + PingIdentityResponse with operation result + """ + if hasattr(self.http, 'ensure_authenticated'): + await self.http.ensure_authenticated() + + url = self.base_url + "/passwordPolicies" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PingIdentityResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_password_policies" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PingIdentityResponse(success=False, error=str(e), message="Failed to execute list_password_policies") + + + # ----------------------------------------------------------------------- + # Identity Providers + # ----------------------------------------------------------------------- + + async def list_identity_providers( + self + ) -> PingIdentityResponse: + """List all identity providers in the environment + + HTTP GET /identityProviders + + Returns: + PingIdentityResponse with operation result + """ + if hasattr(self.http, 'ensure_authenticated'): + await self.http.ensure_authenticated() + + url = self.base_url + "/identityProviders" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PingIdentityResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_identity_providers" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PingIdentityResponse(success=False, error=str(e), message="Failed to execute list_identity_providers") + + + # ----------------------------------------------------------------------- + # Gateways + # ----------------------------------------------------------------------- + + async def list_gateways( + self + ) -> PingIdentityResponse: + """List all gateways in the environment + + HTTP GET /gateways + + Returns: + PingIdentityResponse with operation result + """ + if hasattr(self.http, 'ensure_authenticated'): + await self.http.ensure_authenticated() + + url = self.base_url + "/gateways" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PingIdentityResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_gateways" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PingIdentityResponse(success=False, error=str(e), message="Failed to execute list_gateways") + + diff --git a/backend/python/app/sources/external/pipedrive/example.py b/backend/python/app/sources/external/pipedrive/example.py new file mode 100644 index 000000000..63928ef23 --- /dev/null +++ b/backend/python/app/sources/external/pipedrive/example.py @@ -0,0 +1,179 @@ +# ruff: noqa + +""" +Pipedrive API Usage Examples + +This example demonstrates how to use the Pipedrive DataSource to interact with +the Pipedrive API, covering: +- Authentication (OAuth2, API Token) +- Initializing the Client and DataSource +- Getting Current User +- Listing Deals +- Listing Persons (Contacts) +- Listing Pipelines +- Listing Activities + +Prerequisites: +For OAuth2: +1. Create a Pipedrive OAuth app at https://app.pipedrive.com/settings/marketplace +2. Set PIPEDRIVE_CLIENT_ID and PIPEDRIVE_CLIENT_SECRET environment variables +3. The OAuth flow will automatically open a browser for authorization + +For API Token: +1. Log in to Pipedrive +2. Go to Settings > Personal preferences > API +3. Copy your personal API token +4. Set PIPEDRIVE_API_TOKEN environment variable +""" + +import asyncio +import json +import os + +from app.sources.client.pipedrive.pipedrive import ( + PipedriveClient, + PipedriveOAuthConfig, + PipedriveTokenConfig, + PipedriveResponse, +) +from app.sources.external.pipedrive.pipedrive import PipedriveDataSource +from app.sources.external.utils.oauth import perform_oauth_flow + +# --- Configuration --- +# OAuth2 credentials (highest priority) +CLIENT_ID = os.getenv("PIPEDRIVE_CLIENT_ID") +CLIENT_SECRET = os.getenv("PIPEDRIVE_CLIENT_SECRET") + +# API Token (second priority) +API_TOKEN = os.getenv("PIPEDRIVE_API_TOKEN") + +# OAuth redirect URI +REDIRECT_URI = os.getenv("PIPEDRIVE_REDIRECT_URI", "http://localhost:8080/callback") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: PipedriveResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + # Handle Pipedrive list-type responses (data is usually in a 'data' key) + if isinstance(data, dict) and "data" in data: + items = data["data"] + if isinstance(items, list): + print(f" Found {len(items)} items.") + if items: + print(f" Sample: {json.dumps(items[0], indent=2)[:400]}...") + else: + print(f" Data: {json.dumps(items, indent=2)[:500]}...") + else: + # Generic response + print(f" Data: {json.dumps(data, indent=2)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + # 1. Initialize Client + print_section("Initializing Pipedrive Client") + + config = None + + # Priority 1: OAuth2 + if CLIENT_ID and CLIENT_SECRET: + print(" Using OAuth2 authentication") + try: + print("Starting OAuth flow...") + # Pipedrive OAuth authorization URL: https://oauth.pipedrive.com/oauth/authorize + # Pipedrive token endpoint: https://oauth.pipedrive.com/oauth/token + token_response = perform_oauth_flow( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + auth_endpoint="https://oauth.pipedrive.com/oauth/authorize", + token_endpoint="https://oauth.pipedrive.com/oauth/token", + redirect_uri=REDIRECT_URI, + scopes=[], # Pipedrive doesn't require specific scopes in the auth URL + scope_delimiter=" ", + auth_method="header", # Basic Auth with client_id:client_secret + ) + + access_token = token_response.get("access_token") + if not access_token: + raise Exception("No access_token found in OAuth response") + + config = PipedriveOAuthConfig( + access_token=access_token, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + ) + print(" OAuth authentication successful") + except Exception as e: + print(f" OAuth flow failed: {e}") + print(" Falling back to other authentication methods...") + + # Priority 2: API Token + if config is None and API_TOKEN: + print(" Using API Token authentication") + config = PipedriveTokenConfig( + token=API_TOKEN, + ) + + if config is None: + print(" No valid authentication method found.") + print(" Please set one of the following:") + print(" - PIPEDRIVE_CLIENT_ID and PIPEDRIVE_CLIENT_SECRET (for OAuth2)") + print(" - PIPEDRIVE_API_TOKEN (for API Token)") + return + + client = PipedriveClient.build_with_config(config) + data_source = PipedriveDataSource(client) + print("Client initialized successfully.") + + try: + # 2. Get Current User + print_section("Current User") + user_resp = await data_source.get_current_user() + print_result("Get Current User", user_resp) + + # 3. List Deals + print_section("Deals") + deals_resp = await data_source.list_deals(limit=5) + print_result("List Deals", deals_resp) + + # 4. List Persons (Contacts) + print_section("Persons (Contacts)") + persons_resp = await data_source.list_persons(limit=5) + print_result("List Persons", persons_resp) + + # 5. List Pipelines + print_section("Pipelines") + pipelines_resp = await data_source.list_pipelines() + print_result("List Pipelines", pipelines_resp) + + # 6. List Activities + print_section("Activities") + activities_resp = await data_source.list_activities(limit=5) + print_result("List Activities", activities_resp) + + finally: + # Cleanup: Close the HTTP client session + print("\nClosing client connection...") + inner_client = client.get_client() + if hasattr(inner_client, "close"): + await inner_client.close() + + print("\n" + "=" * 80) + print(" All Pipedrive API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/pipedrive/pipedrive.py b/backend/python/app/sources/external/pipedrive/pipedrive.py new file mode 100644 index 000000000..dd01a2613 --- /dev/null +++ b/backend/python/app/sources/external/pipedrive/pipedrive.py @@ -0,0 +1,1371 @@ +""" +Pipedrive REST API DataSource - Auto-generated API wrapper + +Generated from Pipedrive REST API v1 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.http.http_request import HTTPRequest +from app.sources.client.pipedrive.pipedrive import PipedriveClient, PipedriveResponse + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class PipedriveDataSource: + """Pipedrive REST API DataSource + + Provides async wrapper methods for Pipedrive REST API operations: + - Users management + - Deals CRUD and management + - Persons (contacts) CRUD + - Organizations CRUD + - Activities management + - Pipelines and Stages + - Products management + - Notes CRUD + - Leads management + - Custom fields (Deal, Person, Organization) + + The base URL is determined by the PipedriveClient's configured base URL. + + All methods return PipedriveResponse objects. + """ + + def __init__(self, client: PipedriveClient) -> None: + """Initialize with PipedriveClient. + + Args: + client: PipedriveClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'PipedriveDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> PipedriveClient: + """Return the underlying PipedriveClient.""" + return self._client + + async def list_users( + self + ) -> PipedriveResponse: + """List all users in the company + + Returns: + PipedriveResponse with operation result + """ + url = self.base_url + "/users" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PipedriveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_users" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PipedriveResponse(success=False, error=str(e), message="Failed to execute list_users") + + async def get_user( + self, + id_: str + ) -> PipedriveResponse: + """Get details of a specific user + + Args: + id_: The user ID + + Returns: + PipedriveResponse with operation result + """ + url = self.base_url + "/users/{id}".format(id=id_) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PipedriveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_user" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PipedriveResponse(success=False, error=str(e), message="Failed to execute get_user") + + async def get_current_user( + self + ) -> PipedriveResponse: + """Get the current authenticated user + + Returns: + PipedriveResponse with operation result + """ + url = self.base_url + "/users/me" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PipedriveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_current_user" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PipedriveResponse(success=False, error=str(e), message="Failed to execute get_current_user") + + async def list_deals( + self, + status: str | None = None, + start: int | None = None, + limit: int | None = None, + sort: str | None = None, + filter_id: int | None = None + ) -> PipedriveResponse: + """List all deals + + Args: + status: Filter by deal status (open, won, lost, deleted, all_not_deleted) + start: Pagination start (default 0) + limit: Items shown per page (default 100) + sort: Field name and sorting mode (e.g. 'title ASC') + filter_id: ID of the filter to use + + Returns: + PipedriveResponse with operation result + """ + query_params: dict[str, Any] = {} + if status is not None: + query_params['status'] = status + if start is not None: + query_params['start'] = str(start) + if limit is not None: + query_params['limit'] = str(limit) + if sort is not None: + query_params['sort'] = sort + if filter_id is not None: + query_params['filter_id'] = str(filter_id) + + url = self.base_url + "/deals" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PipedriveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_deals" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PipedriveResponse(success=False, error=str(e), message="Failed to execute list_deals") + + async def get_deal( + self, + id_: str + ) -> PipedriveResponse: + """Get details of a specific deal + + Args: + id_: The deal ID + + Returns: + PipedriveResponse with operation result + """ + url = self.base_url + "/deals/{id}".format(id=id_) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PipedriveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_deal" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PipedriveResponse(success=False, error=str(e), message="Failed to execute get_deal") + + async def create_deal( + self, + title: str, + value: str | None = None, + currency: str | None = None, + user_id: int | None = None, + person_id: int | None = None, + org_id: int | None = None, + pipeline_id: int | None = None, + stage_id: int | None = None, + status: str | None = None, + expected_close_date: str | None = None, + probability: int | None = None + ) -> PipedriveResponse: + """Create a new deal + + Args: + title: The title of the deal + value: Value of the deal + currency: Currency of the deal (3-letter code) + user_id: ID of the user who owns the deal + person_id: ID of a person linked to the deal + org_id: ID of an organization linked to the deal + pipeline_id: ID of the pipeline this deal will be placed in + stage_id: ID of the stage this deal will be placed in + status: Status of the deal (open, won, lost, deleted) + expected_close_date: Expected close date (YYYY-MM-DD) + probability: Deal success probability percentage + + Returns: + PipedriveResponse with operation result + """ + url = self.base_url + "/deals" + + body: dict[str, Any] = {} + body['title'] = title + if value is not None: + body['value'] = value + if currency is not None: + body['currency'] = currency + if user_id is not None: + body['user_id'] = user_id + if person_id is not None: + body['person_id'] = person_id + if org_id is not None: + body['org_id'] = org_id + if pipeline_id is not None: + body['pipeline_id'] = pipeline_id + if stage_id is not None: + body['stage_id'] = stage_id + if status is not None: + body['status'] = status + if expected_close_date is not None: + body['expected_close_date'] = expected_close_date + if probability is not None: + body['probability'] = probability + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PipedriveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed create_deal" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PipedriveResponse(success=False, error=str(e), message="Failed to execute create_deal") + + async def update_deal( + self, + id_: str, + title: str | None = None, + value: str | None = None, + currency: str | None = None, + user_id: int | None = None, + person_id: int | None = None, + org_id: int | None = None, + pipeline_id: int | None = None, + stage_id: int | None = None, + status: str | None = None, + expected_close_date: str | None = None, + probability: int | None = None + ) -> PipedriveResponse: + """Update a deal + + Args: + id_: The deal ID + title: The title of the deal + value: Value of the deal + currency: Currency of the deal (3-letter code) + user_id: ID of the user who owns the deal + person_id: ID of a person linked to the deal + org_id: ID of an organization linked to the deal + pipeline_id: ID of the pipeline + stage_id: ID of the stage + status: Status of the deal (open, won, lost, deleted) + expected_close_date: Expected close date (YYYY-MM-DD) + probability: Deal success probability percentage + + Returns: + PipedriveResponse with operation result + """ + url = self.base_url + "/deals/{id}".format(id=id_) + + body: dict[str, Any] = {} + if title is not None: + body['title'] = title + if value is not None: + body['value'] = value + if currency is not None: + body['currency'] = currency + if user_id is not None: + body['user_id'] = user_id + if person_id is not None: + body['person_id'] = person_id + if org_id is not None: + body['org_id'] = org_id + if pipeline_id is not None: + body['pipeline_id'] = pipeline_id + if stage_id is not None: + body['stage_id'] = stage_id + if status is not None: + body['status'] = status + if expected_close_date is not None: + body['expected_close_date'] = expected_close_date + if probability is not None: + body['probability'] = probability + + try: + request = HTTPRequest( + method="PUT", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PipedriveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed update_deal" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PipedriveResponse(success=False, error=str(e), message="Failed to execute update_deal") + + async def delete_deal( + self, + id_: str + ) -> PipedriveResponse: + """Delete a deal + + Args: + id_: The deal ID + + Returns: + PipedriveResponse with operation result + """ + url = self.base_url + "/deals/{id}".format(id=id_) + + try: + request = HTTPRequest( + method="DELETE", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PipedriveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed delete_deal" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PipedriveResponse(success=False, error=str(e), message="Failed to execute delete_deal") + + async def list_persons( + self, + start: int | None = None, + limit: int | None = None, + sort: str | None = None, + filter_id: int | None = None + ) -> PipedriveResponse: + """List all persons (contacts) + + Args: + start: Pagination start (default 0) + limit: Items shown per page (default 100) + sort: Field name and sorting mode + filter_id: ID of the filter to use + + Returns: + PipedriveResponse with operation result + """ + query_params: dict[str, Any] = {} + if start is not None: + query_params['start'] = str(start) + if limit is not None: + query_params['limit'] = str(limit) + if sort is not None: + query_params['sort'] = sort + if filter_id is not None: + query_params['filter_id'] = str(filter_id) + + url = self.base_url + "/persons" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PipedriveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_persons" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PipedriveResponse(success=False, error=str(e), message="Failed to execute list_persons") + + async def get_person( + self, + id_: str + ) -> PipedriveResponse: + """Get details of a specific person + + Args: + id_: The person ID + + Returns: + PipedriveResponse with operation result + """ + url = self.base_url + "/persons/{id}".format(id=id_) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PipedriveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_person" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PipedriveResponse(success=False, error=str(e), message="Failed to execute get_person") + + async def create_person( + self, + name: str, + owner_id: int | None = None, + org_id: int | None = None, + email: str | None = None, + phone: str | None = None + ) -> PipedriveResponse: + """Create a new person (contact) + + Args: + name: The name of the person + owner_id: ID of the user who owns the person + org_id: ID of the organization this person belongs to + email: Email address of the person + phone: Phone number of the person + + Returns: + PipedriveResponse with operation result + """ + url = self.base_url + "/persons" + + body: dict[str, Any] = {} + body['name'] = name + if owner_id is not None: + body['owner_id'] = owner_id + if org_id is not None: + body['org_id'] = org_id + if email is not None: + body['email'] = email + if phone is not None: + body['phone'] = phone + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PipedriveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed create_person" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PipedriveResponse(success=False, error=str(e), message="Failed to execute create_person") + + async def update_person( + self, + id_: str, + name: str | None = None, + owner_id: int | None = None, + org_id: int | None = None, + email: str | None = None, + phone: str | None = None + ) -> PipedriveResponse: + """Update a person + + Args: + id_: The person ID + name: The name of the person + owner_id: ID of the user who owns the person + org_id: ID of the organization + email: Email address of the person + phone: Phone number of the person + + Returns: + PipedriveResponse with operation result + """ + url = self.base_url + "/persons/{id}".format(id=id_) + + body: dict[str, Any] = {} + if name is not None: + body['name'] = name + if owner_id is not None: + body['owner_id'] = owner_id + if org_id is not None: + body['org_id'] = org_id + if email is not None: + body['email'] = email + if phone is not None: + body['phone'] = phone + + try: + request = HTTPRequest( + method="PUT", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PipedriveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed update_person" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PipedriveResponse(success=False, error=str(e), message="Failed to execute update_person") + + async def list_organizations( + self, + start: int | None = None, + limit: int | None = None, + sort: str | None = None, + filter_id: int | None = None + ) -> PipedriveResponse: + """List all organizations + + Args: + start: Pagination start (default 0) + limit: Items shown per page (default 100) + sort: Field name and sorting mode + filter_id: ID of the filter to use + + Returns: + PipedriveResponse with operation result + """ + query_params: dict[str, Any] = {} + if start is not None: + query_params['start'] = str(start) + if limit is not None: + query_params['limit'] = str(limit) + if sort is not None: + query_params['sort'] = sort + if filter_id is not None: + query_params['filter_id'] = str(filter_id) + + url = self.base_url + "/organizations" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PipedriveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_organizations" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PipedriveResponse(success=False, error=str(e), message="Failed to execute list_organizations") + + async def get_organization( + self, + id_: str + ) -> PipedriveResponse: + """Get details of a specific organization + + Args: + id_: The organization ID + + Returns: + PipedriveResponse with operation result + """ + url = self.base_url + "/organizations/{id}".format(id=id_) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PipedriveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_organization" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PipedriveResponse(success=False, error=str(e), message="Failed to execute get_organization") + + async def create_organization( + self, + name: str, + owner_id: int | None = None + ) -> PipedriveResponse: + """Create a new organization + + Args: + name: The name of the organization + owner_id: ID of the user who owns the organization + + Returns: + PipedriveResponse with operation result + """ + url = self.base_url + "/organizations" + + body: dict[str, Any] = {} + body['name'] = name + if owner_id is not None: + body['owner_id'] = owner_id + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PipedriveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed create_organization" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PipedriveResponse(success=False, error=str(e), message="Failed to execute create_organization") + + async def list_activities( + self, + start: int | None = None, + limit: int | None = None, + type_: str | None = None, + done: int | None = None, + user_id: int | None = None, + start_date: str | None = None, + end_date: str | None = None + ) -> PipedriveResponse: + """List all activities + + Args: + start: Pagination start (default 0) + limit: Items shown per page (default 100) + type_: Type of activity (e.g. call, meeting, task, deadline, email) + done: Filter by done status (0 = not done, 1 = done) + user_id: Filter by user ID + start_date: Start date filter (YYYY-MM-DD) + end_date: End date filter (YYYY-MM-DD) + + Returns: + PipedriveResponse with operation result + """ + query_params: dict[str, Any] = {} + if start is not None: + query_params['start'] = str(start) + if limit is not None: + query_params['limit'] = str(limit) + if type_ is not None: + query_params['type'] = type_ + if done is not None: + query_params['done'] = str(done) + if user_id is not None: + query_params['user_id'] = str(user_id) + if start_date is not None: + query_params['start_date'] = start_date + if end_date is not None: + query_params['end_date'] = end_date + + url = self.base_url + "/activities" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PipedriveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_activities" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PipedriveResponse(success=False, error=str(e), message="Failed to execute list_activities") + + async def get_activity( + self, + id_: str + ) -> PipedriveResponse: + """Get details of a specific activity + + Args: + id_: The activity ID + + Returns: + PipedriveResponse with operation result + """ + url = self.base_url + "/activities/{id}".format(id=id_) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PipedriveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_activity" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PipedriveResponse(success=False, error=str(e), message="Failed to execute get_activity") + + async def create_activity( + self, + subject: str, + type_: str, + done: int | None = None, + due_date: str | None = None, + due_time: str | None = None, + duration: str | None = None, + deal_id: int | None = None, + person_id: int | None = None, + org_id: int | None = None, + user_id: int | None = None, + note: str | None = None + ) -> PipedriveResponse: + """Create a new activity + + Args: + subject: Subject of the activity + type_: Type of the activity (e.g. call, meeting, task) + done: Whether the activity is done (0 or 1) + due_date: Due date of the activity (YYYY-MM-DD) + due_time: Due time of the activity (HH:MM) + duration: Duration of the activity (HH:MM) + deal_id: ID of the deal this activity is linked to + person_id: ID of the person this activity is linked to + org_id: ID of the organization this activity is linked to + user_id: ID of the user who owns the activity + note: Note of the activity (HTML format) + + Returns: + PipedriveResponse with operation result + """ + url = self.base_url + "/activities" + + body: dict[str, Any] = {} + body['subject'] = subject + body['type'] = type_ + if done is not None: + body['done'] = done + if due_date is not None: + body['due_date'] = due_date + if due_time is not None: + body['due_time'] = due_time + if duration is not None: + body['duration'] = duration + if deal_id is not None: + body['deal_id'] = deal_id + if person_id is not None: + body['person_id'] = person_id + if org_id is not None: + body['org_id'] = org_id + if user_id is not None: + body['user_id'] = user_id + if note is not None: + body['note'] = note + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PipedriveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed create_activity" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PipedriveResponse(success=False, error=str(e), message="Failed to execute create_activity") + + async def list_pipelines( + self + ) -> PipedriveResponse: + """List all pipelines + + Returns: + PipedriveResponse with operation result + """ + url = self.base_url + "/pipelines" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PipedriveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_pipelines" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PipedriveResponse(success=False, error=str(e), message="Failed to execute list_pipelines") + + async def get_pipeline( + self, + id_: str + ) -> PipedriveResponse: + """Get details of a specific pipeline + + Args: + id_: The pipeline ID + + Returns: + PipedriveResponse with operation result + """ + url = self.base_url + "/pipelines/{id}".format(id=id_) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PipedriveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_pipeline" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PipedriveResponse(success=False, error=str(e), message="Failed to execute get_pipeline") + + async def list_stages( + self, + pipeline_id: int | None = None + ) -> PipedriveResponse: + """List all stages + + Args: + pipeline_id: Filter stages by pipeline ID + + Returns: + PipedriveResponse with operation result + """ + query_params: dict[str, Any] = {} + if pipeline_id is not None: + query_params['pipeline_id'] = str(pipeline_id) + + url = self.base_url + "/stages" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PipedriveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_stages" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PipedriveResponse(success=False, error=str(e), message="Failed to execute list_stages") + + async def get_stage( + self, + id_: str + ) -> PipedriveResponse: + """Get details of a specific stage + + Args: + id_: The stage ID + + Returns: + PipedriveResponse with operation result + """ + url = self.base_url + "/stages/{id}".format(id=id_) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PipedriveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_stage" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PipedriveResponse(success=False, error=str(e), message="Failed to execute get_stage") + + async def list_products( + self, + start: int | None = None, + limit: int | None = None + ) -> PipedriveResponse: + """List all products + + Args: + start: Pagination start (default 0) + limit: Items shown per page (default 100) + + Returns: + PipedriveResponse with operation result + """ + query_params: dict[str, Any] = {} + if start is not None: + query_params['start'] = str(start) + if limit is not None: + query_params['limit'] = str(limit) + + url = self.base_url + "/products" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PipedriveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_products" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PipedriveResponse(success=False, error=str(e), message="Failed to execute list_products") + + async def get_product( + self, + id_: str + ) -> PipedriveResponse: + """Get details of a specific product + + Args: + id_: The product ID + + Returns: + PipedriveResponse with operation result + """ + url = self.base_url + "/products/{id}".format(id=id_) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PipedriveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_product" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PipedriveResponse(success=False, error=str(e), message="Failed to execute get_product") + + async def list_notes( + self, + deal_id: int | None = None, + person_id: int | None = None, + org_id: int | None = None, + start: int | None = None, + limit: int | None = None + ) -> PipedriveResponse: + """List all notes + + Args: + deal_id: Filter notes by deal ID + person_id: Filter notes by person ID + org_id: Filter notes by organization ID + start: Pagination start (default 0) + limit: Items shown per page (default 100) + + Returns: + PipedriveResponse with operation result + """ + query_params: dict[str, Any] = {} + if deal_id is not None: + query_params['deal_id'] = str(deal_id) + if person_id is not None: + query_params['person_id'] = str(person_id) + if org_id is not None: + query_params['org_id'] = str(org_id) + if start is not None: + query_params['start'] = str(start) + if limit is not None: + query_params['limit'] = str(limit) + + url = self.base_url + "/notes" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PipedriveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_notes" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PipedriveResponse(success=False, error=str(e), message="Failed to execute list_notes") + + async def get_note( + self, + id_: str + ) -> PipedriveResponse: + """Get details of a specific note + + Args: + id_: The note ID + + Returns: + PipedriveResponse with operation result + """ + url = self.base_url + "/notes/{id}".format(id=id_) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PipedriveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_note" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PipedriveResponse(success=False, error=str(e), message="Failed to execute get_note") + + async def create_note( + self, + content: str, + deal_id: int | None = None, + person_id: int | None = None, + org_id: int | None = None + ) -> PipedriveResponse: + """Create a new note + + Args: + content: Content of the note (HTML format) + deal_id: ID of the deal this note is attached to + person_id: ID of the person this note is attached to + org_id: ID of the organization this note is attached to + + Returns: + PipedriveResponse with operation result + """ + url = self.base_url + "/notes" + + body: dict[str, Any] = {} + body['content'] = content + if deal_id is not None: + body['deal_id'] = deal_id + if person_id is not None: + body['person_id'] = person_id + if org_id is not None: + body['org_id'] = org_id + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PipedriveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed create_note" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PipedriveResponse(success=False, error=str(e), message="Failed to execute create_note") + + async def list_leads( + self, + limit: int | None = None, + start: int | None = None, + sort: str | None = None, + filter_id: int | None = None + ) -> PipedriveResponse: + """List all leads + + Args: + limit: Items shown per page (default 100) + start: Pagination start (default 0) + sort: Field name and sorting mode + filter_id: ID of the filter to use + + Returns: + PipedriveResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if start is not None: + query_params['start'] = str(start) + if sort is not None: + query_params['sort'] = sort + if filter_id is not None: + query_params['filter_id'] = str(filter_id) + + url = self.base_url + "/leads" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PipedriveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_leads" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PipedriveResponse(success=False, error=str(e), message="Failed to execute list_leads") + + async def get_lead( + self, + id_: str + ) -> PipedriveResponse: + """Get details of a specific lead + + Args: + id_: The lead ID + + Returns: + PipedriveResponse with operation result + """ + url = self.base_url + "/leads/{id}".format(id=id_) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PipedriveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_lead" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PipedriveResponse(success=False, error=str(e), message="Failed to execute get_lead") + + async def list_deal_fields( + self, + start: int | None = None, + limit: int | None = None + ) -> PipedriveResponse: + """List all deal fields (including custom fields) + + Args: + start: Pagination start (default 0) + limit: Items shown per page (default 100) + + Returns: + PipedriveResponse with operation result + """ + query_params: dict[str, Any] = {} + if start is not None: + query_params['start'] = str(start) + if limit is not None: + query_params['limit'] = str(limit) + + url = self.base_url + "/dealFields" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PipedriveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_deal_fields" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PipedriveResponse(success=False, error=str(e), message="Failed to execute list_deal_fields") + + async def list_person_fields( + self, + start: int | None = None, + limit: int | None = None + ) -> PipedriveResponse: + """List all person fields (including custom fields) + + Args: + start: Pagination start (default 0) + limit: Items shown per page (default 100) + + Returns: + PipedriveResponse with operation result + """ + query_params: dict[str, Any] = {} + if start is not None: + query_params['start'] = str(start) + if limit is not None: + query_params['limit'] = str(limit) + + url = self.base_url + "/personFields" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PipedriveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_person_fields" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PipedriveResponse(success=False, error=str(e), message="Failed to execute list_person_fields") + + async def list_organization_fields( + self, + start: int | None = None, + limit: int | None = None + ) -> PipedriveResponse: + """List all organization fields (including custom fields) + + Args: + start: Pagination start (default 0) + limit: Items shown per page (default 100) + + Returns: + PipedriveResponse with operation result + """ + query_params: dict[str, Any] = {} + if start is not None: + query_params['start'] = str(start) + if limit is not None: + query_params['limit'] = str(limit) + + url = self.base_url + "/organizationFields" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PipedriveResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_organization_fields" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PipedriveResponse(success=False, error=str(e), message="Failed to execute list_organization_fields") diff --git a/backend/python/app/sources/external/plusplus/code_generator.py b/backend/python/app/sources/external/plusplus/code_generator.py new file mode 100644 index 000000000..3ed33f37d --- /dev/null +++ b/backend/python/app/sources/external/plusplus/code_generator.py @@ -0,0 +1,207 @@ +# ruff: noqa +""" +PlusPlus DataSource Code Generator + +Defines PlusPlus API endpoint specifications and generates the DataSource +wrapper class (plusplus.py) from them. + +Endpoints: + /events, /events/{id}, /users, /users/{id}, /tracks, /tracks/{id}, + /channels, /channels/{id}, /content, /content/{id}, /tags, /enrollments +""" + +from __future__ import annotations + +ENDPOINTS = [ + # Events + {"method": "GET", "path": "/events", "name": "list_events", "section": "Events", + "doc": "List all events", "paginated": True}, + {"method": "GET", "path": "/events/{event_id}", "name": "get_event", "section": "Events", + "doc": "Get a specific event by ID", "path_params": ["event_id"]}, + # Users + {"method": "GET", "path": "/users", "name": "list_users", "section": "Users", + "doc": "List all users", "paginated": True}, + {"method": "GET", "path": "/users/{user_id}", "name": "get_user", "section": "Users", + "doc": "Get a specific user by ID", "path_params": ["user_id"]}, + # Tracks + {"method": "GET", "path": "/tracks", "name": "list_tracks", "section": "Tracks", + "doc": "List all tracks", "paginated": True}, + {"method": "GET", "path": "/tracks/{track_id}", "name": "get_track", "section": "Tracks", + "doc": "Get a specific track by ID", "path_params": ["track_id"]}, + # Channels + {"method": "GET", "path": "/channels", "name": "list_channels", "section": "Channels", + "doc": "List all channels", "paginated": True}, + {"method": "GET", "path": "/channels/{channel_id}", "name": "get_channel", "section": "Channels", + "doc": "Get a specific channel by ID", "path_params": ["channel_id"]}, + # Content + {"method": "GET", "path": "/content", "name": "list_content", "section": "Content", + "doc": "List all content", "paginated": True}, + {"method": "GET", "path": "/content/{content_id}", "name": "get_content", "section": "Content", + "doc": "Get a specific content item by ID", "path_params": ["content_id"]}, + # Tags + {"method": "GET", "path": "/tags", "name": "list_tags", "section": "Tags", + "doc": "List all tags", "paginated": True}, + # Enrollments + {"method": "GET", "path": "/enrollments", "name": "list_enrollments", "section": "Enrollments", + "doc": "List all enrollments", "paginated": True}, +] + + +def _gen_method(ep: dict) -> str: + """Generate a single async method from an endpoint spec.""" + name = ep["name"] + method = ep["method"] + path = ep["path"] + doc = ep["doc"] + path_params = ep.get("path_params", []) + paginated = ep.get("paginated", False) + + # Build signature + sig_parts = ["self"] + for p in path_params: + sig_parts.append(f"{p}: str") + if paginated: + sig_parts.append("*") + sig_parts.append("page: int | None = None") + sig_parts.append("per_page: int | None = None") + + sig = ",\n ".join(sig_parts) + + # Build doc args + doc_args = "" + if path_params or paginated: + doc_args = "\n Args:\n" + for p in path_params: + doc_args += f" {p}: The {p.replace('_', ' ')}\n" + if paginated: + doc_args += " page: Page number for pagination\n" + doc_args += " per_page: Number of items per page\n" + + # Build query params + query_block = "" + if paginated: + query_block = """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) +""" + + # Build URL + if path_params: + fmt_args = ", ".join(f"{p}={p}" for p in path_params) + url_line = f' url = self.base_url + "{path}".format({fmt_args})' + else: + url_line = f' url = self.base_url + "{path}"' + + # Build request kwargs + req_extra = "" + if paginated: + req_extra = "\n query=query_params," + + return f''' + async def {name}( + {sig} + ) -> PlusPlusResponse: + """{doc} + + HTTP {method} {path} +{doc_args} + Returns: + PlusPlusResponse with operation result + """ +{query_block} +{url_line} + + try: + request = HTTPRequest( + method="{method}", + url=url, + headers={{"Content-Type": "application/json"}},{req_extra} + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PlusPlusResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed {name}" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {{response.status}}" + ) + except Exception as e: + return PlusPlusResponse(success=False, error=str(e), message="Failed to execute {name}") +''' + + +def generate_datasource() -> str: + """Generate the full PlusPlus DataSource module code.""" + header = '''# ruff: noqa +""" +PlusPlus REST API DataSource - Auto-generated API wrapper + +Generated from PlusPlus REST API v1 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.plusplus.plusplus import PlusPlusClient, PlusPlusResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class PlusPlusDataSource: + """PlusPlus REST API DataSource + + Provides async wrapper methods for PlusPlus REST API operations: + - Events management + - Users management + - Tracks management + - Channels management + - Content management + - Tags + - Enrollments + + All methods return PlusPlusResponse objects. + """ + + def __init__(self, client: PlusPlusClient) -> None: + """Initialize with PlusPlusClient. + + Args: + client: PlusPlusClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'PlusPlusDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> PlusPlusClient: + """Return the underlying PlusPlusClient.""" + return self._client +''' + + methods = [] + current_section = None + for ep in ENDPOINTS: + section = ep.get("section", "") + if section and section != current_section: + current_section = section + methods.append(f"\n # {'-' * 71}\n # {section}\n # {'-' * 71}") + methods.append(_gen_method(ep)) + + return header + "\n".join(methods) + "\n" + + +if __name__ == "__main__": + print(generate_datasource()) diff --git a/backend/python/app/sources/external/plusplus/example.py b/backend/python/app/sources/external/plusplus/example.py new file mode 100644 index 000000000..6ae8176d5 --- /dev/null +++ b/backend/python/app/sources/external/plusplus/example.py @@ -0,0 +1,128 @@ +# ruff: noqa + +""" +PlusPlus API Usage Examples + +This example demonstrates how to use the PlusPlus DataSource to interact with +the PlusPlus API, covering: +- Authentication (API Key / Bearer Token) +- Initializing the Client and DataSource +- Listing Events, Users, Tracks, Channels, Content +- Fetching specific resources by ID + +Prerequisites: +1. Obtain an API key from PlusPlus +2. Set PLUSPLUS_API_KEY environment variable +""" + +import asyncio +import json +import os + +from app.sources.client.plusplus.plusplus import ( + PlusPlusClient, + PlusPlusTokenConfig, + PlusPlusResponse, +) +from app.sources.external.plusplus.plusplus import PlusPlusDataSource + +# --- Configuration --- +API_KEY = os.getenv("PLUSPLUS_API_KEY") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: PlusPlusResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + for key in ("events", "users", "tracks", "channels", "content", + "tags", "enrollments", "results"): + if isinstance(data, dict) and key in data: + items = data[key] + print(f" Found {len(items)} {key}.") + if items: + print(f" Sample: {json.dumps(items[0], indent=2)[:400]}...") + return + if isinstance(data, list): + print(f" Found {len(data)} items.") + if data: + print(f" Sample: {json.dumps(data[0], indent=2)[:400]}...") + return + print(f" Data: {json.dumps(data, indent=2)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + # 1. Initialize Client + print_section("Initializing PlusPlus Client") + + if not API_KEY: + print(" No API key found.") + print(" Please set PLUSPLUS_API_KEY environment variable.") + return + + print(" Using API Key authentication") + config = PlusPlusTokenConfig(token=API_KEY) + client = PlusPlusClient.build_with_config(config) + data_source = PlusPlusDataSource(client) + print("Client initialized successfully.") + + try: + # 2. List Events + print_section("Events") + events_resp = await data_source.list_events(page=1, per_page=10) + print_result("List Events", events_resp) + + # 3. List Users + print_section("Users") + users_resp = await data_source.list_users(page=1, per_page=10) + print_result("List Users", users_resp) + + # 4. List Tracks + print_section("Tracks") + tracks_resp = await data_source.list_tracks(page=1, per_page=10) + print_result("List Tracks", tracks_resp) + + # 5. List Channels + print_section("Channels") + channels_resp = await data_source.list_channels(page=1, per_page=10) + print_result("List Channels", channels_resp) + + # 6. List Content + print_section("Content") + content_resp = await data_source.list_content(page=1, per_page=10) + print_result("List Content", content_resp) + + # 7. List Tags + print_section("Tags") + tags_resp = await data_source.list_tags(page=1, per_page=10) + print_result("List Tags", tags_resp) + + # 8. List Enrollments + print_section("Enrollments") + enrollments_resp = await data_source.list_enrollments(page=1, per_page=10) + print_result("List Enrollments", enrollments_resp) + + finally: + print("\nClosing client connection...") + inner_client = client.get_client() + if hasattr(inner_client, "close"): + await inner_client.close() + + print("\n" + "=" * 80) + print(" All PlusPlus API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/plusplus/plusplus.py b/backend/python/app/sources/external/plusplus/plusplus.py new file mode 100644 index 000000000..13ab92d84 --- /dev/null +++ b/backend/python/app/sources/external/plusplus/plusplus.py @@ -0,0 +1,537 @@ +# ruff: noqa +""" +PlusPlus REST API DataSource - Auto-generated API wrapper + +Generated from PlusPlus REST API v1 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.plusplus.plusplus import PlusPlusClient, PlusPlusResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class PlusPlusDataSource: + """PlusPlus REST API DataSource + + Provides async wrapper methods for PlusPlus REST API operations: + - Events management + - Users management + - Tracks management + - Channels management + - Content management + - Tags + - Enrollments + + All methods return PlusPlusResponse objects. + """ + + def __init__(self, client: PlusPlusClient) -> None: + """Initialize with PlusPlusClient. + + Args: + client: PlusPlusClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'PlusPlusDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> PlusPlusClient: + """Return the underlying PlusPlusClient.""" + return self._client + + # ----------------------------------------------------------------------- + # Events + # ----------------------------------------------------------------------- + + async def list_events( + self, + *, + page: int | None = None, + per_page: int | None = None + ) -> PlusPlusResponse: + """List all events + + HTTP GET /events + + Args: + page: Page number for pagination + per_page: Number of items per page + + Returns: + PlusPlusResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/events" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PlusPlusResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_events" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PlusPlusResponse(success=False, error=str(e), message="Failed to execute list_events") + + async def get_event( + self, + event_id: str + ) -> PlusPlusResponse: + """Get a specific event by ID + + HTTP GET /events/{event_id} + + Args: + event_id: The event ID + + Returns: + PlusPlusResponse with operation result + """ + url = self.base_url + "/events/{event_id}".format(event_id=event_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PlusPlusResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_event" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PlusPlusResponse(success=False, error=str(e), message="Failed to execute get_event") + + # ----------------------------------------------------------------------- + # Users + # ----------------------------------------------------------------------- + + async def list_users( + self, + *, + page: int | None = None, + per_page: int | None = None + ) -> PlusPlusResponse: + """List all users + + HTTP GET /users + + Args: + page: Page number for pagination + per_page: Number of items per page + + Returns: + PlusPlusResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/users" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PlusPlusResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_users" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PlusPlusResponse(success=False, error=str(e), message="Failed to execute list_users") + + async def get_user( + self, + user_id: str + ) -> PlusPlusResponse: + """Get a specific user by ID + + HTTP GET /users/{user_id} + + Args: + user_id: The user ID + + Returns: + PlusPlusResponse with operation result + """ + url = self.base_url + "/users/{user_id}".format(user_id=user_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PlusPlusResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_user" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PlusPlusResponse(success=False, error=str(e), message="Failed to execute get_user") + + # ----------------------------------------------------------------------- + # Tracks + # ----------------------------------------------------------------------- + + async def list_tracks( + self, + *, + page: int | None = None, + per_page: int | None = None + ) -> PlusPlusResponse: + """List all tracks + + HTTP GET /tracks + + Args: + page: Page number for pagination + per_page: Number of items per page + + Returns: + PlusPlusResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/tracks" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PlusPlusResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_tracks" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PlusPlusResponse(success=False, error=str(e), message="Failed to execute list_tracks") + + async def get_track( + self, + track_id: str + ) -> PlusPlusResponse: + """Get a specific track by ID + + HTTP GET /tracks/{track_id} + + Args: + track_id: The track ID + + Returns: + PlusPlusResponse with operation result + """ + url = self.base_url + "/tracks/{track_id}".format(track_id=track_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PlusPlusResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_track" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PlusPlusResponse(success=False, error=str(e), message="Failed to execute get_track") + + # ----------------------------------------------------------------------- + # Channels + # ----------------------------------------------------------------------- + + async def list_channels( + self, + *, + page: int | None = None, + per_page: int | None = None + ) -> PlusPlusResponse: + """List all channels + + HTTP GET /channels + + Args: + page: Page number for pagination + per_page: Number of items per page + + Returns: + PlusPlusResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/channels" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PlusPlusResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_channels" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PlusPlusResponse(success=False, error=str(e), message="Failed to execute list_channels") + + async def get_channel( + self, + channel_id: str + ) -> PlusPlusResponse: + """Get a specific channel by ID + + HTTP GET /channels/{channel_id} + + Args: + channel_id: The channel ID + + Returns: + PlusPlusResponse with operation result + """ + url = self.base_url + "/channels/{channel_id}".format(channel_id=channel_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PlusPlusResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_channel" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PlusPlusResponse(success=False, error=str(e), message="Failed to execute get_channel") + + # ----------------------------------------------------------------------- + # Content + # ----------------------------------------------------------------------- + + async def list_content( + self, + *, + page: int | None = None, + per_page: int | None = None + ) -> PlusPlusResponse: + """List all content + + HTTP GET /content + + Args: + page: Page number for pagination + per_page: Number of items per page + + Returns: + PlusPlusResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/content" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PlusPlusResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_content" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PlusPlusResponse(success=False, error=str(e), message="Failed to execute list_content") + + async def get_content( + self, + content_id: str + ) -> PlusPlusResponse: + """Get a specific content item by ID + + HTTP GET /content/{content_id} + + Args: + content_id: The content ID + + Returns: + PlusPlusResponse with operation result + """ + url = self.base_url + "/content/{content_id}".format(content_id=content_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PlusPlusResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_content" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PlusPlusResponse(success=False, error=str(e), message="Failed to execute get_content") + + # ----------------------------------------------------------------------- + # Tags + # ----------------------------------------------------------------------- + + async def list_tags( + self, + *, + page: int | None = None, + per_page: int | None = None + ) -> PlusPlusResponse: + """List all tags + + HTTP GET /tags + + Args: + page: Page number for pagination + per_page: Number of items per page + + Returns: + PlusPlusResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/tags" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PlusPlusResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_tags" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PlusPlusResponse(success=False, error=str(e), message="Failed to execute list_tags") + + # ----------------------------------------------------------------------- + # Enrollments + # ----------------------------------------------------------------------- + + async def list_enrollments( + self, + *, + page: int | None = None, + per_page: int | None = None + ) -> PlusPlusResponse: + """List all enrollments + + HTTP GET /enrollments + + Args: + page: Page number for pagination + per_page: Number of items per page + + Returns: + PlusPlusResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/enrollments" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return PlusPlusResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_enrollments" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return PlusPlusResponse(success=False, error=str(e), message="Failed to execute list_enrollments") diff --git a/backend/python/app/sources/external/plusplus/run_generator.py b/backend/python/app/sources/external/plusplus/run_generator.py new file mode 100644 index 000000000..b61a5bc13 --- /dev/null +++ b/backend/python/app/sources/external/plusplus/run_generator.py @@ -0,0 +1,24 @@ +"""Runner script to generate the PlusPlus DataSource wrapper. + +Execute this script to regenerate plusplus.py from the endpoint definitions +in code_generator.py. + +Usage: + python -m app.sources.external.plusplus.run_generator +""" + +from app.sources.external.plusplus.code_generator import generate_datasource + + +def main() -> None: + """Generate the PlusPlus DataSource file.""" + code = generate_datasource() + output_path = __file__.replace("run_generator.py", "plusplus.py") + with open(output_path, "w") as f: + f.write(code) + print(f"Generated PlusPlus DataSource -> {output_path}") + print(f" Total characters: {len(code)}") + + +if __name__ == "__main__": + main() diff --git a/backend/python/app/sources/external/procore/example.py b/backend/python/app/sources/external/procore/example.py new file mode 100644 index 000000000..3aa8cc71c --- /dev/null +++ b/backend/python/app/sources/external/procore/example.py @@ -0,0 +1,260 @@ +# ruff: noqa + +""" +Procore API Usage Examples + +This example demonstrates how to use the Procore DataSource to interact with +the Procore API (v1.0), covering: +- Authentication (OAuth2) +- Initializing the Client and DataSource +- Fetching current user and companies +- Listing projects +- Browsing RFIs, submittals, documents, drawings +- Checking daily logs, incidents +- Viewing users, tasks, budgets, change orders + +Prerequisites: +For OAuth2 (required): +1. Create a Procore OAuth app at the Developer Portal +2. Set PROCORE_CLIENT_ID and PROCORE_CLIENT_SECRET environment variables +3. The OAuth flow will automatically open a browser for authorization + +OAuth Endpoints: +- Authorization: https://login.procore.com/oauth/authorize +- Token: https://login.procore.com/oauth/token +""" + +import asyncio +import json +import os + +from app.sources.client.procore.procore import ( + ProcoreClient, + ProcoreOAuthConfig, + ProcoreResponse, + ProcoreTokenConfig, +) +from app.sources.external.procore.procore import ProcoreDataSource +from app.sources.external.utils.oauth import perform_oauth_flow + +# --- Configuration --- +# OAuth2 credentials (primary) +CLIENT_ID = os.getenv("PROCORE_CLIENT_ID") +CLIENT_SECRET = os.getenv("PROCORE_CLIENT_SECRET") + +# Bearer token (fallback, for pre-obtained tokens) +BEARER_TOKEN = os.getenv("PROCORE_BEARER_TOKEN") + +# OAuth redirect URI +REDIRECT_URI = os.getenv("PROCORE_REDIRECT_URI", "http://localhost:8080/callback") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: ProcoreResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + # Handle list responses (Procore often returns arrays) + if isinstance(data, list): + print(f" Found {len(data)} items.") + if data: + print(f" Sample: {json.dumps(data[0], indent=2)[:400]}...") + return + # Handle dict responses with common list keys + for key in ("companies", "projects", "rfis", "submittals", "documents", + "drawings", "daily_logs", "incidents", "users", "tasks", + "budgets", "change_orders"): + if isinstance(data, dict) and key in data: + items = data[key] + print(f" Found {len(items)} {key}.") + if items: + print(f" Sample: {json.dumps(items[0], indent=2)[:400]}...") + return + # Generic response + print(f" Data: {json.dumps(data, indent=2)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + # 1. Initialize Client + print_section("Initializing Procore Client") + + config = None + + # Priority 1: OAuth2 + if CLIENT_ID and CLIENT_SECRET: + print(" Using OAuth2 authentication") + try: + print("Starting OAuth flow...") + token_response = perform_oauth_flow( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + auth_endpoint="https://login.procore.com/oauth/authorize", + token_endpoint="https://login.procore.com/oauth/token", + redirect_uri=REDIRECT_URI, + scopes=[], + scope_delimiter=" ", + auth_method="body", + ) + + access_token = token_response.get("access_token") + if not access_token: + raise Exception("No access_token found in OAuth response") + + config = ProcoreOAuthConfig( + access_token=access_token, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + ) + print(" OAuth authentication successful") + except Exception as e: + print(f" OAuth flow failed: {e}") + print(" Falling back to other authentication methods...") + + # Priority 2: Bearer Token + if config is None and BEARER_TOKEN: + print(" Using Bearer Token authentication") + config = ProcoreTokenConfig(token=BEARER_TOKEN) + + if config is None: + print(" No valid authentication method found.") + print(" Please set one of the following:") + print(" - PROCORE_CLIENT_ID and PROCORE_CLIENT_SECRET (for OAuth2)") + print(" - PROCORE_BEARER_TOKEN (for pre-obtained Bearer token)") + return + + client = ProcoreClient.build_with_config(config) + data_source = ProcoreDataSource(client) + print("Client initialized successfully.") + + try: + # 2. Get Current User + print_section("Current User") + me_resp = await data_source.get_me() + print_result("Get Me", me_resp) + + # 3. List Companies + print_section("Companies") + companies_resp = await data_source.list_companies() + print_result("List Companies", companies_resp) + + company_id = None + if companies_resp.success and companies_resp.data: + data = companies_resp.data + companies = data if isinstance(data, list) else [] + if companies: + company_id = str(companies[0].get("id")) if isinstance(companies[0], dict) else None + if company_id: + print(f" Using Company ID: {company_id}") + + if not company_id: + print(" No company found. Skipping further operations.") + return + + # 4. Get Specific Company + print_section(f"Company Details: {company_id}") + company_resp = await data_source.get_company(company_id=company_id) + print_result("Get Company", company_resp) + + # 5. List Projects + print_section("Projects") + projects_resp = await data_source.list_projects(company_id=company_id, page=1, per_page=10) + print_result("List Projects", projects_resp) + + project_id = None + if projects_resp.success and projects_resp.data: + data = projects_resp.data + projects = data if isinstance(data, list) else [] + if projects: + project_id = str(projects[0].get("id")) if isinstance(projects[0], dict) else None + if project_id: + print(f" Using Project ID: {project_id}") + + if not project_id: + print(" No project found. Skipping project-level operations.") + return + + # 6. Get Specific Project + print_section(f"Project Details: {project_id}") + project_resp = await data_source.get_project(project_id=project_id) + print_result("Get Project", project_resp) + + # 7. List RFIs + print_section("RFIs") + rfis_resp = await data_source.list_rfis(project_id=project_id, page=1, per_page=10) + print_result("List RFIs", rfis_resp) + + # 8. List Submittals + print_section("Submittals") + submittals_resp = await data_source.list_submittals(project_id=project_id, page=1, per_page=10) + print_result("List Submittals", submittals_resp) + + # 9. List Documents + print_section("Documents") + documents_resp = await data_source.list_documents(project_id=project_id, page=1, per_page=10) + print_result("List Documents", documents_resp) + + # 10. List Drawings + print_section("Drawings") + drawings_resp = await data_source.list_drawings(project_id=project_id, page=1, per_page=10) + print_result("List Drawings", drawings_resp) + + # 11. List Daily Logs + print_section("Daily Logs") + daily_logs_resp = await data_source.list_daily_logs(project_id=project_id, page=1, per_page=10) + print_result("List Daily Logs", daily_logs_resp) + + # 12. List Incidents + print_section("Incidents") + incidents_resp = await data_source.list_incidents(project_id=project_id, page=1, per_page=10) + print_result("List Incidents", incidents_resp) + + # 13. List Company Users + print_section("Company Users") + company_users_resp = await data_source.list_company_users(company_id=company_id, page=1, per_page=10) + print_result("List Company Users", company_users_resp) + + # 14. List Project Users + print_section("Project Users") + project_users_resp = await data_source.list_project_users(project_id=project_id, page=1, per_page=10) + print_result("List Project Users", project_users_resp) + + # 15. List Tasks + print_section("Tasks") + tasks_resp = await data_source.list_tasks(project_id=project_id, page=1, per_page=10) + print_result("List Tasks", tasks_resp) + + # 16. List Budgets + print_section("Budgets") + budgets_resp = await data_source.list_budgets(project_id=project_id) + print_result("List Budgets", budgets_resp) + + # 17. List Change Orders + print_section("Change Orders") + change_orders_resp = await data_source.list_change_orders(project_id=project_id, page=1, per_page=10) + print_result("List Change Orders", change_orders_resp) + + finally: + # Cleanup: Close the HTTP client session + print("\nClosing client connection...") + inner_client = client.get_client() + if hasattr(inner_client, "close"): + await inner_client.close() + + print("\n" + "=" * 80) + print(" All Procore API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/procore/procore.py b/backend/python/app/sources/external/procore/procore.py new file mode 100644 index 000000000..1eb6f1e8d --- /dev/null +++ b/backend/python/app/sources/external/procore/procore.py @@ -0,0 +1,723 @@ +""" +Procore REST API DataSource - Auto-generated API wrapper + +Generated from Procore REST API v1.0 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.http.http_request import HTTPRequest +from app.sources.client.procore.procore import ProcoreClient, ProcoreResponse + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class ProcoreDataSource: + """Procore REST API DataSource + + Provides async wrapper methods for Procore REST API operations: + - Current user + - Companies + - Projects + - RFIs, Submittals + - Documents, Drawings + - Daily logs, Incidents + - Users (company and project level) + - Tasks, Budgets, Change orders + + The base URL is determined by the ProcoreClient's configuration. + + All methods return ProcoreResponse objects. + """ + + def __init__(self, client: ProcoreClient) -> None: + """Initialize with ProcoreClient. + + Args: + client: ProcoreClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'ProcoreDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> ProcoreClient: + """Return the underlying ProcoreClient.""" + return self._client + + async def get_me( + self + ) -> ProcoreResponse: + """Get the current authenticated user + + Returns: + ProcoreResponse with operation result + """ + url = self.base_url + "/me" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return ProcoreResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_me" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return ProcoreResponse(success=False, error=str(e), message="Failed to execute get_me") + + async def list_companies( + self + ) -> ProcoreResponse: + """List all companies accessible to the current user + + Returns: + ProcoreResponse with operation result + """ + url = self.base_url + "/companies" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return ProcoreResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_companies" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return ProcoreResponse(success=False, error=str(e), message="Failed to execute list_companies") + + async def get_company( + self, + company_id: str + ) -> ProcoreResponse: + """Get a specific company by ID + + Args: + company_id: The company ID + + Returns: + ProcoreResponse with operation result + """ + url = self.base_url + "/companies/{company_id}".format(company_id=company_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return ProcoreResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_company" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return ProcoreResponse(success=False, error=str(e), message="Failed to execute get_company") + + async def list_projects( + self, + company_id: str, + page: int | None = None, + per_page: int | None = None, + filters_status_id: str | None = None + ) -> ProcoreResponse: + """List projects for a company + + Args: + company_id: The company ID (required) + page: Page number for pagination + per_page: Number of results per page + filters_status_id: Filter by project status ID + + Returns: + ProcoreResponse with operation result + """ + query_params: dict[str, Any] = {} + query_params['company_id'] = company_id + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + if filters_status_id is not None: + query_params['filters_status_id'] = filters_status_id + + url = self.base_url + "/projects" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return ProcoreResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_projects" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return ProcoreResponse(success=False, error=str(e), message="Failed to execute list_projects") + + async def get_project( + self, + project_id: str + ) -> ProcoreResponse: + """Get a specific project by ID + + Args: + project_id: The project ID + + Returns: + ProcoreResponse with operation result + """ + url = self.base_url + "/projects/{project_id}".format(project_id=project_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return ProcoreResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_project" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return ProcoreResponse(success=False, error=str(e), message="Failed to execute get_project") + + async def list_rfis( + self, + project_id: str, + page: int | None = None, + per_page: int | None = None + ) -> ProcoreResponse: + """List RFIs for a project + + Args: + project_id: The project ID + page: Page number for pagination + per_page: Number of results per page + + Returns: + ProcoreResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/projects/{project_id}/rfis".format(project_id=project_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return ProcoreResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_rfis" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return ProcoreResponse(success=False, error=str(e), message="Failed to execute list_rfis") + + async def get_rfi( + self, + project_id: str, + rfi_id: str + ) -> ProcoreResponse: + """Get a specific RFI by ID + + Args: + project_id: The project ID + rfi_id: The RFI ID + + Returns: + ProcoreResponse with operation result + """ + url = self.base_url + "/projects/{project_id}/rfis/{rfi_id}".format(project_id=project_id, rfi_id=rfi_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return ProcoreResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_rfi" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return ProcoreResponse(success=False, error=str(e), message="Failed to execute get_rfi") + + async def list_submittals( + self, + project_id: str, + page: int | None = None, + per_page: int | None = None + ) -> ProcoreResponse: + """List submittals for a project + + Args: + project_id: The project ID + page: Page number for pagination + per_page: Number of results per page + + Returns: + ProcoreResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/projects/{project_id}/submittals".format(project_id=project_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return ProcoreResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_submittals" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return ProcoreResponse(success=False, error=str(e), message="Failed to execute list_submittals") + + async def get_submittal( + self, + project_id: str, + submittal_id: str + ) -> ProcoreResponse: + """Get a specific submittal by ID + + Args: + project_id: The project ID + submittal_id: The submittal ID + + Returns: + ProcoreResponse with operation result + """ + url = self.base_url + "/projects/{project_id}/submittals/{submittal_id}".format(project_id=project_id, submittal_id=submittal_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return ProcoreResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_submittal" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return ProcoreResponse(success=False, error=str(e), message="Failed to execute get_submittal") + + async def list_documents( + self, + project_id: str, + page: int | None = None, + per_page: int | None = None + ) -> ProcoreResponse: + """List documents for a project + + Args: + project_id: The project ID + page: Page number for pagination + per_page: Number of results per page + + Returns: + ProcoreResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/projects/{project_id}/documents".format(project_id=project_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return ProcoreResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_documents" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return ProcoreResponse(success=False, error=str(e), message="Failed to execute list_documents") + + async def list_drawings( + self, + project_id: str, + page: int | None = None, + per_page: int | None = None + ) -> ProcoreResponse: + """List drawings for a project + + Args: + project_id: The project ID + page: Page number for pagination + per_page: Number of results per page + + Returns: + ProcoreResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/projects/{project_id}/drawings".format(project_id=project_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return ProcoreResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_drawings" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return ProcoreResponse(success=False, error=str(e), message="Failed to execute list_drawings") + + async def list_daily_logs( + self, + project_id: str, + page: int | None = None, + per_page: int | None = None, + log_date: str | None = None + ) -> ProcoreResponse: + """List daily logs for a project + + Args: + project_id: The project ID + page: Page number for pagination + per_page: Number of results per page + log_date: Filter by log date (YYYY-MM-DD) + + Returns: + ProcoreResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + if log_date is not None: + query_params['log_date'] = log_date + + url = self.base_url + "/projects/{project_id}/daily_logs".format(project_id=project_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return ProcoreResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_daily_logs" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return ProcoreResponse(success=False, error=str(e), message="Failed to execute list_daily_logs") + + async def list_incidents( + self, + project_id: str, + page: int | None = None, + per_page: int | None = None + ) -> ProcoreResponse: + """List incidents for a project + + Args: + project_id: The project ID + page: Page number for pagination + per_page: Number of results per page + + Returns: + ProcoreResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/projects/{project_id}/incidents".format(project_id=project_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return ProcoreResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_incidents" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return ProcoreResponse(success=False, error=str(e), message="Failed to execute list_incidents") + + async def list_company_users( + self, + company_id: str, + page: int | None = None, + per_page: int | None = None + ) -> ProcoreResponse: + """List users for a company + + Args: + company_id: The company ID + page: Page number for pagination + per_page: Number of results per page + + Returns: + ProcoreResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/companies/{company_id}/users".format(company_id=company_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return ProcoreResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_company_users" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return ProcoreResponse(success=False, error=str(e), message="Failed to execute list_company_users") + + async def list_project_users( + self, + project_id: str, + page: int | None = None, + per_page: int | None = None + ) -> ProcoreResponse: + """List users for a project + + Args: + project_id: The project ID + page: Page number for pagination + per_page: Number of results per page + + Returns: + ProcoreResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/projects/{project_id}/users".format(project_id=project_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return ProcoreResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_project_users" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return ProcoreResponse(success=False, error=str(e), message="Failed to execute list_project_users") + + async def list_tasks( + self, + project_id: str, + page: int | None = None, + per_page: int | None = None + ) -> ProcoreResponse: + """List tasks for a project + + Args: + project_id: The project ID + page: Page number for pagination + per_page: Number of results per page + + Returns: + ProcoreResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/projects/{project_id}/tasks".format(project_id=project_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return ProcoreResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_tasks" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return ProcoreResponse(success=False, error=str(e), message="Failed to execute list_tasks") + + async def list_budgets( + self, + project_id: str + ) -> ProcoreResponse: + """List budgets for a project + + Args: + project_id: The project ID + + Returns: + ProcoreResponse with operation result + """ + url = self.base_url + "/projects/{project_id}/budgets".format(project_id=project_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return ProcoreResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_budgets" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return ProcoreResponse(success=False, error=str(e), message="Failed to execute list_budgets") + + async def list_change_orders( + self, + project_id: str, + page: int | None = None, + per_page: int | None = None + ) -> ProcoreResponse: + """List change orders for a project + + Args: + project_id: The project ID + page: Page number for pagination + per_page: Number of results per page + + Returns: + ProcoreResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/projects/{project_id}/change_orders".format(project_id=project_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return ProcoreResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_change_orders" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return ProcoreResponse(success=False, error=str(e), message="Failed to execute list_change_orders") diff --git a/backend/python/app/sources/external/quickbooks/example.py b/backend/python/app/sources/external/quickbooks/example.py new file mode 100644 index 000000000..e1d9077af --- /dev/null +++ b/backend/python/app/sources/external/quickbooks/example.py @@ -0,0 +1,167 @@ +# ruff: noqa + +""" +QuickBooks Online API Usage Examples + +This example demonstrates how to use the QuickBooks DataSource to interact with +the QuickBooks Online API v3, covering: +- Authentication (OAuth2) +- Initializing the Client and DataSource +- SQL-like query endpoint +- Fetching Customers, Invoices, Payments +- Company info + +Prerequisites: +1. Create a QuickBooks app at https://developer.intuit.com +2. Set QUICKBOOKS_CLIENT_ID and QUICKBOOKS_CLIENT_SECRET +3. Complete OAuth flow to get access_token +4. Set QUICKBOOKS_ACCESS_TOKEN and QUICKBOOKS_COMPANY_ID + +OAuth Endpoints: +- Auth: https://appcenter.intuit.com/connect/oauth2 +- Token: https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer +- Auth Method: "body" +""" + +import asyncio +import json +import os + +from app.sources.client.quickbooks.quickbooks import ( + QuickBooksClient, + QuickBooksOAuthConfig, + QuickBooksResponse, +) +from app.sources.external.quickbooks.quickbooks import QuickBooksDataSource +from app.sources.external.utils.oauth import perform_oauth_flow + +# --- Configuration --- +CLIENT_ID = os.getenv("QUICKBOOKS_CLIENT_ID", "") +CLIENT_SECRET = os.getenv("QUICKBOOKS_CLIENT_SECRET", "") +ACCESS_TOKEN = os.getenv("QUICKBOOKS_ACCESS_TOKEN", "") +COMPANY_ID = os.getenv("QUICKBOOKS_COMPANY_ID", "") +REDIRECT_URI = os.getenv("QUICKBOOKS_REDIRECT_URI", "http://localhost:8080/callback") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: QuickBooksResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + if isinstance(data, list): + print(f" Found {len(data)} items.") + if data: + print(f" Sample: {json.dumps(data[0], indent=2)[:400]}...") + elif isinstance(data, dict): + print(f" Data: {json.dumps(data, indent=2)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + # 1. Initialize Client + print_section("Initializing QuickBooks Client") + + access_token = ACCESS_TOKEN + + # Try OAuth flow if no access token + if not access_token and CLIENT_ID and CLIENT_SECRET: + print(" Starting OAuth flow...") + try: + token_response = perform_oauth_flow( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + auth_endpoint="https://appcenter.intuit.com/connect/oauth2", + token_endpoint="https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer", + redirect_uri=REDIRECT_URI, + scopes=["com.intuit.quickbooks.accounting"], + scope_delimiter=" ", + auth_method="body", + ) + access_token = token_response.get("access_token", "") + print(" OAuth authentication successful") + except Exception as e: + print(f" OAuth flow failed: {e}") + + if not access_token or not COMPANY_ID: + print(" No valid authentication found.") + print(" Please set:") + print(" - QUICKBOOKS_ACCESS_TOKEN and QUICKBOOKS_COMPANY_ID") + print(" Or for OAuth flow:") + print(" - QUICKBOOKS_CLIENT_ID, QUICKBOOKS_CLIENT_SECRET, QUICKBOOKS_COMPANY_ID") + return + + config = QuickBooksOAuthConfig( + access_token=access_token, + company_id=COMPANY_ID, + client_id=CLIENT_ID or None, + client_secret=CLIENT_SECRET or None, + ) + client = QuickBooksClient.build_with_config(config) + data_source = QuickBooksDataSource(client) + print(f"Client initialized for company: {COMPANY_ID}") + + try: + # 2. Get Company Info + print_section("Company Info") + company_resp = await data_source.get_company_info(COMPANY_ID) + print_result("Get Company Info", company_resp) + + # 3. Query Customers + print_section("Query Customers") + customers_resp = await data_source.query("SELECT * FROM Customer MAXRESULTS 5") + print_result("Query Customers", customers_resp) + + # 4. Get a Specific Customer (ID 1) + print_section("Customer Details") + customer_resp = await data_source.get_customer("1") + print_result("Get Customer", customer_resp) + + # 5. Query Invoices + print_section("Query Invoices") + invoices_resp = await data_source.query("SELECT * FROM Invoice MAXRESULTS 5") + print_result("Query Invoices", invoices_resp) + + # 6. Query Items + print_section("Query Items") + items_resp = await data_source.query("SELECT * FROM Item MAXRESULTS 5") + print_result("Query Items", items_resp) + + # 7. Query Accounts + print_section("Query Accounts") + accounts_resp = await data_source.query("SELECT * FROM Account MAXRESULTS 5") + print_result("Query Accounts", accounts_resp) + + # 8. Query Vendors + print_section("Query Vendors") + vendors_resp = await data_source.query("SELECT * FROM Vendor MAXRESULTS 5") + print_result("Query Vendors", vendors_resp) + + # 9. Query Employees + print_section("Query Employees") + employees_resp = await data_source.query("SELECT * FROM Employee MAXRESULTS 5") + print_result("Query Employees", employees_resp) + + finally: + # Cleanup + print("\nClosing client connection...") + inner_client = client.get_client() + if hasattr(inner_client, "close"): + await inner_client.close() + + print("\n" + "=" * 80) + print(" All QuickBooks Online API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/quickbooks/quickbooks.py b/backend/python/app/sources/external/quickbooks/quickbooks.py new file mode 100644 index 000000000..baf999123 --- /dev/null +++ b/backend/python/app/sources/external/quickbooks/quickbooks.py @@ -0,0 +1,671 @@ +""" +QuickBooks Online REST API DataSource - Auto-generated API wrapper + +Generated from QuickBooks Online REST API v3 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.http.http_request import HTTPRequest +from app.sources.client.quickbooks.quickbooks import ( + QuickBooksClient, + QuickBooksResponse, +) + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class QuickBooksDataSource: + """QuickBooks Online REST API DataSource + + Provides async wrapper methods for QuickBooks Online REST API v3 operations: + - SQL-like query endpoint + - Customer CRUD + - Invoice CRUD + - Payment CRUD + - Vendor CRUD + - Item CRUD + - Account CRUD + - Bill CRUD + - Estimate CRUD + - Employee CRUD + - Company info + + The base URL includes the company_id as configured in the client. + All methods return QuickBooksResponse objects. + """ + + def __init__(self, client: QuickBooksClient) -> None: + """Initialize with QuickBooksClient. + + Args: + client: QuickBooksClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip("/") + except AttributeError as exc: + raise ValueError( + "HTTP client does not have get_base_url method" + ) from exc + + def get_data_source(self) -> "QuickBooksDataSource": + """Return the data source instance.""" + return self + + def get_client(self) -> QuickBooksClient: + """Return the underlying QuickBooksClient.""" + return self._client + + # ----------------------------------------------------------------------- + # Query (SQL-like) + # ----------------------------------------------------------------------- + + async def query(self, query_string: str) -> QuickBooksResponse: + """Execute a SQL-like query against QuickBooks data. + + Example queries: + "SELECT * FROM Customer" + "SELECT * FROM Invoice WHERE TotalAmt > '100.00'" + "SELECT * FROM Item STARTPOSITION 1 MAXRESULTS 10" + + Args: + query_string: SQL-like query string + + Returns: + QuickBooksResponse with operation result + """ + url = self.base_url + "/query" + query_params: dict[str, Any] = {"query": query_string} + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + }, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return QuickBooksResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed query" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return QuickBooksResponse( + success=False, + error=str(e), + message="Failed to execute query", + ) + + # ----------------------------------------------------------------------- + # Customer + # ----------------------------------------------------------------------- + + async def get_customer(self, customer_id: str) -> QuickBooksResponse: + """Get a customer by ID. + + Args: + customer_id: The customer ID + + Returns: + QuickBooksResponse with operation result + """ + url = self.base_url + "/customer/{customer_id}".format( + customer_id=customer_id + ) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + }, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return QuickBooksResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_customer" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return QuickBooksResponse( + success=False, + error=str(e), + message="Failed to execute get_customer", + ) + + async def create_customer( + self, + display_name: str, + *, + given_name: str | None = None, + family_name: str | None = None, + company_name: str | None = None, + primary_email: str | None = None, + primary_phone: str | None = None, + ) -> QuickBooksResponse: + """Create a new customer. + + Args: + display_name: Customer display name (required) + given_name: Customer first name + family_name: Customer last name + company_name: Company name + primary_email: Primary email address + primary_phone: Primary phone number + + Returns: + QuickBooksResponse with operation result + """ + url = self.base_url + "/customer" + + body: dict[str, Any] = {"DisplayName": display_name} + if given_name is not None: + body["GivenName"] = given_name + if family_name is not None: + body["FamilyName"] = family_name + if company_name is not None: + body["CompanyName"] = company_name + if primary_email is not None: + body["PrimaryEmailAddr"] = {"Address": primary_email} + if primary_phone is not None: + body["PrimaryPhone"] = {"FreeFormNumber": primary_phone} + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + }, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return QuickBooksResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed create_customer" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return QuickBooksResponse( + success=False, + error=str(e), + message="Failed to execute create_customer", + ) + + # ----------------------------------------------------------------------- + # Invoice + # ----------------------------------------------------------------------- + + async def get_invoice(self, invoice_id: str) -> QuickBooksResponse: + """Get an invoice by ID. + + Args: + invoice_id: The invoice ID + + Returns: + QuickBooksResponse with operation result + """ + url = self.base_url + "/invoice/{invoice_id}".format( + invoice_id=invoice_id + ) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + }, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return QuickBooksResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_invoice" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return QuickBooksResponse( + success=False, + error=str(e), + message="Failed to execute get_invoice", + ) + + async def create_invoice( + self, + customer_ref_value: str, + *, + line_items: list[dict[str, Any]] | None = None, + due_date: str | None = None, + ) -> QuickBooksResponse: + """Create a new invoice. + + Args: + customer_ref_value: Customer reference ID (required) + line_items: List of line item dicts (Amount, DetailType, etc.) + due_date: Due date in YYYY-MM-DD format + + Returns: + QuickBooksResponse with operation result + """ + url = self.base_url + "/invoice" + + body: dict[str, Any] = { + "CustomerRef": {"value": customer_ref_value} + } + if line_items is not None: + body["Line"] = line_items + if due_date is not None: + body["DueDate"] = due_date + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + }, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return QuickBooksResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed create_invoice" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return QuickBooksResponse( + success=False, + error=str(e), + message="Failed to execute create_invoice", + ) + + # ----------------------------------------------------------------------- + # Payment + # ----------------------------------------------------------------------- + + async def get_payment(self, payment_id: str) -> QuickBooksResponse: + """Get a payment by ID. + + Args: + payment_id: The payment ID + + Returns: + QuickBooksResponse with operation result + """ + url = self.base_url + "/payment/{payment_id}".format( + payment_id=payment_id + ) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + }, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return QuickBooksResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_payment" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return QuickBooksResponse( + success=False, + error=str(e), + message="Failed to execute get_payment", + ) + + # ----------------------------------------------------------------------- + # Vendor + # ----------------------------------------------------------------------- + + async def get_vendor(self, vendor_id: str) -> QuickBooksResponse: + """Get a vendor by ID. + + Args: + vendor_id: The vendor ID + + Returns: + QuickBooksResponse with operation result + """ + url = self.base_url + "/vendor/{vendor_id}".format( + vendor_id=vendor_id + ) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + }, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return QuickBooksResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_vendor" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return QuickBooksResponse( + success=False, + error=str(e), + message="Failed to execute get_vendor", + ) + + # ----------------------------------------------------------------------- + # Item + # ----------------------------------------------------------------------- + + async def get_item(self, item_id: str) -> QuickBooksResponse: + """Get an item by ID. + + Args: + item_id: The item ID + + Returns: + QuickBooksResponse with operation result + """ + url = self.base_url + "/item/{item_id}".format(item_id=item_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + }, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return QuickBooksResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_item" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return QuickBooksResponse( + success=False, + error=str(e), + message="Failed to execute get_item", + ) + + # ----------------------------------------------------------------------- + # Account + # ----------------------------------------------------------------------- + + async def get_account(self, account_id: str) -> QuickBooksResponse: + """Get an account by ID. + + Args: + account_id: The account ID + + Returns: + QuickBooksResponse with operation result + """ + url = self.base_url + "/account/{account_id}".format( + account_id=account_id + ) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + }, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return QuickBooksResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_account" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return QuickBooksResponse( + success=False, + error=str(e), + message="Failed to execute get_account", + ) + + # ----------------------------------------------------------------------- + # Bill + # ----------------------------------------------------------------------- + + async def get_bill(self, bill_id: str) -> QuickBooksResponse: + """Get a bill by ID. + + Args: + bill_id: The bill ID + + Returns: + QuickBooksResponse with operation result + """ + url = self.base_url + "/bill/{bill_id}".format(bill_id=bill_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + }, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return QuickBooksResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_bill" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return QuickBooksResponse( + success=False, + error=str(e), + message="Failed to execute get_bill", + ) + + # ----------------------------------------------------------------------- + # Estimate + # ----------------------------------------------------------------------- + + async def get_estimate(self, estimate_id: str) -> QuickBooksResponse: + """Get an estimate by ID. + + Args: + estimate_id: The estimate ID + + Returns: + QuickBooksResponse with operation result + """ + url = self.base_url + "/estimate/{estimate_id}".format( + estimate_id=estimate_id + ) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + }, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return QuickBooksResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_estimate" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return QuickBooksResponse( + success=False, + error=str(e), + message="Failed to execute get_estimate", + ) + + # ----------------------------------------------------------------------- + # Employee + # ----------------------------------------------------------------------- + + async def get_employee(self, employee_id: str) -> QuickBooksResponse: + """Get an employee by ID. + + Args: + employee_id: The employee ID + + Returns: + QuickBooksResponse with operation result + """ + url = self.base_url + "/employee/{employee_id}".format( + employee_id=employee_id + ) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + }, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return QuickBooksResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_employee" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return QuickBooksResponse( + success=False, + error=str(e), + message="Failed to execute get_employee", + ) + + # ----------------------------------------------------------------------- + # Company Info + # ----------------------------------------------------------------------- + + async def get_company_info( + self, company_id: str + ) -> QuickBooksResponse: + """Get company information. + + Args: + company_id: The company (realm) ID + + Returns: + QuickBooksResponse with operation result + """ + url = self.base_url + "/companyinfo/{company_id}".format( + company_id=company_id + ) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + }, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return QuickBooksResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_company_info" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return QuickBooksResponse( + success=False, + error=str(e), + message="Failed to execute get_company_info", + ) diff --git a/backend/python/app/sources/external/quip/example.py b/backend/python/app/sources/external/quip/example.py new file mode 100644 index 000000000..2398594a3 --- /dev/null +++ b/backend/python/app/sources/external/quip/example.py @@ -0,0 +1,202 @@ +# ruff: noqa + +""" +Quip API Usage Examples + +This example demonstrates how to use the Quip DataSource to interact with +the Quip Automation API, covering: +- Authentication (OAuth2, Personal Access Token) +- Initializing the Client and DataSource +- Fetching Current User Info and Contacts +- Listing Recent Threads (Documents) +- Getting Thread Details +- Searching Threads +- Working with Folders +- Getting Thread Messages + +Prerequisites: +For OAuth2: +1. Create a Quip API app at https://quip.com/dev/automation +2. Set QUIP_CLIENT_ID and QUIP_CLIENT_SECRET environment variables + +For Personal Token: +1. Generate a token at https://quip.com/dev/token +2. Set QUIP_PERSONAL_TOKEN environment variable + +API Reference: https://quip.com/dev/automation/documentation +""" + +import asyncio +import json +import os + +from app.sources.client.quip.quip import ( + QuipClient, + QuipOAuthConfig, + QuipResponse, + QuipTokenConfig, +) +from app.sources.external.quip.quip import QuipDataSource +from app.sources.external.utils.oauth import perform_oauth_flow + +# --- Configuration --- +# OAuth2 credentials (highest priority) +CLIENT_ID = os.getenv("QUIP_CLIENT_ID") +CLIENT_SECRET = os.getenv("QUIP_CLIENT_SECRET") + +# Personal Token (second priority) +PERSONAL_TOKEN = os.getenv("QUIP_PERSONAL_TOKEN") + +# OAuth redirect URI +REDIRECT_URI = os.getenv("QUIP_REDIRECT_URI", "http://localhost:8080/callback") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: QuipResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + print(f" Data: {json.dumps(data, indent=2, default=str)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + # 1. Initialize Client + print_section("Initializing Quip Client") + + config = None + + # Priority 1: OAuth2 + if CLIENT_ID and CLIENT_SECRET: + print(" Using OAuth2 authentication") + try: + print("Starting OAuth flow...") + token_response = perform_oauth_flow( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + auth_endpoint="https://platform.quip.com/1/oauth/login", + token_endpoint="https://platform.quip.com/1/oauth/access_token", + redirect_uri=REDIRECT_URI, + scopes=[], + scope_delimiter=" ", + auth_method="body", + ) + + access_token = token_response.get("access_token") + if not access_token: + raise Exception("No access_token found in OAuth response") + + config = QuipOAuthConfig( + access_token=access_token, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + ) + print(" OAuth authentication successful") + except Exception as e: + print(f" OAuth flow failed: {e}") + print(" Falling back to other authentication methods...") + + # Priority 2: Personal Token + if config is None and PERSONAL_TOKEN: + print(" Using Personal Token authentication") + config = QuipTokenConfig(token=PERSONAL_TOKEN) + + if config is None: + print(" No valid authentication method found.") + print(" Please set one of the following:") + print(" - QUIP_CLIENT_ID and QUIP_CLIENT_SECRET (for OAuth2)") + print(" - QUIP_PERSONAL_TOKEN (for Personal Access Token)") + return + + client = QuipClient.build_with_config(config) + data_source = QuipDataSource(client) + print("Client initialized successfully.") + + try: + # 2. Get Current User + print_section("Current User") + user_resp = await data_source.get_current_user() + print_result("Get Current User", user_resp) + + # 3. Get Contacts + print_section("Contacts") + contacts_resp = await data_source.get_contacts() + print_result("Get Contacts", contacts_resp) + + # 4. Get Recent Threads + print_section("Recent Threads") + recent_resp = await data_source.get_recent_threads(count=5) + print_result("Get Recent Threads", recent_resp) + + # Extract first thread ID for further exploration + thread_id = None + if recent_resp.success and recent_resp.data: + # Quip returns threads as a list of objects + if isinstance(recent_resp.data, list) and len(recent_resp.data) > 0: + first_thread = recent_resp.data[0] + if isinstance(first_thread, dict): + thread = first_thread.get("thread", {}) + thread_id = thread.get("id") if isinstance(thread, dict) else None + elif isinstance(recent_resp.data, dict): + # Try to get first thread from the dict response + for key, value in recent_resp.data.items(): + if isinstance(value, dict) and "thread" in value: + thread_id = value["thread"].get("id") + break + + if thread_id: + # 5. Get Thread Details + print_section(f"Thread Details: {thread_id}") + thread_resp = await data_source.get_thread(thread_id=thread_id) + print_result("Get Thread", thread_resp) + + # 6. Get Thread Messages + print_section("Thread Messages") + messages_resp = await data_source.get_thread_messages( + thread_id=thread_id, count=5 + ) + print_result("Get Thread Messages", messages_resp) + else: + print("\n No threads found. Skipping thread detail operations.") + + # 7. Search Threads + print_section("Search Threads") + search_resp = await data_source.search_threads(query="meeting notes") + print_result("Search 'meeting notes'", search_resp) + + # 8. Get a Folder (using current user's private folder if available) + if user_resp.success and user_resp.data: + user_data = user_resp.data + if isinstance(user_data, dict): + private_folder = user_data.get("private_folder_id") + if private_folder: + print_section(f"Private Folder: {private_folder}") + folder_resp = await data_source.get_folder( + folder_id=str(private_folder) + ) + print_result("Get Private Folder", folder_resp) + + finally: + # Cleanup: Close the HTTP client session + print("\nClosing client connection...") + inner_client = client.get_client() + if hasattr(inner_client, "close"): + await inner_client.close() + + print("\n" + "=" * 80) + print(" All Quip API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/quip/quip.py b/backend/python/app/sources/external/quip/quip.py new file mode 100644 index 000000000..4585489f1 --- /dev/null +++ b/backend/python/app/sources/external/quip/quip.py @@ -0,0 +1,905 @@ +""" +Quip REST API DataSource - Auto-generated API wrapper + +Generated from Quip Automation API documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.http.http_request import HTTPRequest +from app.sources.client.quip.quip import QuipClient, QuipResponse + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class QuipDataSource: + """Quip REST API DataSource + + Provides async wrapper methods for Quip Automation API operations: + - Users (current user, get user, contacts) + - Threads/Documents (get, create, edit, search, recent) + - Messages/Comments (get, create) + - Folders (get, create, update, members) + + The base URL is https://platform.quip.com/1. + + All methods return QuipResponse objects. + """ + + def __init__(self, client: QuipClient) -> None: + """Initialize with QuipClient. + + Args: + client: QuipClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'QuipDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> QuipClient: + """Return the underlying QuipClient.""" + return self._client + + async def get_current_user( + self + ) -> QuipResponse: + """Get the authenticated user's information + + Returns: + QuipResponse with operation result + """ + url = self.base_url + "/users/current" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return QuipResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_current_user" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return QuipResponse(success=False, error=str(e), message="Failed to execute get_current_user") + + async def get_user( + self, + user_id: str + ) -> QuipResponse: + """Get a specific user by ID + + Args: + user_id: The user ID + + Returns: + QuipResponse with operation result + """ + url = self.base_url + "/users/{user_id}".format(user_id=user_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return QuipResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_user" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return QuipResponse(success=False, error=str(e), message="Failed to execute get_user") + + async def get_users( + self, + user_ids: str + ) -> QuipResponse: + """Get multiple users by IDs (comma-separated) + + Args: + user_ids: Comma-separated user IDs + + Returns: + QuipResponse with operation result + """ + url = self.base_url + "/users/{user_ids}".format(user_ids=user_ids) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return QuipResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_users" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return QuipResponse(success=False, error=str(e), message="Failed to execute get_users") + + async def get_contacts( + self + ) -> QuipResponse: + """Get the authenticated user's contacts + + Returns: + QuipResponse with operation result + """ + url = self.base_url + "/users/contacts" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return QuipResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_contacts" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return QuipResponse(success=False, error=str(e), message="Failed to execute get_contacts") + + async def get_thread( + self, + thread_id: str + ) -> QuipResponse: + """Get a specific thread (document) by ID + + Args: + thread_id: The thread ID + + Returns: + QuipResponse with operation result + """ + url = self.base_url + "/threads/{thread_id}".format(thread_id=thread_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return QuipResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_thread" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return QuipResponse(success=False, error=str(e), message="Failed to execute get_thread") + + async def get_threads( + self, + thread_ids: str + ) -> QuipResponse: + """Get multiple threads by IDs (comma-separated) + + Args: + thread_ids: Comma-separated thread IDs + + Returns: + QuipResponse with operation result + """ + url = self.base_url + "/threads/{thread_ids}".format(thread_ids=thread_ids) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return QuipResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_threads" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return QuipResponse(success=False, error=str(e), message="Failed to execute get_threads") + + async def get_recent_threads( + self, + *, + count: int | None = None, + max_updated_usec: int | None = None + ) -> QuipResponse: + """Get recently accessed threads for the authenticated user + + Args: + count: Number of threads to return + max_updated_usec: Max updated time in microseconds (for pagination) + + Returns: + QuipResponse with operation result + """ + query_params: dict[str, Any] = {} + if count is not None: + query_params['count'] = str(count) + if max_updated_usec is not None: + query_params['max_updated_usec'] = str(max_updated_usec) + + url = self.base_url + "/threads/recent" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return QuipResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_recent_threads" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return QuipResponse(success=False, error=str(e), message="Failed to execute get_recent_threads") + + async def search_threads( + self, + query: str, + *, + count: int | None = None, + only_match_titles: bool | None = None + ) -> QuipResponse: + """Search for threads (documents) + + Args: + query: Search query string + count: Number of results to return + only_match_titles: Only match thread titles + + Returns: + QuipResponse with operation result + """ + query_params: dict[str, Any] = {} + query_params['query'] = query + if count is not None: + query_params['count'] = str(count) + if only_match_titles is not None: + query_params['only_match_titles'] = str(only_match_titles).lower() + + url = self.base_url + "/threads/search" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return QuipResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed search_threads" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return QuipResponse(success=False, error=str(e), message="Failed to execute search_threads") + + async def create_document( + self, + content: str, + *, + title: str | None = None, + content_format: str | None = None, + member_ids: list[str] | None = None, + thread_type: str | None = None + ) -> QuipResponse: + """Create a new document thread + + Args: + content: HTML content of the document + title: Document title + content_format: Content format ('html' or 'markdown') + member_ids: List of member IDs to add + thread_type: Thread type (document, spreadsheet) + + Returns: + QuipResponse with operation result + """ + url = self.base_url + "/threads/new-document" + + body: dict[str, Any] = {} + body['content'] = content + if title is not None: + body['title'] = title + if content_format is not None: + body['format'] = content_format + if member_ids is not None: + body['member_ids'] = member_ids + if thread_type is not None: + body['type'] = thread_type + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return QuipResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed create_document" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return QuipResponse(success=False, error=str(e), message="Failed to execute create_document") + + async def edit_document( + self, + thread_id: str, + *, + content: str | None = None, + content_format: str | None = None, + location: int | None = None, + section_id: str | None = None + ) -> QuipResponse: + """Edit an existing document thread + + Args: + thread_id: The thread ID to edit + content: New HTML content + content_format: Content format ('html' or 'markdown') + location: Insert location (0=beginning, 1=end, 2=after_section, 3=before_section, 4=replace_section, 5=delete_section) + section_id: Section ID for location-based edits + + Returns: + QuipResponse with operation result + """ + url = self.base_url + "/threads/edit-document" + + body: dict[str, Any] = {} + body['thread_id'] = thread_id + if content is not None: + body['content'] = content + if content_format is not None: + body['format'] = content_format + if location is not None: + body['location'] = location + if section_id is not None: + body['section_id'] = section_id + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return QuipResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed edit_document" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return QuipResponse(success=False, error=str(e), message="Failed to execute edit_document") + + async def add_thread_members( + self, + thread_id: str, + member_ids: list[str] + ) -> QuipResponse: + """Add members to a thread + + Args: + thread_id: The thread ID + member_ids: List of user IDs to add as members + + Returns: + QuipResponse with operation result + """ + url = self.base_url + "/threads/add-members" + + body: dict[str, Any] = {} + body['thread_id'] = thread_id + body['member_ids'] = member_ids + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return QuipResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed add_thread_members" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return QuipResponse(success=False, error=str(e), message="Failed to execute add_thread_members") + + async def remove_thread_members( + self, + thread_id: str, + member_ids: list[str] + ) -> QuipResponse: + """Remove members from a thread + + Args: + thread_id: The thread ID + member_ids: List of user IDs to remove + + Returns: + QuipResponse with operation result + """ + url = self.base_url + "/threads/remove-members" + + body: dict[str, Any] = {} + body['thread_id'] = thread_id + body['member_ids'] = member_ids + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return QuipResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed remove_thread_members" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return QuipResponse(success=False, error=str(e), message="Failed to execute remove_thread_members") + + async def move_thread( + self, + thread_id: str, + folder_id: str + ) -> QuipResponse: + """Move a thread to a different folder + + Args: + thread_id: The thread ID to move + folder_id: Destination folder ID + + Returns: + QuipResponse with operation result + """ + url = self.base_url + "/threads/move" + + body: dict[str, Any] = {} + body['thread_id'] = thread_id + body['folder_id'] = folder_id + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return QuipResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed move_thread" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return QuipResponse(success=False, error=str(e), message="Failed to execute move_thread") + + async def delete_thread( + self, + thread_id: str + ) -> QuipResponse: + """Delete (trash) a thread + + Args: + thread_id: The thread ID to delete + + Returns: + QuipResponse with operation result + """ + url = self.base_url + "/threads/delete" + + body: dict[str, Any] = {} + body['thread_id'] = thread_id + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return QuipResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed delete_thread" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return QuipResponse(success=False, error=str(e), message="Failed to execute delete_thread") + + async def get_thread_messages( + self, + thread_id: str, + *, + count: int | None = None, + max_created_usec: int | None = None + ) -> QuipResponse: + """Get messages (comments) for a thread + + Args: + thread_id: The thread ID + count: Number of messages to return + max_created_usec: Max created time in microseconds (for pagination) + + Returns: + QuipResponse with operation result + """ + query_params: dict[str, Any] = {} + if count is not None: + query_params['count'] = str(count) + if max_created_usec is not None: + query_params['max_created_usec'] = str(max_created_usec) + + url = self.base_url + "/messages/{thread_id}".format(thread_id=thread_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return QuipResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_thread_messages" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return QuipResponse(success=False, error=str(e), message="Failed to execute get_thread_messages") + + async def create_message( + self, + thread_id: str, + content: str, + *, + frame: str | None = None, + section_id: str | None = None, + annotation_id: str | None = None + ) -> QuipResponse: + """Create a new message (comment) on a thread + + Args: + thread_id: The thread ID to comment on + content: Message content (can contain HTML) + frame: Frame type (bubble, card, line) + section_id: Section ID to attach comment to + annotation_id: Annotation ID for inline comments + + Returns: + QuipResponse with operation result + """ + url = self.base_url + "/messages/new" + + body: dict[str, Any] = {} + body['thread_id'] = thread_id + body['content'] = content + if frame is not None: + body['frame'] = frame + if section_id is not None: + body['section_id'] = section_id + if annotation_id is not None: + body['annotation_id'] = annotation_id + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return QuipResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed create_message" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return QuipResponse(success=False, error=str(e), message="Failed to execute create_message") + + async def get_folder( + self, + folder_id: str + ) -> QuipResponse: + """Get a specific folder by ID + + Args: + folder_id: The folder ID + + Returns: + QuipResponse with operation result + """ + url = self.base_url + "/folders/{folder_id}".format(folder_id=folder_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return QuipResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_folder" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return QuipResponse(success=False, error=str(e), message="Failed to execute get_folder") + + async def get_folders( + self, + folder_ids: str + ) -> QuipResponse: + """Get multiple folders by IDs (comma-separated) + + Args: + folder_ids: Comma-separated folder IDs + + Returns: + QuipResponse with operation result + """ + url = self.base_url + "/folders/{folder_ids}".format(folder_ids=folder_ids) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return QuipResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_folders" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return QuipResponse(success=False, error=str(e), message="Failed to execute get_folders") + + async def create_folder( + self, + title: str, + *, + parent_id: str | None = None, + color: str | None = None, + member_ids: list[str] | None = None + ) -> QuipResponse: + """Create a new folder + + Args: + title: Folder title + parent_id: Parent folder ID + color: Folder color (manila, red, orange, green, blue) + member_ids: List of member IDs to add + + Returns: + QuipResponse with operation result + """ + url = self.base_url + "/folders/new" + + body: dict[str, Any] = {} + body['title'] = title + if parent_id is not None: + body['parent_id'] = parent_id + if color is not None: + body['color'] = color + if member_ids is not None: + body['member_ids'] = member_ids + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return QuipResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed create_folder" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return QuipResponse(success=False, error=str(e), message="Failed to execute create_folder") + + async def update_folder( + self, + folder_id: str, + *, + title: str | None = None, + color: str | None = None + ) -> QuipResponse: + """Update a folder + + Args: + folder_id: The folder ID to update + title: New folder title + color: New folder color + + Returns: + QuipResponse with operation result + """ + url = self.base_url + "/folders/update" + + body: dict[str, Any] = {} + body['folder_id'] = folder_id + if title is not None: + body['title'] = title + if color is not None: + body['color'] = color + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return QuipResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed update_folder" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return QuipResponse(success=False, error=str(e), message="Failed to execute update_folder") + + async def add_folder_members( + self, + folder_id: str, + member_ids: list[str] + ) -> QuipResponse: + """Add members to a folder + + Args: + folder_id: The folder ID + member_ids: List of user IDs to add + + Returns: + QuipResponse with operation result + """ + url = self.base_url + "/folders/add-members" + + body: dict[str, Any] = {} + body['folder_id'] = folder_id + body['member_ids'] = member_ids + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return QuipResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed add_folder_members" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return QuipResponse(success=False, error=str(e), message="Failed to execute add_folder_members") + + async def remove_folder_members( + self, + folder_id: str, + member_ids: list[str] + ) -> QuipResponse: + """Remove members from a folder + + Args: + folder_id: The folder ID + member_ids: List of user IDs to remove + + Returns: + QuipResponse with operation result + """ + url = self.base_url + "/folders/remove-members" + + body: dict[str, Any] = {} + body['folder_id'] = folder_id + body['member_ids'] = member_ids + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return QuipResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed remove_folder_members" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return QuipResponse(success=False, error=str(e), message="Failed to execute remove_folder_members") + + async def delete_folder( + self, + folder_id: str + ) -> QuipResponse: + """Delete (trash) a folder + + Args: + folder_id: The folder ID to delete + + Returns: + QuipResponse with operation result + """ + url = self.base_url + "/folders/delete" + + body: dict[str, Any] = {} + body['folder_id'] = folder_id + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return QuipResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed delete_folder" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return QuipResponse(success=False, error=str(e), message="Failed to execute delete_folder") diff --git a/backend/python/app/sources/external/redmine/example.py b/backend/python/app/sources/external/redmine/example.py new file mode 100644 index 000000000..8034afcda --- /dev/null +++ b/backend/python/app/sources/external/redmine/example.py @@ -0,0 +1,176 @@ +# ruff: noqa + +""" +Redmine API Usage Examples + +This example demonstrates how to use the Redmine DataSource to interact with +the Redmine REST API, covering: +- Authentication (API Key, Basic Auth) +- Initializing the Client and DataSource +- Fetching Projects, Issues, Users +- Time entries, News, Wiki pages +- Issue statuses, Trackers, Roles + +Prerequisites: +For API Key: +1. Set REDMINE_INSTANCE_URL to your Redmine instance (e.g. "redmine.example.com") +2. Set REDMINE_API_KEY (found at My Account > API access key) + +For Basic Auth: +1. Set REDMINE_INSTANCE_URL +2. Set REDMINE_USERNAME and REDMINE_PASSWORD +""" + +import asyncio +import json +import os + +from app.sources.client.redmine.redmine import ( + RedmineApiKeyConfig, + RedmineBasicAuthConfig, + RedmineClient, + RedmineResponse, +) +from app.sources.external.redmine.redmine import RedmineDataSource + +# --- Configuration --- +INSTANCE_URL = os.getenv("REDMINE_INSTANCE_URL", "") +API_KEY = os.getenv("REDMINE_API_KEY", "") +USERNAME = os.getenv("REDMINE_USERNAME", "") +PASSWORD = os.getenv("REDMINE_PASSWORD", "") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: RedmineResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + if isinstance(data, list): + print(f" Found {len(data)} items.") + if data: + print(f" Sample: {json.dumps(data[0], indent=2)[:400]}...") + elif isinstance(data, dict): + for key in ("projects", "issues", "users", "time_entries", "news", "wiki_pages"): + if key in data: + items = data[key] + print(f" Found {len(items)} {key}.") + if items: + print(f" Sample: {json.dumps(items[0], indent=2)[:400]}...") + return + print(f" Data: {json.dumps(data, indent=2)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + # 1. Initialize Client + print_section("Initializing Redmine Client") + + if not INSTANCE_URL: + print(" REDMINE_INSTANCE_URL is required.") + return + + config = None + + # Priority 1: API Key + if API_KEY: + print(" Using API Key authentication") + config = RedmineApiKeyConfig(api_key=API_KEY, instance_url=INSTANCE_URL) + + # Priority 2: Basic Auth + elif USERNAME and PASSWORD: + print(" Using Basic Auth authentication") + config = RedmineBasicAuthConfig( + username=USERNAME, password=PASSWORD, instance_url=INSTANCE_URL + ) + + if config is None: + print(" No valid authentication found.") + print(" Please set one of:") + print(" - REDMINE_API_KEY (for API Key auth)") + print(" - REDMINE_USERNAME and REDMINE_PASSWORD (for Basic Auth)") + return + + client = RedmineClient.build_with_config(config) + data_source = RedmineDataSource(client) + print(f"Client initialized for instance: {INSTANCE_URL}") + + try: + # 2. Get Projects + print_section("Projects") + projects_resp = await data_source.get_projects(limit=5) + print_result("Get Projects", projects_resp) + + project_id = None + if projects_resp.success and isinstance(projects_resp.data, dict): + projects = projects_resp.data.get("projects", []) + if projects: + project_id = str(projects[0].get("id", "")) + print(f" Using Project: {projects[0].get('name')} (ID: {project_id})") + + # 3. Get Issues + print_section("Issues") + issues_resp = await data_source.get_issues( + project_id=project_id, limit=5 + ) + print_result("Get Issues", issues_resp) + + # 4. Get Users + print_section("Users") + users_resp = await data_source.get_users(limit=5) + print_result("Get Users", users_resp) + + # 5. Get Time Entries + print_section("Time Entries") + time_resp = await data_source.get_time_entries(limit=5) + print_result("Get Time Entries", time_resp) + + # 6. Get News + print_section("News") + news_resp = await data_source.get_news(limit=5) + print_result("Get News", news_resp) + + # 7. Get Issue Statuses + print_section("Issue Statuses") + statuses_resp = await data_source.get_issue_statuses() + print_result("Get Issue Statuses", statuses_resp) + + # 8. Get Trackers + print_section("Trackers") + trackers_resp = await data_source.get_trackers() + print_result("Get Trackers", trackers_resp) + + # 9. Get Roles + print_section("Roles") + roles_resp = await data_source.get_roles() + print_result("Get Roles", roles_resp) + + # 10. Get Wiki Index (if project found) + if project_id: + print_section("Wiki Index") + wiki_resp = await data_source.get_wiki_index(project_id) + print_result("Get Wiki Index", wiki_resp) + + finally: + # Cleanup + print("\nClosing client connection...") + inner_client = client.get_client() + if hasattr(inner_client, "close"): + await inner_client.close() + + print("\n" + "=" * 80) + print(" All Redmine API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/redmine/redmine.py b/backend/python/app/sources/external/redmine/redmine.py new file mode 100644 index 000000000..27b047354 --- /dev/null +++ b/backend/python/app/sources/external/redmine/redmine.py @@ -0,0 +1,734 @@ +""" +Redmine REST API DataSource - Auto-generated API wrapper + +Generated from Redmine REST API documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +All endpoints use .json suffix for JSON responses. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.http.http_request import HTTPRequest +from app.sources.client.redmine.redmine import RedmineClient, RedmineResponse + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class RedmineDataSource: + """Redmine REST API DataSource + + Provides async wrapper methods for Redmine REST API operations: + - Project management + - Issue tracking + - User management + - Time entries + - News + - Wiki pages + - Issue statuses, trackers, roles + - Project memberships + + The base URL is the instance URL configured in the client. + All endpoints append .json for JSON responses. + All methods return RedmineResponse objects. + """ + + def __init__(self, client: RedmineClient) -> None: + """Initialize with RedmineClient. + + Args: + client: RedmineClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip("/") + except AttributeError as exc: + raise ValueError( + "HTTP client does not have get_base_url method" + ) from exc + + def get_data_source(self) -> "RedmineDataSource": + """Return the data source instance.""" + return self + + def get_client(self) -> RedmineClient: + """Return the underlying RedmineClient.""" + return self._client + + # ----------------------------------------------------------------------- + # Projects + # ----------------------------------------------------------------------- + + async def get_projects( + self, + *, + limit: int | None = None, + offset: int | None = None, + ) -> RedmineResponse: + """Get a list of projects. + + Args: + limit: Maximum number of results + offset: Number of results to skip + + Returns: + RedmineResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params["limit"] = str(limit) + if offset is not None: + query_params["offset"] = str(offset) + + url = self.base_url + "/projects.json" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return RedmineResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_projects" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return RedmineResponse( + success=False, + error=str(e), + message="Failed to execute get_projects", + ) + + async def get_project(self, project_id: str) -> RedmineResponse: + """Get a project by ID or identifier. + + Args: + project_id: The project ID or string identifier + + Returns: + RedmineResponse with operation result + """ + url = self.base_url + "/projects/{project_id}.json".format( + project_id=project_id + ) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return RedmineResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_project" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return RedmineResponse( + success=False, + error=str(e), + message="Failed to execute get_project", + ) + + # ----------------------------------------------------------------------- + # Issues + # ----------------------------------------------------------------------- + + async def get_issues( + self, + *, + project_id: str | None = None, + tracker_id: str | None = None, + status_id: str | None = None, + assigned_to_id: str | None = None, + limit: int | None = None, + offset: int | None = None, + ) -> RedmineResponse: + """Get a list of issues. + + Args: + project_id: Filter by project ID + tracker_id: Filter by tracker ID + status_id: Filter by status ID (use "open", "closed", "*", or numeric ID) + assigned_to_id: Filter by assigned user ID + limit: Maximum number of results + offset: Number of results to skip + + Returns: + RedmineResponse with operation result + """ + query_params: dict[str, Any] = {} + if project_id is not None: + query_params["project_id"] = project_id + if tracker_id is not None: + query_params["tracker_id"] = tracker_id + if status_id is not None: + query_params["status_id"] = status_id + if assigned_to_id is not None: + query_params["assigned_to_id"] = assigned_to_id + if limit is not None: + query_params["limit"] = str(limit) + if offset is not None: + query_params["offset"] = str(offset) + + url = self.base_url + "/issues.json" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return RedmineResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_issues" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return RedmineResponse( + success=False, + error=str(e), + message="Failed to execute get_issues", + ) + + async def get_issue(self, issue_id: str) -> RedmineResponse: + """Get an issue by ID. + + Args: + issue_id: The issue ID + + Returns: + RedmineResponse with operation result + """ + url = self.base_url + "/issues/{issue_id}.json".format( + issue_id=issue_id + ) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return RedmineResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_issue" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return RedmineResponse( + success=False, + error=str(e), + message="Failed to execute get_issue", + ) + + # ----------------------------------------------------------------------- + # Users + # ----------------------------------------------------------------------- + + async def get_users( + self, + *, + limit: int | None = None, + offset: int | None = None, + ) -> RedmineResponse: + """Get a list of users. + + Args: + limit: Maximum number of results + offset: Number of results to skip + + Returns: + RedmineResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params["limit"] = str(limit) + if offset is not None: + query_params["offset"] = str(offset) + + url = self.base_url + "/users.json" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return RedmineResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_users" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return RedmineResponse( + success=False, + error=str(e), + message="Failed to execute get_users", + ) + + async def get_user(self, user_id: str) -> RedmineResponse: + """Get a user by ID. + + Args: + user_id: The user ID + + Returns: + RedmineResponse with operation result + """ + url = self.base_url + "/users/{user_id}.json".format( + user_id=user_id + ) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return RedmineResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_user" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return RedmineResponse( + success=False, + error=str(e), + message="Failed to execute get_user", + ) + + # ----------------------------------------------------------------------- + # Time Entries + # ----------------------------------------------------------------------- + + async def get_time_entries( + self, + *, + limit: int | None = None, + offset: int | None = None, + ) -> RedmineResponse: + """Get a list of time entries. + + Args: + limit: Maximum number of results + offset: Number of results to skip + + Returns: + RedmineResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params["limit"] = str(limit) + if offset is not None: + query_params["offset"] = str(offset) + + url = self.base_url + "/time_entries.json" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return RedmineResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_time_entries" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return RedmineResponse( + success=False, + error=str(e), + message="Failed to execute get_time_entries", + ) + + async def get_time_entry( + self, time_entry_id: str + ) -> RedmineResponse: + """Get a time entry by ID. + + Args: + time_entry_id: The time entry ID + + Returns: + RedmineResponse with operation result + """ + url = self.base_url + "/time_entries/{time_entry_id}.json".format( + time_entry_id=time_entry_id + ) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return RedmineResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_time_entry" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return RedmineResponse( + success=False, + error=str(e), + message="Failed to execute get_time_entry", + ) + + # ----------------------------------------------------------------------- + # News + # ----------------------------------------------------------------------- + + async def get_news( + self, + *, + limit: int | None = None, + offset: int | None = None, + ) -> RedmineResponse: + """Get a list of news items. + + Args: + limit: Maximum number of results + offset: Number of results to skip + + Returns: + RedmineResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params["limit"] = str(limit) + if offset is not None: + query_params["offset"] = str(offset) + + url = self.base_url + "/news.json" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return RedmineResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_news" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return RedmineResponse( + success=False, + error=str(e), + message="Failed to execute get_news", + ) + + # ----------------------------------------------------------------------- + # Wiki + # ----------------------------------------------------------------------- + + async def get_wiki_index( + self, project_id: str + ) -> RedmineResponse: + """Get wiki page index for a project. + + Args: + project_id: The project ID or identifier + + Returns: + RedmineResponse with operation result + """ + url = self.base_url + "/projects/{project_id}/wiki/index.json".format( + project_id=project_id + ) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return RedmineResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_wiki_index" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return RedmineResponse( + success=False, + error=str(e), + message="Failed to execute get_wiki_index", + ) + + async def get_wiki_page( + self, + project_id: str, + page_title: str, + ) -> RedmineResponse: + """Get a specific wiki page. + + Args: + project_id: The project ID or identifier + page_title: The wiki page title + + Returns: + RedmineResponse with operation result + """ + url = ( + self.base_url + + "/projects/{project_id}/wiki/{page_title}.json".format( + project_id=project_id, page_title=page_title + ) + ) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return RedmineResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_wiki_page" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return RedmineResponse( + success=False, + error=str(e), + message="Failed to execute get_wiki_page", + ) + + # ----------------------------------------------------------------------- + # Issue Statuses + # ----------------------------------------------------------------------- + + async def get_issue_statuses(self) -> RedmineResponse: + """Get all issue statuses. + + Returns: + RedmineResponse with operation result + """ + url = self.base_url + "/issue_statuses.json" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return RedmineResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_issue_statuses" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return RedmineResponse( + success=False, + error=str(e), + message="Failed to execute get_issue_statuses", + ) + + # ----------------------------------------------------------------------- + # Trackers + # ----------------------------------------------------------------------- + + async def get_trackers(self) -> RedmineResponse: + """Get all trackers. + + Returns: + RedmineResponse with operation result + """ + url = self.base_url + "/trackers.json" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return RedmineResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_trackers" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return RedmineResponse( + success=False, + error=str(e), + message="Failed to execute get_trackers", + ) + + # ----------------------------------------------------------------------- + # Roles + # ----------------------------------------------------------------------- + + async def get_roles(self) -> RedmineResponse: + """Get all roles. + + Returns: + RedmineResponse with operation result + """ + url = self.base_url + "/roles.json" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return RedmineResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_roles" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return RedmineResponse( + success=False, + error=str(e), + message="Failed to execute get_roles", + ) + + # ----------------------------------------------------------------------- + # Memberships + # ----------------------------------------------------------------------- + + async def get_memberships( + self, + *, + limit: int | None = None, + offset: int | None = None, + ) -> RedmineResponse: + """Get project memberships. + + Args: + limit: Maximum number of results + offset: Number of results to skip + + Returns: + RedmineResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params["limit"] = str(limit) + if offset is not None: + query_params["offset"] = str(offset) + + url = self.base_url + "/memberships.json" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return RedmineResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message=( + "Successfully executed get_memberships" + if response.status < HTTP_ERROR_THRESHOLD + else f"Failed with status {response.status}" + ), + ) + except Exception as e: + return RedmineResponse( + success=False, + error=str(e), + message="Failed to execute get_memberships", + ) diff --git a/backend/python/app/sources/external/seismic/code_generator.py b/backend/python/app/sources/external/seismic/code_generator.py new file mode 100644 index 000000000..266f6cd03 --- /dev/null +++ b/backend/python/app/sources/external/seismic/code_generator.py @@ -0,0 +1,207 @@ +# ruff: noqa +""" +Seismic DataSource Code Generator + +Defines Seismic API endpoint specifications and generates the DataSource +wrapper class (seismic.py) from them. + +Endpoints: + /library/content, /library/content/{id}, /library/folders, + /library/folders/{id}, /teamsites, /teamsites/{id}, + /teamsites/{id}/content, /users, /users/{id}, + /workspace/documents, /workspace/documents/{id}, + /livesend/links, /analytics/content +""" + +from __future__ import annotations + +ENDPOINTS = [ + # Library Content + {"method": "GET", "path": "/library/content", "name": "list_library_content", "section": "Library Content", + "doc": "List all library content", "paginated": True}, + {"method": "GET", "path": "/library/content/{content_id}", "name": "get_library_content", "section": "Library Content", + "doc": "Get a specific library content item by ID", "path_params": ["content_id"]}, + # Library Folders + {"method": "GET", "path": "/library/folders", "name": "list_library_folders", "section": "Library Folders", + "doc": "List all library folders", "paginated": True}, + {"method": "GET", "path": "/library/folders/{folder_id}", "name": "get_library_folder", "section": "Library Folders", + "doc": "Get a specific library folder by ID", "path_params": ["folder_id"]}, + # Teamsites + {"method": "GET", "path": "/teamsites", "name": "list_teamsites", "section": "Teamsites", + "doc": "List all teamsites", "paginated": True}, + {"method": "GET", "path": "/teamsites/{teamsite_id}", "name": "get_teamsite", "section": "Teamsites", + "doc": "Get a specific teamsite by ID", "path_params": ["teamsite_id"]}, + {"method": "GET", "path": "/teamsites/{teamsite_id}/content", "name": "get_teamsite_content", "section": "Teamsites", + "doc": "Get content in a specific teamsite", "path_params": ["teamsite_id"], "paginated": True}, + # Users + {"method": "GET", "path": "/users", "name": "list_users", "section": "Users", + "doc": "List all users", "paginated": True}, + {"method": "GET", "path": "/users/{user_id}", "name": "get_user", "section": "Users", + "doc": "Get a specific user by ID", "path_params": ["user_id"]}, + # Workspace Documents + {"method": "GET", "path": "/workspace/documents", "name": "list_workspace_documents", "section": "Workspace Documents", + "doc": "List all workspace documents", "paginated": True}, + {"method": "GET", "path": "/workspace/documents/{document_id}", "name": "get_workspace_document", "section": "Workspace Documents", + "doc": "Get a specific workspace document by ID", "path_params": ["document_id"]}, + # LiveSend Links + {"method": "GET", "path": "/livesend/links", "name": "list_livesend_links", "section": "LiveSend Links", + "doc": "List all LiveSend links", "paginated": True}, + # Analytics + {"method": "GET", "path": "/analytics/content", "name": "get_content_analytics", "section": "Analytics", + "doc": "Get content analytics"}, +] + + +def _gen_method(ep: dict) -> str: + """Generate a single async method from an endpoint spec.""" + name = ep["name"] + method = ep["method"] + path = ep["path"] + doc = ep["doc"] + path_params = ep.get("path_params", []) + paginated = ep.get("paginated", False) + + sig_parts = ["self"] + for p in path_params: + sig_parts.append(f"{p}: str") + if paginated: + sig_parts.append("*") + sig_parts.append("page: int | None = None") + sig_parts.append("per_page: int | None = None") + + sig = ",\n ".join(sig_parts) + + doc_args = "" + if path_params or paginated: + doc_args = "\n Args:\n" + for p in path_params: + doc_args += f" {p}: The {p.replace('_', ' ')}\n" + if paginated: + doc_args += " page: Page number for pagination\n" + doc_args += " per_page: Number of items per page\n" + + query_block = "" + if paginated: + query_block = """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) +""" + + if path_params: + fmt_args = ", ".join(f"{p}={p}" for p in path_params) + url_line = f' url = self.base_url + "{path}".format({fmt_args})' + else: + url_line = f' url = self.base_url + "{path}"' + + req_extra = "" + if paginated: + req_extra = "\n query=query_params," + + return f''' + async def {name}( + {sig} + ) -> SeismicResponse: + """{doc} + + HTTP {method} {path} +{doc_args} + Returns: + SeismicResponse with operation result + """ +{query_block} +{url_line} + + try: + request = HTTPRequest( + method="{method}", + url=url, + headers={{"Content-Type": "application/json"}},{req_extra} + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return SeismicResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed {name}" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {{response.status}}" + ) + except Exception as e: + return SeismicResponse(success=False, error=str(e), message="Failed to execute {name}") +''' + + +def generate_datasource() -> str: + """Generate the full Seismic DataSource module code.""" + header = '''# ruff: noqa +""" +Seismic REST API DataSource - Auto-generated API wrapper + +Generated from Seismic REST API v2 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.seismic.seismic import SeismicClient, SeismicResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class SeismicDataSource: + """Seismic REST API DataSource + + Provides async wrapper methods for Seismic REST API operations: + - Library content management + - Library folders management + - Teamsites management + - Users management + - Workspace documents management + - LiveSend links + - Analytics + + All methods return SeismicResponse objects. + """ + + def __init__(self, client: SeismicClient) -> None: + """Initialize with SeismicClient. + + Args: + client: SeismicClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'SeismicDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> SeismicClient: + """Return the underlying SeismicClient.""" + return self._client +''' + + methods = [] + current_section = None + for ep in ENDPOINTS: + section = ep.get("section", "") + if section and section != current_section: + current_section = section + methods.append(f"\n # {'-' * 71}\n # {section}\n # {'-' * 71}") + methods.append(_gen_method(ep)) + + return header + "\n".join(methods) + "\n" + + +if __name__ == "__main__": + print(generate_datasource()) diff --git a/backend/python/app/sources/external/seismic/example.py b/backend/python/app/sources/external/seismic/example.py new file mode 100644 index 000000000..72b00cf0e --- /dev/null +++ b/backend/python/app/sources/external/seismic/example.py @@ -0,0 +1,178 @@ +# ruff: noqa + +""" +Seismic API Usage Examples + +This example demonstrates how to use the Seismic DataSource to interact with +the Seismic API, covering: +- Authentication (OAuth2 or Bearer Token) +- Initializing the Client and DataSource +- Listing Library Content, Folders, Teamsites, Users +- Fetching workspace documents, LiveSend links, analytics + +Prerequisites: +For OAuth2: +1. Register an OAuth app with Seismic +2. Set SEISMIC_CLIENT_ID, SEISMIC_CLIENT_SECRET, and SEISMIC_TENANT_ID +3. OAuth uses "body" auth method (credentials in POST body) + +For Bearer Token: +1. Set SEISMIC_ACCESS_TOKEN environment variable +""" + +import asyncio +import json +import os + +from app.sources.client.seismic.seismic import ( + SeismicClient, + SeismicOAuthConfig, + SeismicTokenConfig, + SeismicResponse, +) +from app.sources.external.seismic.seismic import SeismicDataSource +from app.sources.external.utils.oauth import perform_oauth_flow + +# --- Configuration --- +CLIENT_ID = os.getenv("SEISMIC_CLIENT_ID") +CLIENT_SECRET = os.getenv("SEISMIC_CLIENT_SECRET") +TENANT_ID = os.getenv("SEISMIC_TENANT_ID") +ACCESS_TOKEN = os.getenv("SEISMIC_ACCESS_TOKEN") +REDIRECT_URI = os.getenv("SEISMIC_REDIRECT_URI", "http://localhost:8080/callback") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: SeismicResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + for key in ("content", "folders", "teamsites", "users", "documents", + "links", "results"): + if isinstance(data, dict) and key in data: + items = data[key] + print(f" Found {len(items)} {key}.") + if items: + print(f" Sample: {json.dumps(items[0], indent=2)[:400]}...") + return + if isinstance(data, list): + print(f" Found {len(data)} items.") + if data: + print(f" Sample: {json.dumps(data[0], indent=2)[:400]}...") + return + print(f" Data: {json.dumps(data, indent=2)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + # 1. Initialize Client + print_section("Initializing Seismic Client") + + config = None + + # Priority 1: OAuth2 + if CLIENT_ID and CLIENT_SECRET and TENANT_ID: + print(" Using OAuth2 authentication") + try: + auth_endpoint = f"https://auth.seismic.com/tenants/{TENANT_ID}/connect/authorize" + token_endpoint = f"https://auth.seismic.com/tenants/{TENANT_ID}/connect/token" + + token_response = perform_oauth_flow( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + auth_endpoint=auth_endpoint, + token_endpoint=token_endpoint, + redirect_uri=REDIRECT_URI, + scopes=[], + scope_delimiter=" ", + auth_method="body", # Seismic uses body method for token exchange + ) + access_token = token_response.get("access_token") + if not access_token: + raise Exception("No access_token found in OAuth response") + + config = SeismicOAuthConfig( + access_token=access_token, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + tenant_id=TENANT_ID, + ) + print(" OAuth authentication successful") + except Exception as e: + print(f" OAuth flow failed: {e}") + print(" Falling back to other authentication methods...") + + # Priority 2: Bearer Token + if config is None and ACCESS_TOKEN: + print(" Using Bearer Token authentication") + config = SeismicTokenConfig(token=ACCESS_TOKEN) + + if config is None: + print(" No valid authentication method found.") + print(" Please set one of the following:") + print(" - SEISMIC_CLIENT_ID, SEISMIC_CLIENT_SECRET, and SEISMIC_TENANT_ID (for OAuth2)") + print(" - SEISMIC_ACCESS_TOKEN (for Bearer Token)") + return + + client = SeismicClient.build_with_config(config) + data_source = SeismicDataSource(client) + print("Client initialized successfully.") + + try: + # 2. List Library Content + print_section("Library Content") + content_resp = await data_source.list_library_content(page=1, per_page=10) + print_result("List Library Content", content_resp) + + # 3. List Library Folders + print_section("Library Folders") + folders_resp = await data_source.list_library_folders(page=1, per_page=10) + print_result("List Library Folders", folders_resp) + + # 4. List Teamsites + print_section("Teamsites") + teamsites_resp = await data_source.list_teamsites(page=1, per_page=10) + print_result("List Teamsites", teamsites_resp) + + # 5. List Users + print_section("Users") + users_resp = await data_source.list_users(page=1, per_page=10) + print_result("List Users", users_resp) + + # 6. List Workspace Documents + print_section("Workspace Documents") + docs_resp = await data_source.list_workspace_documents(page=1, per_page=10) + print_result("List Workspace Documents", docs_resp) + + # 7. List LiveSend Links + print_section("LiveSend Links") + links_resp = await data_source.list_livesend_links(page=1, per_page=10) + print_result("List LiveSend Links", links_resp) + + # 8. Content Analytics + print_section("Content Analytics") + analytics_resp = await data_source.get_content_analytics() + print_result("Content Analytics", analytics_resp) + + finally: + print("\nClosing client connection...") + inner_client = client.get_client() + if hasattr(inner_client, "close"): + await inner_client.close() + + print("\n" + "=" * 80) + print(" All Seismic API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/seismic/run_generator.py b/backend/python/app/sources/external/seismic/run_generator.py new file mode 100644 index 000000000..314222c70 --- /dev/null +++ b/backend/python/app/sources/external/seismic/run_generator.py @@ -0,0 +1,24 @@ +"""Runner script to generate the Seismic DataSource wrapper. + +Execute this script to regenerate seismic.py from the endpoint definitions +in code_generator.py. + +Usage: + python -m app.sources.external.seismic.run_generator +""" + +from app.sources.external.seismic.code_generator import generate_datasource + + +def main() -> None: + """Generate the Seismic DataSource file.""" + code = generate_datasource() + output_path = __file__.replace("run_generator.py", "seismic.py") + with open(output_path, "w") as f: + f.write(code) + print(f"Generated Seismic DataSource -> {output_path}") + print(f" Total characters: {len(code)}") + + +if __name__ == "__main__": + main() diff --git a/backend/python/app/sources/external/seismic/seismic.py b/backend/python/app/sources/external/seismic/seismic.py new file mode 100644 index 000000000..fa1647b12 --- /dev/null +++ b/backend/python/app/sources/external/seismic/seismic.py @@ -0,0 +1,567 @@ +# ruff: noqa +""" +Seismic REST API DataSource - Auto-generated API wrapper + +Generated from Seismic REST API v2 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.seismic.seismic import SeismicClient, SeismicResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class SeismicDataSource: + """Seismic REST API DataSource + + Provides async wrapper methods for Seismic REST API operations: + - Library content management + - Library folders management + - Teamsites management + - Users management + - Workspace documents management + - LiveSend links + - Analytics + + All methods return SeismicResponse objects. + """ + + def __init__(self, client: SeismicClient) -> None: + """Initialize with SeismicClient. + + Args: + client: SeismicClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'SeismicDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> SeismicClient: + """Return the underlying SeismicClient.""" + return self._client + + # ----------------------------------------------------------------------- + # Library Content + # ----------------------------------------------------------------------- + + async def list_library_content( + self, + *, + page: int | None = None, + per_page: int | None = None + ) -> SeismicResponse: + """List all library content + + HTTP GET /library/content + + Args: + page: Page number for pagination + per_page: Number of items per page + + Returns: + SeismicResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/library/content" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return SeismicResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_library_content" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return SeismicResponse(success=False, error=str(e), message="Failed to execute list_library_content") + + async def get_library_content( + self, + content_id: str + ) -> SeismicResponse: + """Get a specific library content item by ID + + HTTP GET /library/content/{content_id} + + Args: + content_id: The content ID + + Returns: + SeismicResponse with operation result + """ + url = self.base_url + "/library/content/{content_id}".format(content_id=content_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return SeismicResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_library_content" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return SeismicResponse(success=False, error=str(e), message="Failed to execute get_library_content") + + # ----------------------------------------------------------------------- + # Library Folders + # ----------------------------------------------------------------------- + + async def list_library_folders( + self, + *, + page: int | None = None, + per_page: int | None = None + ) -> SeismicResponse: + """List all library folders + + HTTP GET /library/folders + + Args: + page: Page number for pagination + per_page: Number of items per page + + Returns: + SeismicResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/library/folders" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return SeismicResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_library_folders" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return SeismicResponse(success=False, error=str(e), message="Failed to execute list_library_folders") + + async def get_library_folder( + self, + folder_id: str + ) -> SeismicResponse: + """Get a specific library folder by ID + + HTTP GET /library/folders/{folder_id} + + Args: + folder_id: The folder ID + + Returns: + SeismicResponse with operation result + """ + url = self.base_url + "/library/folders/{folder_id}".format(folder_id=folder_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return SeismicResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_library_folder" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return SeismicResponse(success=False, error=str(e), message="Failed to execute get_library_folder") + + # ----------------------------------------------------------------------- + # Teamsites + # ----------------------------------------------------------------------- + + async def list_teamsites( + self, + *, + page: int | None = None, + per_page: int | None = None + ) -> SeismicResponse: + """List all teamsites + + HTTP GET /teamsites + + Args: + page: Page number for pagination + per_page: Number of items per page + + Returns: + SeismicResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/teamsites" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return SeismicResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_teamsites" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return SeismicResponse(success=False, error=str(e), message="Failed to execute list_teamsites") + + async def get_teamsite( + self, + teamsite_id: str + ) -> SeismicResponse: + """Get a specific teamsite by ID + + HTTP GET /teamsites/{teamsite_id} + + Args: + teamsite_id: The teamsite ID + + Returns: + SeismicResponse with operation result + """ + url = self.base_url + "/teamsites/{teamsite_id}".format(teamsite_id=teamsite_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return SeismicResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_teamsite" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return SeismicResponse(success=False, error=str(e), message="Failed to execute get_teamsite") + + async def get_teamsite_content( + self, + teamsite_id: str, + *, + page: int | None = None, + per_page: int | None = None + ) -> SeismicResponse: + """Get content in a specific teamsite + + HTTP GET /teamsites/{teamsite_id}/content + + Args: + teamsite_id: The teamsite ID + page: Page number for pagination + per_page: Number of items per page + + Returns: + SeismicResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/teamsites/{teamsite_id}/content".format(teamsite_id=teamsite_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return SeismicResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_teamsite_content" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return SeismicResponse(success=False, error=str(e), message="Failed to execute get_teamsite_content") + + # ----------------------------------------------------------------------- + # Users + # ----------------------------------------------------------------------- + + async def list_users( + self, + *, + page: int | None = None, + per_page: int | None = None + ) -> SeismicResponse: + """List all users + + HTTP GET /users + + Args: + page: Page number for pagination + per_page: Number of items per page + + Returns: + SeismicResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/users" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return SeismicResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_users" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return SeismicResponse(success=False, error=str(e), message="Failed to execute list_users") + + async def get_user( + self, + user_id: str + ) -> SeismicResponse: + """Get a specific user by ID + + HTTP GET /users/{user_id} + + Args: + user_id: The user ID + + Returns: + SeismicResponse with operation result + """ + url = self.base_url + "/users/{user_id}".format(user_id=user_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return SeismicResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_user" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return SeismicResponse(success=False, error=str(e), message="Failed to execute get_user") + + # ----------------------------------------------------------------------- + # Workspace Documents + # ----------------------------------------------------------------------- + + async def list_workspace_documents( + self, + *, + page: int | None = None, + per_page: int | None = None + ) -> SeismicResponse: + """List all workspace documents + + HTTP GET /workspace/documents + + Args: + page: Page number for pagination + per_page: Number of items per page + + Returns: + SeismicResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/workspace/documents" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return SeismicResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_workspace_documents" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return SeismicResponse(success=False, error=str(e), message="Failed to execute list_workspace_documents") + + async def get_workspace_document( + self, + document_id: str + ) -> SeismicResponse: + """Get a specific workspace document by ID + + HTTP GET /workspace/documents/{document_id} + + Args: + document_id: The document ID + + Returns: + SeismicResponse with operation result + """ + url = self.base_url + "/workspace/documents/{document_id}".format(document_id=document_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return SeismicResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_workspace_document" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return SeismicResponse(success=False, error=str(e), message="Failed to execute get_workspace_document") + + # ----------------------------------------------------------------------- + # LiveSend Links + # ----------------------------------------------------------------------- + + async def list_livesend_links( + self, + *, + page: int | None = None, + per_page: int | None = None + ) -> SeismicResponse: + """List all LiveSend links + + HTTP GET /livesend/links + + Args: + page: Page number for pagination + per_page: Number of items per page + + Returns: + SeismicResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + + url = self.base_url + "/livesend/links" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return SeismicResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_livesend_links" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return SeismicResponse(success=False, error=str(e), message="Failed to execute list_livesend_links") + + # ----------------------------------------------------------------------- + # Analytics + # ----------------------------------------------------------------------- + + async def get_content_analytics( + self + ) -> SeismicResponse: + """Get content analytics + + HTTP GET /analytics/content + + Returns: + SeismicResponse with operation result + """ + url = self.base_url + "/analytics/content" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return SeismicResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_content_analytics" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return SeismicResponse(success=False, error=str(e), message="Failed to execute get_content_analytics") diff --git a/backend/python/app/sources/external/simpplr/code_generator.py b/backend/python/app/sources/external/simpplr/code_generator.py new file mode 100644 index 000000000..2416e50f1 --- /dev/null +++ b/backend/python/app/sources/external/simpplr/code_generator.py @@ -0,0 +1,223 @@ +# ruff: noqa +""" +Simpplr DataSource Code Generator + +Defines Simpplr API endpoint specifications and generates the DataSource +wrapper class (simpplr.py) from them. + +Endpoints: + /sites, /sites/{id}, /content, /content/{id}, /users, /users/{id}, + /pages, /pages/{id}, /events, /events/{id}, /newsletters, /newsletters/{id}, + /search (query: q, type, limit, offset), /analytics/content +""" + +from __future__ import annotations + +ENDPOINTS = [ + # Sites + {"method": "GET", "path": "/sites", "name": "list_sites", "section": "Sites", + "doc": "List all sites", "query_params": [("limit", "int", "Maximum number of results"), ("offset", "int", "Offset for pagination")]}, + {"method": "GET", "path": "/sites/{site_id}", "name": "get_site", "section": "Sites", + "doc": "Get a specific site by ID", "path_params": ["site_id"]}, + # Content + {"method": "GET", "path": "/content", "name": "list_content", "section": "Content", + "doc": "List all content", "query_params": [("limit", "int", "Maximum number of results"), ("offset", "int", "Offset for pagination")]}, + {"method": "GET", "path": "/content/{content_id}", "name": "get_content", "section": "Content", + "doc": "Get a specific content item by ID", "path_params": ["content_id"]}, + # Users + {"method": "GET", "path": "/users", "name": "list_users", "section": "Users", + "doc": "List all users", "query_params": [("limit", "int", "Maximum number of results"), ("offset", "int", "Offset for pagination")]}, + {"method": "GET", "path": "/users/{user_id}", "name": "get_user", "section": "Users", + "doc": "Get a specific user by ID", "path_params": ["user_id"]}, + # Pages + {"method": "GET", "path": "/pages", "name": "list_pages", "section": "Pages", + "doc": "List all pages", "query_params": [("limit", "int", "Maximum number of results"), ("offset", "int", "Offset for pagination")]}, + {"method": "GET", "path": "/pages/{page_id}", "name": "get_page", "section": "Pages", + "doc": "Get a specific page by ID", "path_params": ["page_id"]}, + # Events + {"method": "GET", "path": "/events", "name": "list_events", "section": "Events", + "doc": "List all events", "query_params": [("limit", "int", "Maximum number of results"), ("offset", "int", "Offset for pagination")]}, + {"method": "GET", "path": "/events/{event_id}", "name": "get_event", "section": "Events", + "doc": "Get a specific event by ID", "path_params": ["event_id"]}, + # Newsletters + {"method": "GET", "path": "/newsletters", "name": "list_newsletters", "section": "Newsletters", + "doc": "List all newsletters", "query_params": [("limit", "int", "Maximum number of results"), ("offset", "int", "Offset for pagination")]}, + {"method": "GET", "path": "/newsletters/{newsletter_id}", "name": "get_newsletter", "section": "Newsletters", + "doc": "Get a specific newsletter by ID", "path_params": ["newsletter_id"]}, + # Search + {"method": "GET", "path": "/search", "name": "search", "section": "Search", + "doc": "Search across Simpplr content", + "query_params": [("q", "str", "Search query string"), ("type", "str", "Content type filter"), ("limit", "int", "Maximum number of results"), ("offset", "int", "Offset for pagination")]}, + # Analytics + {"method": "GET", "path": "/analytics/content", "name": "get_content_analytics", "section": "Analytics", + "doc": "Get content analytics"}, +] + + +def _gen_method(ep: dict) -> str: + """Generate a single async method from an endpoint spec.""" + name = ep["name"] + method = ep["method"] + path = ep["path"] + doc = ep["doc"] + path_params = ep.get("path_params", []) + query_params = ep.get("query_params", []) + body_params = ep.get("body_params", []) + + sig_parts = ["self"] + for p in path_params: + sig_parts.append(f"{p}: str") + for bp in body_params: + sig_parts.append(f"{bp[0]}: {bp[2]}") + if query_params: + sig_parts.append("*") + for qp in query_params: + sig_parts.append(f"{qp[0]}: {qp[1]} | None = None") + + sig = ",\n ".join(sig_parts) + + doc_args = "" + if path_params or query_params or body_params: + doc_args = "\n Args:\n" + for p in path_params: + doc_args += f" {p}: The {p.replace('_', ' ')}\n" + for bp in body_params: + doc_args += f" {bp[0]}: {bp[3]}\n" + for qp in query_params: + doc_args += f" {qp[0]}: {qp[2]}\n" + + query_block = "" + if query_params: + lines = ["\n query_params: dict[str, Any] = {}"] + for qp in query_params: + lines.append(f" if {qp[0]} is not None:") + lines.append(f" query_params['{qp[0]}'] = str({qp[0]})") + query_block = "\n".join(lines) + "\n" + + if path_params: + fmt_args = ", ".join(f"{p}={p}" for p in path_params) + url_line = f' url = self.base_url + "{path}".format({fmt_args})' + else: + url_line = f' url = self.base_url + "{path}"' + + body_block = "" + if body_params: + lines = ["\n body: dict[str, Any] = {"] + for bp in body_params: + lines.append(f' "{bp[1]}": {bp[0]},') + lines.append(" }") + body_block = "\n".join(lines) + + req_extra = "" + if query_params: + req_extra += "\n query=query_params," + if body_params: + req_extra += "\n body=body," + + return f''' + async def {name}( + {sig} + ) -> SimpplrResponse: + """{doc} + + HTTP {method} {path} +{doc_args} + Returns: + SimpplrResponse with operation result + """ +{query_block} +{url_line} +{body_block} + + try: + request = HTTPRequest( + method="{method}", + url=url, + headers={{"Content-Type": "application/json"}},{req_extra} + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return SimpplrResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed {name}" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {{response.status}}" + ) + except Exception as e: + return SimpplrResponse(success=False, error=str(e), message="Failed to execute {name}") +''' + + +def generate_datasource() -> str: + """Generate the full Simpplr DataSource module code.""" + header = '''# ruff: noqa +""" +Simpplr REST API DataSource - Auto-generated API wrapper + +Generated from Simpplr REST API v1 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.simpplr.simpplr import SimpplrClient, SimpplrResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class SimpplrDataSource: + """Simpplr REST API DataSource + + Provides async wrapper methods for Simpplr REST API operations: + - Sites management + - Content management + - Users management + - Pages management + - Events management + - Newsletters management + - Search + - Analytics + + All methods return SimpplrResponse objects. + """ + + def __init__(self, client: SimpplrClient) -> None: + """Initialize with SimpplrClient. + + Args: + client: SimpplrClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'SimpplrDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> SimpplrClient: + """Return the underlying SimpplrClient.""" + return self._client +''' + + methods = [] + current_section = None + for ep in ENDPOINTS: + section = ep.get("section", "") + if section and section != current_section: + current_section = section + methods.append(f"\n # {'-' * 71}\n # {section}\n # {'-' * 71}") + methods.append(_gen_method(ep)) + + return header + "\n".join(methods) + "\n" + + +if __name__ == "__main__": + print(generate_datasource()) diff --git a/backend/python/app/sources/external/simpplr/example.py b/backend/python/app/sources/external/simpplr/example.py new file mode 100644 index 000000000..5eb737467 --- /dev/null +++ b/backend/python/app/sources/external/simpplr/example.py @@ -0,0 +1,179 @@ +# ruff: noqa + +""" +Simpplr API Usage Examples + +This example demonstrates how to use the Simpplr DataSource to interact with +the Simpplr API, covering: +- Authentication (OAuth2, Bearer Token) +- Initializing the Client and DataSource +- Listing Sites, Content, Users, Pages +- Searching content +- Getting analytics + +Prerequisites: +For OAuth2: +1. Register an OAuth app with Simpplr +2. Set SIMPPLR_CLIENT_ID and SIMPPLR_CLIENT_SECRET environment variables + +For Bearer Token: +1. Get your Simpplr API token +2. Set SIMPPLR_TOKEN environment variable +""" + +import asyncio +import json +import os + +from app.sources.client.simpplr.simpplr import ( + SimpplrClient, + SimpplrOAuthConfig, + SimpplrResponse, + SimpplrTokenConfig, +) +from app.sources.external.simpplr.simpplr import SimpplrDataSource +from app.sources.external.utils.oauth import perform_oauth_flow + +# --- Configuration --- +# OAuth2 credentials (highest priority) +CLIENT_ID = os.getenv("SIMPPLR_CLIENT_ID") +CLIENT_SECRET = os.getenv("SIMPPLR_CLIENT_SECRET") + +# Bearer Token (second priority) +TOKEN = os.getenv("SIMPPLR_TOKEN") + +# OAuth redirect URI +REDIRECT_URI = os.getenv("SIMPPLR_REDIRECT_URI", "http://localhost:8080/callback") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: SimpplrResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + for key in ("sites", "content", "users", "pages", "events", + "newsletters", "results"): + if isinstance(data, dict) and key in data: + items = data[key] + print(f" Found {len(items)} {key}.") + if items: + print(f" Sample: {json.dumps(items[0], indent=2)[:400]}...") + return + if isinstance(data, list): + print(f" Found {len(data)} items.") + if data: + print(f" Sample: {json.dumps(data[0], indent=2)[:400]}...") + return + print(f" Data: {json.dumps(data, indent=2)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + # 1. Initialize Client + print_section("Initializing Simpplr Client") + + config = None + + # Priority 1: OAuth2 + if CLIENT_ID and CLIENT_SECRET: + print(" Using OAuth2 authentication") + try: + token_response = perform_oauth_flow( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + auth_endpoint="https://api.simpplr.com/oauth/authorize", + token_endpoint="https://api.simpplr.com/oauth/token", + redirect_uri=REDIRECT_URI, + scopes=[], + scope_delimiter=" ", + auth_method="body", + ) + access_token = token_response.get("access_token") + if not access_token: + raise Exception("No access_token found in OAuth response") + + config = SimpplrOAuthConfig( + access_token=access_token, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + ) + print(" OAuth authentication successful") + except Exception as e: + print(f" OAuth flow failed: {e}") + print(" Falling back to other authentication methods...") + + # Priority 2: Bearer Token + if config is None and TOKEN: + print(" Using Bearer Token authentication") + config = SimpplrTokenConfig(token=TOKEN) + + if config is None: + print(" No valid authentication method found.") + print(" Please set one of the following:") + print(" - SIMPPLR_CLIENT_ID and SIMPPLR_CLIENT_SECRET (for OAuth2)") + print(" - SIMPPLR_TOKEN (for Bearer Token)") + return + + client = SimpplrClient.build_with_config(config) + data_source = SimpplrDataSource(client) + print("Client initialized successfully.") + + try: + # 2. List Sites + print_section("Sites") + sites_resp = await data_source.list_sites(limit=10) + print_result("List Sites", sites_resp) + + # 3. List Content + print_section("Content") + content_resp = await data_source.list_content(limit=10) + print_result("List Content", content_resp) + + # 4. List Users + print_section("Users") + users_resp = await data_source.list_users(limit=10) + print_result("List Users", users_resp) + + # 5. List Pages + print_section("Pages") + pages_resp = await data_source.list_pages(limit=10) + print_result("List Pages", pages_resp) + + # 6. List Events + print_section("Events") + events_resp = await data_source.list_events(limit=10) + print_result("List Events", events_resp) + + # 7. Search + print_section("Search") + search_resp = await data_source.search(q="getting started", limit=10) + print_result("Search", search_resp) + + # 8. Content Analytics + print_section("Content Analytics") + analytics_resp = await data_source.get_content_analytics() + print_result("Content Analytics", analytics_resp) + + finally: + print("\nClosing client connection...") + inner_client = client.get_client() + if hasattr(inner_client, "close"): + await inner_client.close() + + print("\n" + "=" * 80) + print(" All Simpplr API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/simpplr/run_generator.py b/backend/python/app/sources/external/simpplr/run_generator.py new file mode 100644 index 000000000..b25886b23 --- /dev/null +++ b/backend/python/app/sources/external/simpplr/run_generator.py @@ -0,0 +1,24 @@ +"""Runner script to generate the Simpplr DataSource wrapper. + +Execute this script to regenerate simpplr.py from the endpoint definitions +in code_generator.py. + +Usage: + python -m app.sources.external.simpplr.run_generator +""" + +from app.sources.external.simpplr.code_generator import generate_datasource + + +def main() -> None: + """Generate the Simpplr DataSource file.""" + code = generate_datasource() + output_path = __file__.replace("run_generator.py", "simpplr.py") + with open(output_path, "w") as f: + f.write(code) + print(f"Generated Simpplr DataSource -> {output_path}") + print(f" Total characters: {len(code)}") + + +if __name__ == "__main__": + main() diff --git a/backend/python/app/sources/external/simpplr/simpplr.py b/backend/python/app/sources/external/simpplr/simpplr.py new file mode 100644 index 000000000..750477b67 --- /dev/null +++ b/backend/python/app/sources/external/simpplr/simpplr.py @@ -0,0 +1,652 @@ +# ruff: noqa +""" +Simpplr REST API DataSource - Auto-generated API wrapper + +Generated from Simpplr REST API v1 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.simpplr.simpplr import SimpplrClient, SimpplrResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class SimpplrDataSource: + """Simpplr REST API DataSource + + Provides async wrapper methods for Simpplr REST API operations: + - Sites management + - Content management + - Users management + - Pages management + - Events management + - Newsletters management + - Search + - Analytics + + All methods return SimpplrResponse objects. + """ + + def __init__(self, client: SimpplrClient) -> None: + """Initialize with SimpplrClient. + + Args: + client: SimpplrClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'SimpplrDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> SimpplrClient: + """Return the underlying SimpplrClient.""" + return self._client + + # ----------------------------------------------------------------------- + # Sites + # ----------------------------------------------------------------------- + + async def list_sites( + self, + *, + limit: int | None = None, + offset: int | None = None + ) -> SimpplrResponse: + """List all sites + + HTTP GET /sites + + Args: + limit: Maximum number of results + offset: Offset for pagination + + Returns: + SimpplrResponse with operation result + """ + + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/sites" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return SimpplrResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_sites" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return SimpplrResponse(success=False, error=str(e), message="Failed to execute list_sites") + + + async def get_site( + self, + site_id: str + ) -> SimpplrResponse: + """Get a specific site by ID + + HTTP GET /sites/{site_id} + + Args: + site_id: The site id + + Returns: + SimpplrResponse with operation result + """ + + url = self.base_url + "/sites/{site_id}".format(site_id=site_id) + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return SimpplrResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_site" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return SimpplrResponse(success=False, error=str(e), message="Failed to execute get_site") + + + # ----------------------------------------------------------------------- + # Content + # ----------------------------------------------------------------------- + + async def list_content( + self, + *, + limit: int | None = None, + offset: int | None = None + ) -> SimpplrResponse: + """List all content + + HTTP GET /content + + Args: + limit: Maximum number of results + offset: Offset for pagination + + Returns: + SimpplrResponse with operation result + """ + + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/content" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return SimpplrResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_content" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return SimpplrResponse(success=False, error=str(e), message="Failed to execute list_content") + + + async def get_content( + self, + content_id: str + ) -> SimpplrResponse: + """Get a specific content item by ID + + HTTP GET /content/{content_id} + + Args: + content_id: The content id + + Returns: + SimpplrResponse with operation result + """ + + url = self.base_url + "/content/{content_id}".format(content_id=content_id) + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return SimpplrResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_content" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return SimpplrResponse(success=False, error=str(e), message="Failed to execute get_content") + + + # ----------------------------------------------------------------------- + # Users + # ----------------------------------------------------------------------- + + async def list_users( + self, + *, + limit: int | None = None, + offset: int | None = None + ) -> SimpplrResponse: + """List all users + + HTTP GET /users + + Args: + limit: Maximum number of results + offset: Offset for pagination + + Returns: + SimpplrResponse with operation result + """ + + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/users" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return SimpplrResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_users" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return SimpplrResponse(success=False, error=str(e), message="Failed to execute list_users") + + + async def get_user( + self, + user_id: str + ) -> SimpplrResponse: + """Get a specific user by ID + + HTTP GET /users/{user_id} + + Args: + user_id: The user id + + Returns: + SimpplrResponse with operation result + """ + + url = self.base_url + "/users/{user_id}".format(user_id=user_id) + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return SimpplrResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_user" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return SimpplrResponse(success=False, error=str(e), message="Failed to execute get_user") + + + # ----------------------------------------------------------------------- + # Pages + # ----------------------------------------------------------------------- + + async def list_pages( + self, + *, + limit: int | None = None, + offset: int | None = None + ) -> SimpplrResponse: + """List all pages + + HTTP GET /pages + + Args: + limit: Maximum number of results + offset: Offset for pagination + + Returns: + SimpplrResponse with operation result + """ + + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/pages" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return SimpplrResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_pages" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return SimpplrResponse(success=False, error=str(e), message="Failed to execute list_pages") + + + async def get_page( + self, + page_id: str + ) -> SimpplrResponse: + """Get a specific page by ID + + HTTP GET /pages/{page_id} + + Args: + page_id: The page id + + Returns: + SimpplrResponse with operation result + """ + + url = self.base_url + "/pages/{page_id}".format(page_id=page_id) + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return SimpplrResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_page" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return SimpplrResponse(success=False, error=str(e), message="Failed to execute get_page") + + + # ----------------------------------------------------------------------- + # Events + # ----------------------------------------------------------------------- + + async def list_events( + self, + *, + limit: int | None = None, + offset: int | None = None + ) -> SimpplrResponse: + """List all events + + HTTP GET /events + + Args: + limit: Maximum number of results + offset: Offset for pagination + + Returns: + SimpplrResponse with operation result + """ + + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/events" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return SimpplrResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_events" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return SimpplrResponse(success=False, error=str(e), message="Failed to execute list_events") + + + async def get_event( + self, + event_id: str + ) -> SimpplrResponse: + """Get a specific event by ID + + HTTP GET /events/{event_id} + + Args: + event_id: The event id + + Returns: + SimpplrResponse with operation result + """ + + url = self.base_url + "/events/{event_id}".format(event_id=event_id) + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return SimpplrResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_event" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return SimpplrResponse(success=False, error=str(e), message="Failed to execute get_event") + + + # ----------------------------------------------------------------------- + # Newsletters + # ----------------------------------------------------------------------- + + async def list_newsletters( + self, + *, + limit: int | None = None, + offset: int | None = None + ) -> SimpplrResponse: + """List all newsletters + + HTTP GET /newsletters + + Args: + limit: Maximum number of results + offset: Offset for pagination + + Returns: + SimpplrResponse with operation result + """ + + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/newsletters" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return SimpplrResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_newsletters" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return SimpplrResponse(success=False, error=str(e), message="Failed to execute list_newsletters") + + + async def get_newsletter( + self, + newsletter_id: str + ) -> SimpplrResponse: + """Get a specific newsletter by ID + + HTTP GET /newsletters/{newsletter_id} + + Args: + newsletter_id: The newsletter id + + Returns: + SimpplrResponse with operation result + """ + + url = self.base_url + "/newsletters/{newsletter_id}".format(newsletter_id=newsletter_id) + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return SimpplrResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_newsletter" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return SimpplrResponse(success=False, error=str(e), message="Failed to execute get_newsletter") + + + # ----------------------------------------------------------------------- + # Search + # ----------------------------------------------------------------------- + + async def search( + self, + *, + q: str | None = None, + type: str | None = None, + limit: int | None = None, + offset: int | None = None + ) -> SimpplrResponse: + """Search across Simpplr content + + HTTP GET /search + + Args: + q: Search query string + type: Content type filter + limit: Maximum number of results + offset: Offset for pagination + + Returns: + SimpplrResponse with operation result + """ + + query_params: dict[str, Any] = {} + if q is not None: + query_params['q'] = str(q) + if type is not None: + query_params['type'] = str(type) + if limit is not None: + query_params['limit'] = str(limit) + if offset is not None: + query_params['offset'] = str(offset) + + url = self.base_url + "/search" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return SimpplrResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed search" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return SimpplrResponse(success=False, error=str(e), message="Failed to execute search") + + + # ----------------------------------------------------------------------- + # Analytics + # ----------------------------------------------------------------------- + + async def get_content_analytics( + self + ) -> SimpplrResponse: + """Get content analytics + + HTTP GET /analytics/content + + Returns: + SimpplrResponse with operation result + """ + + url = self.base_url + "/analytics/content" + + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return SimpplrResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_content_analytics" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return SimpplrResponse(success=False, error=str(e), message="Failed to execute get_content_analytics") + diff --git a/backend/python/app/sources/external/slab/example.py b/backend/python/app/sources/external/slab/example.py new file mode 100644 index 000000000..c20b52829 --- /dev/null +++ b/backend/python/app/sources/external/slab/example.py @@ -0,0 +1,154 @@ +# ruff: noqa +""" +Slab API Usage Examples + +This example demonstrates how to use the Slab DataSource to interact with +the Slab GraphQL API, covering: +- Authentication (API Token) +- Initializing the Client and DataSource +- Fetching Organization Info +- Listing Users +- Listing and Getting Posts +- Listing and Getting Topics +- Searching Posts + +Prerequisites: +1. Generate a Slab API token at your organization's Slab settings +2. Set SLAB_API_TOKEN environment variable + +API Reference: https://slab.com/api/ +""" + +import asyncio +import json +import os + +from app.sources.client.slab.slab import SlabClient, SlabTokenConfig +from app.sources.external.slab.slab import SlabDataSource + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +async def main() -> None: + """Example usage of Slab API.""" + SLAB_API_TOKEN = os.getenv("SLAB_API_TOKEN") + + if not SLAB_API_TOKEN: + print("Please set SLAB_API_TOKEN environment variable") + print(" Get your token from your Slab organization settings") + return + + # Initialize Slab client and data source + client = SlabClient.build_with_config(SlabTokenConfig(token=SLAB_API_TOKEN)) + data_source = SlabDataSource(client) + + try: + # 1. Validate connection - Get organization info + print_section("Organization Info") + org_response = await data_source.organization() + if not org_response.success: + print(f"Failed to connect to Slab API: {org_response.message}") + if org_response.errors: + for error in org_response.errors: + print(f" Error: {error.message}") + return + + org_data = org_response.data.get("organization", {}) if org_response.data else {} + if org_data: + print(f" Connected successfully!") + print(f" Organization: {org_data.get('name', 'Unknown')} (ID: {org_data.get('id', 'Unknown')})") + print(f" Hostname: {org_data.get('hostname', 'Unknown')}") + else: + print(" Connection successful but no organization data returned") + + # 2. List users + print_section("Users") + users_response = await data_source.users() + if users_response.success: + org = users_response.data.get("organization", {}) if users_response.data else {} + members = org.get("members", []) if org else [] + print(f" Found {len(members)} users:") + for user in members[:5]: + status = "active" if not user.get("deactivatedAt") else "deactivated" + print(f" - {user.get('name')} ({user.get('email')}) [{status}]") + else: + print(f" Failed to get users: {users_response.message}") + + # 3. List topics + print_section("Topics") + topics_response = await data_source.topics() + if topics_response.success: + topics = topics_response.data.get("topics", []) if topics_response.data else [] + print(f" Found {len(topics)} topics:") + for topic in topics[:5]: + print(f" - {topic.get('name')} (ID: {topic.get('id')}, Posts: {topic.get('postCount', 0)})") + + # Get details of first topic + if topics: + first_topic_id = topics[0].get("id") + print_section(f"Topic Details: {topics[0].get('name')}") + topic_response = await data_source.topic(id=first_topic_id) + if topic_response.success: + topic_data = topic_response.data.get("topic", {}) if topic_response.data else {} + posts = topic_data.get("posts", []) + print(f" Posts in topic: {len(posts)}") + for post in posts[:3]: + print(f" - {post.get('title')} (ID: {post.get('id')})") + else: + print(f" Failed to get topic: {topic_response.message}") + else: + print(f" Failed to get topics: {topics_response.message}") + + # 4. List published posts + print_section("Published Posts") + posts_response = await data_source.posts(status="PUBLISHED") + if posts_response.success: + posts = posts_response.data.get("posts", []) if posts_response.data else [] + print(f" Found {len(posts)} published posts:") + for post in posts[:5]: + creator = post.get("creator", {}) + creator_name = creator.get("name", "Unknown") if creator else "Unknown" + print(f" - {post.get('title')} (by {creator_name})") + + # Get details of first post + if posts: + first_post_id = posts[0].get("id") + print_section(f"Post Details: {posts[0].get('title')}") + post_response = await data_source.post(id=first_post_id) + if post_response.success: + post_data = post_response.data.get("post", {}) if post_response.data else {} + topics = post_data.get("topics", []) + print(f" Topics: {', '.join(t.get('name', '') for t in topics) if topics else 'None'}") + print(f" Published: {post_data.get('publishedAt', 'N/A')}") + print(f" Updated: {post_data.get('updatedAt', 'N/A')}") + else: + print(f" Failed to get post: {post_response.message}") + else: + print(f" Failed to get posts: {posts_response.message}") + + # 5. Search posts + print_section("Search Posts") + search_response = await data_source.search_posts(query="getting started") + if search_response.success: + results = search_response.data.get("searchPosts", []) if search_response.data else [] + print(f" Found {len(results)} results for 'getting started':") + for result in results[:5]: + print(f" - {result.get('title')} (ID: {result.get('id')})") + else: + print(f" Search failed: {search_response.message}") + + finally: + # Close the client + await client.get_client().close() + + print("\n" + "=" * 80) + print(" All Slab API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/slab/slab.py b/backend/python/app/sources/external/slab/slab.py new file mode 100644 index 000000000..4c33d11a1 --- /dev/null +++ b/backend/python/app/sources/external/slab/slab.py @@ -0,0 +1,192 @@ +from typing import Any + +from app.sources.client.graphql.response import GraphQLResponse +from app.sources.client.slab.graphql_op import SlabGraphQLOperations +from app.sources.client.slab.slab import ( + SlabClient, +) + + +class SlabDataSource: + """ + Slab GraphQL API client wrapper + Auto-generated wrapper for Slab GraphQL operations. + This class provides unified access to all Slab GraphQL operations while + maintaining proper typing and error handling. + + Coverage: + - Organization info + - Users listing + - Posts (list, get, search) + - Topics (list, get) + - Mutations (syncPost) + """ + + def __init__(self, slab_client: SlabClient) -> None: + """ + Initialize the Slab GraphQL data source. + Args: + slab_client (SlabClient): Slab client instance + """ + self._slab_client = slab_client + + # ============================================================================= + # QUERY OPERATIONS + # ============================================================================= + + async def organization(self) -> GraphQLResponse: + """Get organization information""" + query = SlabGraphQLOperations.get_operation_with_fragments("query", "organization") + variables: dict[str, Any] = {} + + try: + return await self._slab_client.get_client().execute( + query=query, variables=variables, operation_name="organization" + ) + except Exception as e: + return GraphQLResponse( + success=False, + message=f"Failed to execute query organization: {str(e)}", + ) + + async def users(self) -> GraphQLResponse: + """List all users in the organization""" + query = SlabGraphQLOperations.get_operation_with_fragments("query", "users") + variables: dict[str, Any] = {} + + try: + return await self._slab_client.get_client().execute( + query=query, variables=variables, operation_name="users" + ) + except Exception as e: + return GraphQLResponse( + success=False, + message=f"Failed to execute query users: {str(e)}", + ) + + async def posts( + self, + status: str | None = None, + ) -> GraphQLResponse: + """List posts with optional status filter + + Args: + status: Post status filter (e.g. PUBLISHED) + """ + query = SlabGraphQLOperations.get_operation_with_fragments("query", "posts") + variables: dict[str, Any] = {} + if status is not None: + variables["status"] = status + + try: + return await self._slab_client.get_client().execute( + query=query, variables=variables, operation_name="posts" + ) + except Exception as e: + return GraphQLResponse( + success=False, + message=f"Failed to execute query posts: {str(e)}", + ) + + async def post(self, post_id: str) -> GraphQLResponse: + """Get a single post by ID + + Args: + post_id: Post ID + """ + query = SlabGraphQLOperations.get_operation_with_fragments("query", "post") + variables: dict[str, Any] = {"id": post_id} + + try: + return await self._slab_client.get_client().execute( + query=query, variables=variables, operation_name="post" + ) + except Exception as e: + return GraphQLResponse( + success=False, + message=f"Failed to execute query post: {str(e)}", + ) + + async def topics(self) -> GraphQLResponse: + """List all topics""" + query = SlabGraphQLOperations.get_operation_with_fragments("query", "topics") + variables: dict[str, Any] = {} + + try: + return await self._slab_client.get_client().execute( + query=query, variables=variables, operation_name="topics" + ) + except Exception as e: + return GraphQLResponse( + success=False, + message=f"Failed to execute query topics: {str(e)}", + ) + + async def topic(self, topic_id: str) -> GraphQLResponse: + """Get a single topic by ID with its posts + + Args: + topic_id: Topic ID + """ + query = SlabGraphQLOperations.get_operation_with_fragments("query", "topic") + variables: dict[str, Any] = {"id": topic_id} + + try: + return await self._slab_client.get_client().execute( + query=query, variables=variables, operation_name="topic" + ) + except Exception as e: + return GraphQLResponse( + success=False, + message=f"Failed to execute query topic: {str(e)}", + ) + + async def search_posts(self, query: str) -> GraphQLResponse: + """Search posts by query string + + Args: + query: Search query string + """ + graphql_query = SlabGraphQLOperations.get_operation_with_fragments( + "query", "searchPosts" + ) + variables: dict[str, Any] = {"query": query} + + try: + return await self._slab_client.get_client().execute( + query=graphql_query, + variables=variables, + operation_name="searchPosts", + ) + except Exception as e: + return GraphQLResponse( + success=False, + message=f"Failed to execute query searchPosts: {str(e)}", + ) + + # ============================================================================= + # MUTATION OPERATIONS + # ============================================================================= + + async def sync_post(self, sync_input: dict[str, Any]) -> GraphQLResponse: + """Create or update a post via sync + + Args: + sync_input: Sync post input object + """ + graphql_query = SlabGraphQLOperations.get_operation_with_fragments( + "mutation", "syncPost" + ) + variables: dict[str, Any] = {"input": sync_input} + + try: + return await self._slab_client.get_client().execute( + query=graphql_query, + variables=variables, + operation_name="syncPost", + ) + except Exception as e: + return GraphQLResponse( + success=False, + message=f"Failed to execute mutation syncPost: {str(e)}", + ) diff --git a/backend/python/app/sources/external/smartsheet/example.py b/backend/python/app/sources/external/smartsheet/example.py new file mode 100644 index 000000000..e95b0d071 --- /dev/null +++ b/backend/python/app/sources/external/smartsheet/example.py @@ -0,0 +1,217 @@ +# ruff: noqa + +""" +Smartsheet SDK Usage Examples + +This example demonstrates how to use the Smartsheet DataSource (backed by the +official smartsheet-python-sdk) to interact with the Smartsheet API, covering: +- Authentication (OAuth2 or API Access Token) +- Initializing the Client and DataSource +- Getting Current User +- Listing Sheets +- Getting Home +- Searching +- Listing Workspaces + +Prerequisites: +For OAuth2: +1. Create a Smartsheet Developer App at https://app.smartsheet.com/b/home +2. Set SMARTSHEET_CLIENT_ID and SMARTSHEET_CLIENT_SECRET environment variables +3. The OAuth flow will automatically open a browser for authorization + +For API Access Token: +1. Log in to Smartsheet +2. Go to Account > Apps & Integrations > API Access > Generate new access token +3. Set SMARTSHEET_ACCESS_TOKEN environment variable +""" + +import json +import os + +from app.sources.client.smartsheet.smartsheet import ( + SmartsheetClient, + SmartsheetOAuthConfig, + SmartsheetResponse, + SmartsheetTokenConfig, +) +from app.sources.external.smartsheet.smartsheet import SmartsheetDataSource +from app.sources.external.utils.oauth import perform_oauth_flow + +# --- Configuration --- +# OAuth2 credentials (highest priority) +CLIENT_ID = os.getenv("SMARTSHEET_CLIENT_ID") +CLIENT_SECRET = os.getenv("SMARTSHEET_CLIENT_SECRET") + +# API Access Token (second priority) +ACCESS_TOKEN = os.getenv("SMARTSHEET_ACCESS_TOKEN") + +# OAuth redirect URI +REDIRECT_URI = os.getenv("SMARTSHEET_REDIRECT_URI", "http://localhost:8080/callback") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: SmartsheetResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + # SDK returns model objects; try to convert to dict for display + if hasattr(data, "to_dict"): + data = data.to_dict() + if isinstance(data, dict): + for key in ("data", "sheets", "workspaces", "folders", "reports", + "results", "users", "columns", "rows", "discussions", + "attachments"): + if key in data: + items = data[key] + if isinstance(items, list): + print(f" Found {len(items)} {key}.") + if items: + item = items[0] + if hasattr(item, "to_dict"): + item = item.to_dict() + print(f" Sample: {json.dumps(item, indent=2, default=str)[:400]}...") + else: + print(f" {key}: {json.dumps(items, indent=2, default=str)[:400]}...") + return + print(f" Data: {str(data)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +def main() -> None: + # 1. Initialize Client + print_section("Initializing Smartsheet Client") + + config = None + + # Priority 1: OAuth2 + if CLIENT_ID and CLIENT_SECRET: + print(" Using OAuth2 authentication") + try: + print("Starting OAuth flow...") + # Smartsheet OAuth authorization URL: https://app.smartsheet.com/b/authorize + # Smartsheet token endpoint: https://api.smartsheet.com/2.0/token + token_response = perform_oauth_flow( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + auth_endpoint="https://app.smartsheet.com/b/authorize", + token_endpoint="https://api.smartsheet.com/2.0/token", + redirect_uri=REDIRECT_URI, + scopes=["READ_SHEETS", "READ_USERS"], + scope_delimiter=" ", + auth_method="header", + ) + + access_token = token_response.get("access_token") + if not access_token: + raise Exception("No access_token found in OAuth response") + + config = SmartsheetOAuthConfig( + access_token=access_token, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + ) + print(" OAuth authentication successful") + except Exception as e: + print(f" OAuth flow failed: {e}") + print(" Falling back to other authentication methods...") + + # Priority 2: API Access Token + if config is None and ACCESS_TOKEN: + print(" Using API Access Token authentication") + config = SmartsheetTokenConfig( + token=ACCESS_TOKEN, + ) + + if config is None: + print(" No valid authentication method found.") + print(" Please set one of the following:") + print(" - SMARTSHEET_CLIENT_ID and SMARTSHEET_CLIENT_SECRET (for OAuth2)") + print(" - SMARTSHEET_ACCESS_TOKEN (for API Access Token)") + return + + client = SmartsheetClient.build_with_config(config) + data_source = SmartsheetDataSource(client) + print("Client initialized successfully.") + + # 2. Get Current User + print_section("Current User") + user_resp = data_source.get_current_user() + print_result("Get Current User", user_resp) + + # 3. List Sheets + print_section("Sheets") + sheets_resp = data_source.list_sheets(page_size=10) + print_result("List Sheets", sheets_resp) + + # 4. Get Home + print_section("Home") + home_resp = data_source.get_home() + print_result("Get Home", home_resp) + + # 5. Search + print_section("Search") + search_resp = data_source.search(query="test") + print_result("Search", search_resp) + + # 6. List Workspaces + print_section("Workspaces") + workspaces_resp = data_source.list_workspaces() + print_result("List Workspaces", workspaces_resp) + + # 7. List Reports + print_section("Reports") + reports_resp = data_source.list_reports() + print_result("List Reports", reports_resp) + + # 8. List Folders + print_section("Folders") + folders_resp = data_source.list_folders() + print_result("List Folders", folders_resp) + + # 9. Get a specific sheet if available + if sheets_resp.success and sheets_resp.data: + sheets_data = sheets_resp.data + sheets_list = [] + if hasattr(sheets_data, "data"): + sheets_list = sheets_data.data or [] + elif hasattr(sheets_data, "to_dict"): + d = sheets_data.to_dict() + if isinstance(d, dict): + sheets_list = d.get("data", []) + + if sheets_list and isinstance(sheets_list, list) and len(sheets_list) > 0: + first_sheet = sheets_list[0] + sheet_id = getattr(first_sheet, "id", None) + if sheet_id is None and isinstance(first_sheet, dict): + sheet_id = first_sheet.get("id") + sheet_name = getattr(first_sheet, "name", "N/A") + if sheet_name == "N/A" and isinstance(first_sheet, dict): + sheet_name = first_sheet.get("name", "N/A") + + if sheet_id: + print_section(f"Sheet Details: {sheet_name}") + sheet_resp = data_source.get_sheet(sheet_id=int(sheet_id)) + print_result("Get Sheet", sheet_resp) + + # 10. List Columns in Sheet + print_section("Sheet Columns") + columns_resp = data_source.list_columns(sheet_id=int(sheet_id)) + print_result("List Columns", columns_resp) + + print("\n" + "=" * 80) + print(" All Smartsheet API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + main() diff --git a/backend/python/app/sources/external/smartsheet/smartsheet.py b/backend/python/app/sources/external/smartsheet/smartsheet.py new file mode 100644 index 000000000..6664c9eb2 --- /dev/null +++ b/backend/python/app/sources/external/smartsheet/smartsheet.py @@ -0,0 +1,151 @@ +# ruff: noqa +from __future__ import annotations + +import smartsheet # type: ignore[reportMissingTypeStubs] +from typing import Any, Union, cast + +from app.sources.client.smartsheet.smartsheet import SmartsheetResponse + +class SmartsheetDataSource: + """ + Strict, typed wrapper over smartsheet-python-sdk for common Smartsheet business operations. + + Accepts either a smartsheet `Smartsheet` instance *or* any object with `.get_sdk() -> smartsheet.Smartsheet`. + """ + + def __init__(self, client_or_sdk: Union[object, "smartsheet.Smartsheet"]) -> None: # type: ignore[reportUnknownMemberType] + super().__init__() + # Support a raw SDK or a wrapper that exposes `.get_sdk()` + if hasattr(client_or_sdk, "get_sdk"): # type: ignore[reportUnknownArgumentType] + sdk_obj = getattr(client_or_sdk, "get_sdk")() # type: ignore[reportUnknownArgumentType] + self._sdk: smartsheet.Smartsheet = cast("smartsheet.Smartsheet", sdk_obj) # type: ignore[reportUnknownMemberType] + else: + self._sdk = cast("smartsheet.Smartsheet", client_or_sdk) # type: ignore[reportUnknownMemberType] + + # ---- helpers ---- + @staticmethod + def _params(**kwargs: object) -> dict[str, object]: + # Filter out Nones to avoid overriding SDK defaults + out: dict[str, object] = {} + for k, v in kwargs.items(): + if v is None: + continue + # Skip empty containers that Smartsheet rejects in some endpoints + if isinstance(v, (list, dict)) and len(v) == 0: # type: ignore[reportUnknownArgumentType] + continue + out[k] = v + return out + def get_current_user(self) -> SmartsheetResponse: + """Get the current authenticated user. [users]""" + result: Any = self._sdk.Users.get_current_user() # type: ignore[reportUnknownMemberType] + return SmartsheetResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def list_users(self, *, include_all: bool = True) -> SmartsheetResponse: + """List all users in the organization. [users]""" + result: Any = self._sdk.Users.list_users(include_all=include_all) # type: ignore[reportUnknownMemberType] + return SmartsheetResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def list_sheets(self, *, page_size: int = 100, page: int = 1, include_all: bool = False, modified_since: Union[str, None] = None) -> SmartsheetResponse: + """List all sheets the user has access to. [sheets]""" + params = self._params(page_size=page_size, page=page, include_all=include_all, modified_since=modified_since) + result: Any = self._sdk.Sheets.list_sheets(**params) # type: ignore[reportUnknownMemberType] + return SmartsheetResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def get_sheet(self, sheet_id: int, *, page_size: int = 100, page: int = 1) -> SmartsheetResponse: + """Get a specific sheet by ID. [sheets]""" + result: Any = self._sdk.Sheets.get_sheet(sheet_id, page_size=page_size, page=page) # type: ignore[reportUnknownMemberType] + return SmartsheetResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def create_sheet(self, sheet_obj: object) -> SmartsheetResponse: + """Create a new sheet at Home level. Pass a smartsheet.models.Sheet object. [sheets]""" + result: Any = self._sdk.Home.create_sheet(sheet_obj) # type: ignore[reportUnknownMemberType] + return SmartsheetResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def create_sheet_in_folder(self, folder_id: int, sheet_obj: object) -> SmartsheetResponse: + """Create a new sheet in a specific folder. [sheets]""" + result: Any = self._sdk.Folders.create_sheet_in_folder(folder_id, sheet_obj) # type: ignore[reportUnknownMemberType] + return SmartsheetResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def create_sheet_in_workspace(self, workspace_id: int, sheet_obj: object) -> SmartsheetResponse: + """Create a new sheet in a specific workspace. [sheets]""" + result: Any = self._sdk.Workspaces.create_sheet_in_workspace(workspace_id, sheet_obj) # type: ignore[reportUnknownMemberType] + return SmartsheetResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def update_sheet(self, sheet_id: int, sheet_obj: object) -> SmartsheetResponse: + """Update a sheet (e.g. rename). Pass a smartsheet.models.Sheet object. [sheets]""" + result: Any = self._sdk.Sheets.update_sheet(sheet_id, sheet_obj) # type: ignore[reportUnknownMemberType] + return SmartsheetResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def delete_sheet(self, sheet_id: int) -> SmartsheetResponse: + """Delete a sheet by ID. [sheets]""" + result: Any = self._sdk.Sheets.delete_sheet(sheet_id) # type: ignore[reportUnknownMemberType] + return SmartsheetResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def add_rows(self, sheet_id: int, row_objects: list[object]) -> SmartsheetResponse: + """Add rows to a sheet. Pass a list of smartsheet.models.Row objects. [rows]""" + result: Any = self._sdk.Sheets.add_rows(sheet_id, row_objects) # type: ignore[reportUnknownMemberType] + return SmartsheetResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def update_rows(self, sheet_id: int, row_objects: list[object]) -> SmartsheetResponse: + """Update rows in a sheet. Pass a list of smartsheet.models.Row objects. [rows]""" + result: Any = self._sdk.Sheets.update_rows(sheet_id, row_objects) # type: ignore[reportUnknownMemberType] + return SmartsheetResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def delete_rows(self, sheet_id: int, row_ids: list[int]) -> SmartsheetResponse: + """Delete rows from a sheet by row IDs. [rows]""" + result: Any = self._sdk.Sheets.delete_rows(sheet_id, row_ids) # type: ignore[reportUnknownMemberType] + return SmartsheetResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def list_columns(self, sheet_id: int, *, include_all: bool = True) -> SmartsheetResponse: + """List all columns in a sheet. [columns]""" + result: Any = self._sdk.Sheets.get_columns(sheet_id, include_all=include_all) # type: ignore[reportUnknownMemberType] + return SmartsheetResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def get_column(self, sheet_id: int, column_id: int) -> SmartsheetResponse: + """Get a specific column in a sheet. [columns]""" + result: Any = self._sdk.Sheets.get_column(sheet_id, column_id) # type: ignore[reportUnknownMemberType] + return SmartsheetResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def add_columns(self, sheet_id: int, column_objects: list[object]) -> SmartsheetResponse: + """Add columns to a sheet. Pass a list of smartsheet.models.Column objects. [columns]""" + result: Any = self._sdk.Sheets.add_columns(sheet_id, column_objects) # type: ignore[reportUnknownMemberType] + return SmartsheetResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def update_column(self, sheet_id: int, column_id: int, column_obj: object) -> SmartsheetResponse: + """Update a column in a sheet. Pass a smartsheet.models.Column object. [columns]""" + result: Any = self._sdk.Sheets.update_column(sheet_id, column_id, column_obj) # type: ignore[reportUnknownMemberType] + return SmartsheetResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def list_workspaces(self) -> SmartsheetResponse: + """List all workspaces. [workspaces]""" + result: Any = self._sdk.Workspaces.list_workspaces() # type: ignore[reportUnknownMemberType] + return SmartsheetResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def get_workspace(self, workspace_id: int) -> SmartsheetResponse: + """Get a specific workspace by ID. [workspaces]""" + result: Any = self._sdk.Workspaces.get_workspace(workspace_id) # type: ignore[reportUnknownMemberType] + return SmartsheetResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def list_folders(self, *, include_all: bool = True) -> SmartsheetResponse: + """List all top-level folders in the user's Home. [folders]""" + result: Any = self._sdk.Home.list_folders(include_all=include_all) # type: ignore[reportUnknownMemberType] + return SmartsheetResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def get_folder(self, folder_id: int) -> SmartsheetResponse: + """Get a specific folder by ID. [folders]""" + result: Any = self._sdk.Folders.get_folder(folder_id) # type: ignore[reportUnknownMemberType] + return SmartsheetResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def list_workspace_folders(self, workspace_id: int) -> SmartsheetResponse: + """List all folders in a workspace. [folders]""" + result: Any = self._sdk.Workspaces.list_folders(workspace_id) # type: ignore[reportUnknownMemberType] + return SmartsheetResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def list_reports(self, *, page_size: int = 100, page: int = 1, modified_since: Union[str, None] = None) -> SmartsheetResponse: + """List all reports the user has access to. [reports]""" + params = self._params(page_size=page_size, page=page, modified_since=modified_since) + result: Any = self._sdk.Reports.list_reports(**params) # type: ignore[reportUnknownMemberType] + return SmartsheetResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def get_report(self, report_id: int, *, page_size: int = 100, page: int = 1) -> SmartsheetResponse: + """Get a specific report by ID. [reports]""" + result: Any = self._sdk.Reports.get_report(report_id, page_size=page_size, page=page) # type: ignore[reportUnknownMemberType] + return SmartsheetResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def search(self, query: str) -> SmartsheetResponse: + """Search for sheets, reports, rows, etc. [search]""" + result: Any = self._sdk.Search.search(query) # type: ignore[reportUnknownMemberType] + return SmartsheetResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def search_sheet(self, sheet_id: int, query: str) -> SmartsheetResponse: + """Search within a specific sheet. [search]""" + result: Any = self._sdk.Search.search_sheet(sheet_id, query) # type: ignore[reportUnknownMemberType] + return SmartsheetResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def get_home(self) -> SmartsheetResponse: + """Get the user's Home (top-level sheets, folders, workspaces, etc.). [home]""" + result: Any = self._sdk.Home.list_all_contents() # type: ignore[reportUnknownMemberType] + return SmartsheetResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def list_sheet_discussions(self, sheet_id: int, *, include_all: bool = True) -> SmartsheetResponse: + """List all discussions on a sheet. [discussions]""" + result: Any = self._sdk.Discussions.get_all_discussions(sheet_id, include_all=include_all) # type: ignore[reportUnknownMemberType] + return SmartsheetResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] + def list_sheet_attachments(self, sheet_id: int, *, include_all: bool = True) -> SmartsheetResponse: + """List all attachments on a sheet. [attachments]""" + result: Any = self._sdk.Attachments.list_all_attachments(sheet_id, include_all=include_all) # type: ignore[reportUnknownMemberType] + return SmartsheetResponse(success=True, data=result) # type: ignore[reportUnknownArgumentType] diff --git a/backend/python/app/sources/external/splunk/example.py b/backend/python/app/sources/external/splunk/example.py new file mode 100644 index 000000000..300ca4d07 --- /dev/null +++ b/backend/python/app/sources/external/splunk/example.py @@ -0,0 +1,136 @@ +# ruff: noqa +from __future__ import annotations + +import os + +from dotenv import load_dotenv + +from app.sources.client.splunk.splunk import ( + SplunkClient, + SplunkCredentialsConfig, + SplunkResponse, + SplunkTokenConfig, +) +from app.sources.external.splunk.splunk_ import SplunkDataSource + + +def _print_status(title: str, res: SplunkResponse) -> None: + print(f"\n== {title} ==") + if not res.success: + print("error:", res.error or res.message) + else: + print("ok") + + +def main() -> None: + # Load .env if present + load_dotenv() + + # Minimal envs + host = os.getenv("SPLUNK_HOST", "localhost") + port = int(os.getenv("SPLUNK_PORT", "8089")) + auth_type = os.getenv("SPLUNK_AUTH_TYPE", "CREDENTIALS") # CREDENTIALS or BEARER_TOKEN + + if auth_type == "BEARER_TOKEN": + token = os.getenv("SPLUNK_TOKEN", "") + if not token: + raise RuntimeError("SPLUNK_TOKEN is required for BEARER_TOKEN auth") + client = SplunkClient.build_with_config( + SplunkTokenConfig( + host=host, + port=port, + token=token, + ) + ) + else: + username = os.getenv("SPLUNK_USERNAME", "admin") + password = os.getenv("SPLUNK_PASSWORD", "") + if not password: + raise RuntimeError("SPLUNK_PASSWORD is required for CREDENTIALS auth") + client = SplunkClient.build_with_config( + SplunkCredentialsConfig( + host=host, + port=port, + username=username, + password=password, + ) + ) + + ds = SplunkDataSource(client) + + # 1) Server info + info_res: SplunkResponse = ds.get_server_info() + _print_status("Server Info", info_res) + if info_res.success and info_res.data: + print("server_name:", getattr(info_res.data, "server_name", "unknown")) + + # 2) List apps + apps_res: SplunkResponse = ds.list_apps() + _print_status("List Apps", apps_res) + if apps_res.success and apps_res.data: + names = [getattr(a, "name", str(a)) for a in apps_res.data[:10]] + print("apps:", names) + + # 3) List indexes + indexes_res: SplunkResponse = ds.list_indexes() + _print_status("List Indexes", indexes_res) + if indexes_res.success and indexes_res.data: + names = [getattr(i, "name", str(i)) for i in indexes_res.data[:10]] + print("indexes:", names) + + # 4) List saved searches + try: + ss_res: SplunkResponse = ds.list_saved_searches() + _print_status("List Saved Searches", ss_res) + if ss_res.success and ss_res.data: + names = [getattr(s, "name", str(s)) for s in ss_res.data[:10]] + print("saved_searches:", names) + except Exception as e: + print(f"List saved searches failed: {e}") + + # 5) List users + try: + users_res: SplunkResponse = ds.list_users() + _print_status("List Users", users_res) + if users_res.success and users_res.data: + names = [getattr(u, "name", str(u)) for u in users_res.data[:10]] + print("users:", names) + except Exception as e: + print(f"List users failed: {e}") + + # 6) List jobs + try: + jobs_res: SplunkResponse = ds.list_jobs() + _print_status("List Jobs", jobs_res) + if jobs_res.success and jobs_res.data: + sids = [getattr(j, "sid", str(j)) for j in jobs_res.data[:10]] + print("jobs:", sids) + except Exception as e: + print(f"List jobs failed: {e}") + + # 7) Run a simple search (if an index exists) + try: + search_res: SplunkResponse = ds.search( + "search index=_internal | head 5", + earliest_time="-1h", + latest_time="now", + ) + _print_status("Search", search_res) + if search_res.success and search_res.data: + print(f"results: {len(search_res.data)} events") + except Exception as e: + print(f"Search failed: {e}") + + # 8) List inputs + try: + inputs_res: SplunkResponse = ds.list_inputs() + _print_status("List Inputs", inputs_res) + if inputs_res.success and inputs_res.data: + names = [getattr(i, "name", str(i)) for i in inputs_res.data[:10]] + print("inputs:", names) + except Exception as e: + print(f"List inputs failed: {e}") + + +if __name__ == "__main__": + main() diff --git a/backend/python/app/sources/external/splunk/splunk_.py b/backend/python/app/sources/external/splunk/splunk_.py new file mode 100644 index 000000000..43ef7566c --- /dev/null +++ b/backend/python/app/sources/external/splunk/splunk_.py @@ -0,0 +1,92 @@ +# ruff: noqa +from __future__ import annotations + +import splunklib.client as splunk_client # type: ignore[import-untyped] +import splunklib.results as splunk_results # type: ignore[import-untyped] +from typing import Dict, List, Optional, Union, cast + +from app.sources.client.splunk.splunk import SplunkResponse + +class SplunkDataSource: + """ + Strict, typed wrapper over splunk-sdk for common Splunk operations. + + Accepts either a splunklib `Service` instance *or* any object with `.get_sdk() -> Service`. + """ + + def __init__(self, client_or_sdk: Union[splunk_client.Service, object]) -> None: + super().__init__() + # Support a raw SDK or a wrapper that exposes `.get_sdk()` + if hasattr(client_or_sdk, "get_sdk"): + sdk_obj = getattr(client_or_sdk, "get_sdk")() + self._sdk: splunk_client.Service = cast(splunk_client.Service, sdk_obj) + else: + self._sdk = cast(splunk_client.Service, client_or_sdk) + + # ---- helpers ---- + @staticmethod + def _params(**kwargs: object) -> Dict[str, object]: + # Filter out Nones to avoid overriding SDK defaults + out: Dict[str, object] = {} + for k, v in kwargs.items(): + if v is None: + continue + if isinstance(v, (list, dict)) and len(v) == 0: + continue + out[k] = v + return out + def get_server_info(self) -> SplunkResponse: + """Get Splunk server information.""" + info = self._sdk.info + return SplunkResponse(success=True, data=info) + def search(self, query: str, earliest_time: Optional[str] = None, latest_time: Optional[str] = None, max_count: Optional[int] = None, exec_mode: Optional[str] = None) -> SplunkResponse: + """Run a search query and return results.""" + params = self._params(earliest_time=earliest_time, latest_time=latest_time, max_count=max_count, exec_mode=exec_mode) + job = self._sdk.jobs.create(query, **params) + while not job.is_done(): + import time + time.sleep(0.5) + rr = splunk_results.JSONResultsReader(job.results(output_mode='json')) + results = [result for result in rr if isinstance(result, dict)] + return SplunkResponse(success=True, data=results) + def list_saved_searches(self) -> SplunkResponse: + """List all saved searches.""" + items = list(self._sdk.saved_searches) + return SplunkResponse(success=True, data=items) + def get_saved_search(self, name: str) -> SplunkResponse: + """Get a saved search by name.""" + ss = self._sdk.saved_searches[name] + return SplunkResponse(success=True, data=ss) + def list_indexes(self) -> SplunkResponse: + """List all indexes.""" + items = list(self._sdk.indexes) + return SplunkResponse(success=True, data=items) + def get_index(self, name: str) -> SplunkResponse: + """Get an index by name.""" + idx = self._sdk.indexes[name] + return SplunkResponse(success=True, data=idx) + def list_apps(self) -> SplunkResponse: + """List all installed apps.""" + items = list(self._sdk.apps) + return SplunkResponse(success=True, data=items) + def get_app(self, name: str) -> SplunkResponse: + """Get an app by name.""" + app = self._sdk.apps[name] + return SplunkResponse(success=True, data=app) + def list_users(self) -> SplunkResponse: + """List all users.""" + items = list(self._sdk.users) + return SplunkResponse(success=True, data=items) + def list_jobs(self) -> SplunkResponse: + """List all search jobs.""" + items = list(self._sdk.jobs) + return SplunkResponse(success=True, data=items) + def get_job(self, sid: str) -> SplunkResponse: + """Get a search job by SID.""" + job = self._sdk.jobs[sid] + return SplunkResponse(success=True, data=job) + def list_inputs(self) -> SplunkResponse: + """List all data inputs.""" + items = list(self._sdk.inputs) + return SplunkResponse(success=True, data=items) + diff --git a/backend/python/app/sources/external/tableau/example.py b/backend/python/app/sources/external/tableau/example.py new file mode 100644 index 000000000..0c798986d --- /dev/null +++ b/backend/python/app/sources/external/tableau/example.py @@ -0,0 +1,144 @@ +# ruff: noqa + +""" +Tableau SDK Usage Examples + +This example demonstrates how to use the Tableau DataSource with the +official tableauserverclient SDK: +- Authentication via Personal Access Token (PAT) +- Initializing the Client and DataSource +- Listing Workbooks, Views, Data Sources +- Listing Projects, Users, Groups + +Prerequisites: +1. Create a Personal Access Token in Tableau: + - Go to My Account Settings > Personal Access Tokens + - Create a new token and note the token name and secret +2. Set the following environment variables: + - TABLEAU_SERVER_URL: Your Tableau Server/Cloud URL (e.g., "https://10ax.online.tableau.com") + - TABLEAU_TOKEN_NAME: Personal Access Token name + - TABLEAU_TOKEN_SECRET: Personal Access Token secret + - TABLEAU_SITE_ID: Site content URL (empty string for default site) +""" + +import json +import os + +from app.sources.client.tableau.tableau import ( + TableauClient, + TableauPATConfig, + TableauResponse, +) +from app.sources.external.tableau.tableau import TableauDataSource + +# --- Configuration --- +SERVER_URL = os.getenv("TABLEAU_SERVER_URL", "") +TOKEN_NAME = os.getenv("TABLEAU_TOKEN_NAME", "") +TOKEN_SECRET = os.getenv("TABLEAU_TOKEN_SECRET", "") +SITE_ID = os.getenv("TABLEAU_SITE_ID", "") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: TableauResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + if isinstance(data, list): + print(f" Found {len(data)} items.") + if data: + print(f" Sample: {json.dumps(data[0], indent=2, default=str)[:400]}...") + elif isinstance(data, dict): + print(f" Data: {json.dumps(data, indent=2, default=str)[:500]}...") + else: + print(f" Data: {data}") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +def main() -> None: + # 1. Validate Configuration + print_section("Initializing Tableau Client") + + if not SERVER_URL: + print(" TABLEAU_SERVER_URL is required.") + print(" Example: export TABLEAU_SERVER_URL='https://10ax.online.tableau.com'") + return + + if not (TOKEN_NAME and TOKEN_SECRET): + print(" TABLEAU_TOKEN_NAME and TABLEAU_TOKEN_SECRET are required.") + print(" Create a Personal Access Token in Tableau Settings.") + return + + print(f" Server: {SERVER_URL}") + print(f" Token Name: {TOKEN_NAME}") + print(f" Site ID: '{SITE_ID}'") + + # 2. Build Client with PAT Config + config = TableauPATConfig( + server_url=SERVER_URL, + token_name=TOKEN_NAME, + token_secret=TOKEN_SECRET, + site_id=SITE_ID, + ) + + client = TableauClient.build_with_config(config) + data_source = TableauDataSource(client) + print(" Client initialized and authenticated via PAT.") + + try: + # 3. List Workbooks + print_section("Workbooks") + workbooks_resp = data_source.list_workbooks() + print_result("List Workbooks", workbooks_resp) + + # 4. List Views + print_section("Views") + views_resp = data_source.list_views() + print_result("List Views", views_resp) + + # 5. List Data Sources + print_section("Data Sources") + datasources_resp = data_source.list_datasources() + print_result("List Data Sources", datasources_resp) + + # 6. List Projects + print_section("Projects") + projects_resp = data_source.list_projects() + print_result("List Projects", projects_resp) + + # 7. List Users + print_section("Users") + users_resp = data_source.list_users() + print_result("List Users", users_resp) + + # 8. List Groups + print_section("Groups") + groups_resp = data_source.list_groups() + print_result("List Groups", groups_resp) + + # 9. List Flows + print_section("Flows") + flows_resp = data_source.list_flows() + print_result("List Flows", flows_resp) + + finally: + # Cleanup: Sign out + print("\nSigning out...") + data_source.sign_out() + + print("\n" + "=" * 80) + print(" All Tableau SDK operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + main() diff --git a/backend/python/app/sources/external/tableau/tableau.py b/backend/python/app/sources/external/tableau/tableau.py new file mode 100644 index 000000000..c4f6555cb --- /dev/null +++ b/backend/python/app/sources/external/tableau/tableau.py @@ -0,0 +1,130 @@ +# ruff: noqa +from __future__ import annotations + +import tableauserverclient as TSC # type: ignore[reportMissingImports] +from typing import Any, Dict, List, Union, cast + +from app.sources.client.tableau.tableau import TableauResponse + + +class TableauDataSource: + """ + Typed wrapper over tableauserverclient for common Tableau business operations. + + Accepts either a TSC.Server instance or any client exposing `.get_sdk() -> TSC.Server`. + + SDK Reference: https://tableau.github.io/server-client-python/docs/ + """ + + def __init__(self, client_or_sdk: Union[TSC.Server, object]) -> None: # type: ignore[reportUnknownParameterType] + super().__init__() + if hasattr(client_or_sdk, "get_sdk"): # type: ignore[reportUnknownArgumentType] + sdk_obj = getattr(client_or_sdk, "get_sdk")() # type: ignore[reportUnknownArgumentType] + self._sdk: TSC.Server = cast(TSC.Server, sdk_obj) # type: ignore[reportUnknownMemberType] + else: + self._sdk = cast(TSC.Server, client_or_sdk) # type: ignore[reportUnknownMemberType] + + @staticmethod + def _to_dict(item: object) -> Dict[str, Any]: + """Convert a TSC resource item to a dictionary representation.""" + if hasattr(item, "__dict__"): + return {k: v for k, v in item.__dict__.items() if not k.startswith("_")} + return {"value": str(item)} + + @staticmethod + def _to_dict_list(items: object) -> List[Dict[str, Any]]: + """Convert a list of TSC resource items to a list of dictionaries.""" + result: List[Dict[str, Any]] = [] + if hasattr(items, "__iter__"): + for item in items: # type: ignore[union-attr, reportUnknownVariableType] + if hasattr(item, "__dict__"): # type: ignore[reportUnknownArgumentType] + result.append({k: v for k, v in item.__dict__.items() if not k.startswith("_")}) # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + else: + result.append({"value": str(item)}) # type: ignore[reportUnknownArgumentType] + return result + def list_workbooks(self) -> TableauResponse: + """List all workbooks on the site. [workbooks]""" + items, pagination = self._sdk.workbooks.get() # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + return TableauResponse(success=True, data=self._to_dict_list(items)) # type: ignore[reportUnknownArgumentType] + def get_workbook(self, workbook_id: str) -> TableauResponse: + """Get a single workbook by ID. [workbooks]""" + item: Any = self._sdk.workbooks.get_by_id(workbook_id) # type: ignore[reportUnknownMemberType] + return TableauResponse(success=True, data=self._to_dict(item)) # type: ignore[reportUnknownArgumentType] + def populate_workbook_views(self, workbook_id: str) -> TableauResponse: + """Populate and return the views for a workbook. [workbooks]""" + workbook: Any = self._sdk.workbooks.get_by_id(workbook_id) # type: ignore[reportUnknownMemberType] + self._sdk.workbooks.populate_views(workbook) # type: ignore[reportUnknownMemberType] + return TableauResponse(success=True, data=self._to_dict_list(workbook.views)) # type: ignore[reportUnknownArgumentType] + def populate_workbook_connections(self, workbook_id: str) -> TableauResponse: + """Populate and return the connections for a workbook. [workbooks]""" + workbook: Any = self._sdk.workbooks.get_by_id(workbook_id) # type: ignore[reportUnknownMemberType] + self._sdk.workbooks.populate_connections(workbook) # type: ignore[reportUnknownMemberType] + return TableauResponse(success=True, data=self._to_dict_list(workbook.connections)) # type: ignore[reportUnknownArgumentType] + def delete_workbook(self, workbook_id: str) -> TableauResponse: + """Delete a workbook by ID. [workbooks]""" + self._sdk.workbooks.delete(workbook_id) # type: ignore[reportUnknownMemberType] + return TableauResponse(success=True, data=True) # type: ignore[reportUnknownArgumentType] + def list_views(self) -> TableauResponse: + """List all views on the site. [views]""" + items, pagination = self._sdk.views.get() # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + return TableauResponse(success=True, data=self._to_dict_list(items)) # type: ignore[reportUnknownArgumentType] + def get_view(self, view_id: str) -> TableauResponse: + """Get a single view by ID. [views]""" + item: Any = self._sdk.views.get_by_id(view_id) # type: ignore[reportUnknownMemberType] + return TableauResponse(success=True, data=self._to_dict(item)) # type: ignore[reportUnknownArgumentType] + def list_datasources(self) -> TableauResponse: + """List all published data sources on the site. [datasources]""" + items, pagination = self._sdk.datasources.get() # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + return TableauResponse(success=True, data=self._to_dict_list(items)) # type: ignore[reportUnknownArgumentType] + def get_datasource(self, datasource_id: str) -> TableauResponse: + """Get a single data source by ID. [datasources]""" + item: Any = self._sdk.datasources.get_by_id(datasource_id) # type: ignore[reportUnknownMemberType] + return TableauResponse(success=True, data=self._to_dict(item)) # type: ignore[reportUnknownArgumentType] + def delete_datasource(self, datasource_id: str) -> TableauResponse: + """Delete a data source by ID. [datasources]""" + self._sdk.datasources.delete(datasource_id) # type: ignore[reportUnknownMemberType] + return TableauResponse(success=True, data=True) # type: ignore[reportUnknownArgumentType] + def list_projects(self) -> TableauResponse: + """List all projects on the site. [projects]""" + items, pagination = self._sdk.projects.get() # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + return TableauResponse(success=True, data=self._to_dict_list(items)) # type: ignore[reportUnknownArgumentType] + def list_users(self) -> TableauResponse: + """List all users on the site. [users]""" + items, pagination = self._sdk.users.get() # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + return TableauResponse(success=True, data=self._to_dict_list(items)) # type: ignore[reportUnknownArgumentType] + def get_user(self, user_id: str) -> TableauResponse: + """Get a single user by ID. [users]""" + item: Any = self._sdk.users.get_by_id(user_id) # type: ignore[reportUnknownMemberType] + return TableauResponse(success=True, data=self._to_dict(item)) # type: ignore[reportUnknownArgumentType] + def list_groups(self) -> TableauResponse: + """List all groups on the site. [groups]""" + items, pagination = self._sdk.groups.get() # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + return TableauResponse(success=True, data=self._to_dict_list(items)) # type: ignore[reportUnknownArgumentType] + def get_group(self, group_id: str) -> TableauResponse: + """Get a single group by ID. [groups]""" + item: Any = self._sdk.groups.get_by_id(group_id) # type: ignore[reportUnknownMemberType] + return TableauResponse(success=True, data=self._to_dict(item)) # type: ignore[reportUnknownArgumentType] + def list_schedules(self) -> TableauResponse: + """List all schedules on the server. [schedules]""" + items, pagination = self._sdk.schedules.get() # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + return TableauResponse(success=True, data=self._to_dict_list(items)) # type: ignore[reportUnknownArgumentType] + def list_jobs(self) -> TableauResponse: + """List all jobs on the site. [jobs]""" + items, pagination = self._sdk.jobs.get() # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + return TableauResponse(success=True, data=self._to_dict_list(items)) # type: ignore[reportUnknownArgumentType] + def get_job(self, job_id: str) -> TableauResponse: + """Get a single job by ID. [jobs]""" + item: Any = self._sdk.jobs.get_by_id(job_id) # type: ignore[reportUnknownMemberType] + return TableauResponse(success=True, data=self._to_dict(item)) # type: ignore[reportUnknownArgumentType] + def list_flows(self) -> TableauResponse: + """List all flows on the site. [flows]""" + items, pagination = self._sdk.flows.get() # type: ignore[reportUnknownMemberType, reportUnknownVariableType] + return TableauResponse(success=True, data=self._to_dict_list(items)) # type: ignore[reportUnknownArgumentType] + def get_flow(self, flow_id: str) -> TableauResponse: + """Get a single flow by ID. [flows]""" + item: Any = self._sdk.flows.get_by_id(flow_id) # type: ignore[reportUnknownMemberType] + return TableauResponse(success=True, data=self._to_dict(item)) # type: ignore[reportUnknownArgumentType] + def sign_out(self) -> TableauResponse: + """Sign out and invalidate the current auth session. [auth]""" + self._sdk.auth.sign_out() # type: ignore[reportUnknownMemberType] + return TableauResponse(success=True, data=True, message='Signed out successfully') # type: ignore[reportUnknownArgumentType] diff --git a/backend/python/app/sources/external/webex/example.py b/backend/python/app/sources/external/webex/example.py new file mode 100644 index 000000000..fe1cc0147 --- /dev/null +++ b/backend/python/app/sources/external/webex/example.py @@ -0,0 +1,135 @@ +# ruff: noqa +""" +Webex API Usage Examples + +This example demonstrates how to use the Webex DataSource to interact with +the Webex API, covering: +- Authentication (Token or OAuth) +- Initializing the Client and DataSource +- Getting current user info +- Listing people, rooms, teams, meetings +- Listing messages in a room +- Listing organizations, webhooks, recordings + +Prerequisites: +1. Create a Webex integration at https://developer.webex.com/my-apps +2. Set environment variables: + - WEBEX_ACCESS_TOKEN: A valid Webex access token + OR for OAuth: + - WEBEX_CLIENT_ID: OAuth client ID + - WEBEX_CLIENT_SECRET: OAuth client secret + - WEBEX_REDIRECT_URI: OAuth redirect URI + +API Reference: https://developer.webex.com/docs/api/getting-started +""" + +import json +import os + +from app.sources.client.webex.webex import ( + WebexClient, + WebexTokenConfig, + WebexResponse, +) +from app.sources.external.webex.webex import WebexDataSource + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: WebexResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + if isinstance(response.data, list): + print(f" Found {len(response.data)} items") + if response.data: + print(f" Sample: {json.dumps(response.data[0], indent=2, default=str)[:400]}...") + elif isinstance(response.data, dict): + print(f" Data: {json.dumps(response.data, indent=2, default=str)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +def main() -> None: + """Example usage of Webex API.""" + ACCESS_TOKEN = os.getenv("WEBEX_ACCESS_TOKEN") + + if not ACCESS_TOKEN: + print("Please set WEBEX_ACCESS_TOKEN environment variable") + print(" Get a token from https://developer.webex.com/docs/getting-started") + return + + # Initialize Webex client + print_section("Initializing Webex Client") + print(" Using token authentication") + config = WebexTokenConfig(access_token=ACCESS_TOKEN) + client = WebexClient.build_with_config(config) + data_source = WebexDataSource(client) + print(" Client initialized successfully.") + + # 1. Get current user + print_section("Current User") + me_resp = data_source.get_me() + print_result("Get Me", me_resp) + + # 2. List people + print_section("People") + people_resp = data_source.list_people(max_results=5) + print_result("List People", people_resp) + + # 3. List rooms + print_section("Rooms / Spaces") + rooms_resp = data_source.list_rooms(max_results=5) + print_result("List Rooms", rooms_resp) + + # If we have rooms, list messages from the first one + if rooms_resp.success and rooms_resp.data and isinstance(rooms_resp.data, list): + if rooms_resp.data: + first_room = rooms_resp.data[0] + room_id = first_room.get("id", "") if isinstance(first_room, dict) else "" + if room_id: + print_section(f"Messages in Room: {room_id[:20]}...") + messages_resp = data_source.list_messages( + room_id=room_id, max_results=5 + ) + print_result("List Messages", messages_resp) + + # 4. List teams + print_section("Teams") + teams_resp = data_source.list_teams(max_results=5) + print_result("List Teams", teams_resp) + + # 5. List meetings + print_section("Meetings") + meetings_resp = data_source.list_meetings(max_results=5) + print_result("List Meetings", meetings_resp) + + # 6. List organizations + print_section("Organizations") + orgs_resp = data_source.list_organizations() + print_result("List Organizations", orgs_resp) + + # 7. List webhooks + print_section("Webhooks") + webhooks_resp = data_source.list_webhooks(max_results=5) + print_result("List Webhooks", webhooks_resp) + + # 8. List recordings + print_section("Recordings") + recordings_resp = data_source.list_recordings(max_results=5) + print_result("List Recordings", recordings_resp) + + print("\n" + "=" * 80) + print(" All Webex API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + main() diff --git a/backend/python/app/sources/external/webex/webex.py b/backend/python/app/sources/external/webex/webex.py new file mode 100644 index 000000000..9024be078 --- /dev/null +++ b/backend/python/app/sources/external/webex/webex.py @@ -0,0 +1,609 @@ +""" +Webex DataSource - API wrapper using the official wxc_sdk. + +Provides typed wrapper methods for common Webex operations including +people, rooms/spaces, messages, teams, meetings, memberships, +organizations, webhooks, and recordings. + +All methods return WebexResponse objects. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast + +if TYPE_CHECKING: + from wxc_sdk import WebexSimpleApi # type: ignore[import-untyped] + +from app.sources.client.webex.webex import ( + WebexClient, + WebexClientViaOAuth, + WebexClientViaToken, + WebexResponse, +) + + +class WebexDataSource: + """Webex DataSource + + Typed wrapper over the wxc_sdk WebexSimpleApi for common operations. + + Accepts either a WebexClient, WebexClientViaToken, or WebexClientViaOAuth. + + Coverage: + - People: list, get, get_me + - Rooms/Spaces: list, get + - Messages: list + - Teams: list, get + - Meetings: list, get + - Memberships: list + - Organizations: list, get + - Webhooks: list + - Recordings: list + """ + + def __init__( + self, + client_or_wrapper: WebexClient | WebexClientViaToken | WebexClientViaOAuth, + ) -> None: + """Initialize with a Webex client. + + Args: + client_or_wrapper: WebexClient, WebexClientViaToken, + or WebexClientViaOAuth instance + """ + if isinstance(client_or_wrapper, WebexClient): + self._sdk: WebexSimpleApi = client_or_wrapper.get_sdk() # type: ignore[no-any-unimported] + else: + self._sdk = client_or_wrapper.get_sdk() + + def get_data_source(self) -> "WebexDataSource": + """Return the data source instance.""" + return self + + # ========================================================================= + # PEOPLE OPERATIONS + # ========================================================================= + + def list_people( + self, + email: str | None = None, + display_name: str | None = None, + max_results: int | None = None, + ) -> WebexResponse: + """List people in the organization. + + Args: + email: Filter by email address + display_name: Filter by display name + max_results: Maximum number of results to return + + Returns: + WebexResponse with list of people + """ + try: + kwargs: dict[str, Any] = {} + if email: + kwargs["email"] = email + if display_name: + kwargs["display_name"] = display_name + if max_results is not None: + kwargs["max"] = max_results + + people = cast(list[object], list(self._sdk.people.list(**kwargs))) # type: ignore[no-untyped-call] + data = self._serialize_list(people) + return WebexResponse( + success=True, + data=data, + message=f"Found {len(data)} people", + ) + except Exception as e: + return WebexResponse( + success=False, + error=str(e), + message="Failed to list people", + ) + + def get_person(self, person_id: str) -> WebexResponse: + """Get details of a specific person. + + Args: + person_id: The person ID + + Returns: + WebexResponse with person details + """ + try: + person = self._sdk.people.details(person_id) # type: ignore[no-untyped-call] + data = self._serialize_object(person) + return WebexResponse( + success=True, + data=data, + message="Successfully retrieved person", + ) + except Exception as e: + return WebexResponse( + success=False, + error=str(e), + message="Failed to get person", + ) + + def get_me(self) -> WebexResponse: + """Get the authenticated user's details. + + Returns: + WebexResponse with current user details + """ + try: + me = self._sdk.people.me() # type: ignore[no-untyped-call] + data = self._serialize_object(me) + return WebexResponse( + success=True, + data=data, + message="Successfully retrieved current user", + ) + except Exception as e: + return WebexResponse( + success=False, + error=str(e), + message="Failed to get current user", + ) + + # ========================================================================= + # ROOMS / SPACES OPERATIONS + # ========================================================================= + + def list_rooms( + self, + team_id: str | None = None, + room_type: str | None = None, + max_results: int | None = None, + ) -> WebexResponse: + """List rooms (spaces). + + Args: + team_id: Filter by team ID + room_type: Filter by room type ('direct' or 'group') + max_results: Maximum number of results to return + + Returns: + WebexResponse with list of rooms + """ + try: + kwargs: dict[str, Any] = {} + if team_id: + kwargs["team_id"] = team_id + if room_type: + kwargs["type_"] = room_type + if max_results is not None: + kwargs["max"] = max_results + + rooms = cast(list[object], list(self._sdk.rooms.list(**kwargs))) # type: ignore[no-untyped-call] + data = self._serialize_list(rooms) + return WebexResponse( + success=True, + data=data, + message=f"Found {len(data)} rooms", + ) + except Exception as e: + return WebexResponse( + success=False, + error=str(e), + message="Failed to list rooms", + ) + + def get_room(self, room_id: str) -> WebexResponse: + """Get details of a specific room. + + Args: + room_id: The room ID + + Returns: + WebexResponse with room details + """ + try: + room = self._sdk.rooms.details(room_id) # type: ignore[no-untyped-call] + data = self._serialize_object(room) + return WebexResponse( + success=True, + data=data, + message="Successfully retrieved room", + ) + except Exception as e: + return WebexResponse( + success=False, + error=str(e), + message="Failed to get room", + ) + + # ========================================================================= + # MESSAGE OPERATIONS + # ========================================================================= + + def list_messages( + self, + room_id: str, + max_results: int | None = None, + mentioned_people: str | None = None, + before: str | None = None, + ) -> WebexResponse: + """List messages in a room. + + Args: + room_id: Room ID to list messages from + max_results: Maximum number of results to return + mentioned_people: Filter by mentioned person ID (use 'me' for self) + before: List messages before this date/time (ISO 8601) + + Returns: + WebexResponse with list of messages + """ + try: + kwargs: dict[str, Any] = {"room_id": room_id} + if max_results is not None: + kwargs["max"] = max_results + if mentioned_people: + kwargs["mentioned_people"] = mentioned_people + if before: + kwargs["before"] = before + + messages = cast(list[object], list(self._sdk.messages.list(**kwargs))) # type: ignore[no-untyped-call] + data = self._serialize_list(messages) + return WebexResponse( + success=True, + data=data, + message=f"Found {len(data)} messages", + ) + except Exception as e: + return WebexResponse( + success=False, + error=str(e), + message="Failed to list messages", + ) + + # ========================================================================= + # TEAM OPERATIONS + # ========================================================================= + + def list_teams( + self, + max_results: int | None = None, + ) -> WebexResponse: + """List teams. + + Args: + max_results: Maximum number of results to return + + Returns: + WebexResponse with list of teams + """ + try: + kwargs: dict[str, Any] = {} + if max_results is not None: + kwargs["max"] = max_results + + teams = cast(list[object], list(self._sdk.teams.list(**kwargs))) # type: ignore[no-untyped-call] + data = self._serialize_list(teams) + return WebexResponse( + success=True, + data=data, + message=f"Found {len(data)} teams", + ) + except Exception as e: + return WebexResponse( + success=False, + error=str(e), + message="Failed to list teams", + ) + + def get_team(self, team_id: str) -> WebexResponse: + """Get details of a specific team. + + Args: + team_id: The team ID + + Returns: + WebexResponse with team details + """ + try: + team = self._sdk.teams.details(team_id) # type: ignore[no-untyped-call] + data = self._serialize_object(team) + return WebexResponse( + success=True, + data=data, + message="Successfully retrieved team", + ) + except Exception as e: + return WebexResponse( + success=False, + error=str(e), + message="Failed to get team", + ) + + # ========================================================================= + # MEETING OPERATIONS + # ========================================================================= + + def list_meetings( + self, + meeting_type: str | None = None, + state: str | None = None, + max_results: int | None = None, + ) -> WebexResponse: + """List meetings. + + Args: + meeting_type: Filter by meeting type + state: Filter by meeting state + max_results: Maximum number of results to return + + Returns: + WebexResponse with list of meetings + """ + try: + kwargs: dict[str, Any] = {} + if meeting_type: + kwargs["meeting_type"] = meeting_type + if state: + kwargs["state"] = state + if max_results is not None: + kwargs["max"] = max_results + + meetings = cast(list[object], list(self._sdk.meetings.list(**kwargs))) # type: ignore[no-untyped-call] + data = self._serialize_list(meetings) + return WebexResponse( + success=True, + data=data, + message=f"Found {len(data)} meetings", + ) + except Exception as e: + return WebexResponse( + success=False, + error=str(e), + message="Failed to list meetings", + ) + + def get_meeting(self, meeting_id: str) -> WebexResponse: + """Get details of a specific meeting. + + Args: + meeting_id: The meeting ID + + Returns: + WebexResponse with meeting details + """ + try: + meeting = self._sdk.meetings.get(meeting_id) # type: ignore[no-untyped-call] + data = self._serialize_object(meeting) + return WebexResponse( + success=True, + data=data, + message="Successfully retrieved meeting", + ) + except Exception as e: + return WebexResponse( + success=False, + error=str(e), + message="Failed to get meeting", + ) + + # ========================================================================= + # MEMBERSHIP OPERATIONS + # ========================================================================= + + def list_memberships( + self, + room_id: str | None = None, + person_id: str | None = None, + person_email: str | None = None, + max_results: int | None = None, + ) -> WebexResponse: + """List memberships. + + Args: + room_id: Filter by room ID + person_id: Filter by person ID + person_email: Filter by person email + max_results: Maximum number of results to return + + Returns: + WebexResponse with list of memberships + """ + try: + kwargs: dict[str, Any] = {} + if room_id: + kwargs["room_id"] = room_id + if person_id: + kwargs["person_id"] = person_id + if person_email: + kwargs["person_email"] = person_email + if max_results is not None: + kwargs["max"] = max_results + + memberships = cast(list[object], list(self._sdk.membership.list(**kwargs))) # type: ignore[no-untyped-call] + data = self._serialize_list(memberships) + return WebexResponse( + success=True, + data=data, + message=f"Found {len(data)} memberships", + ) + except Exception as e: + return WebexResponse( + success=False, + error=str(e), + message="Failed to list memberships", + ) + + # ========================================================================= + # ORGANIZATION OPERATIONS + # ========================================================================= + + def list_organizations(self) -> WebexResponse: + """List organizations. + + Returns: + WebexResponse with list of organizations + """ + try: + orgs = cast(list[object], list(self._sdk.organizations.list())) # type: ignore[no-untyped-call] + data = self._serialize_list(orgs) + return WebexResponse( + success=True, + data=data, + message=f"Found {len(data)} organizations", + ) + except Exception as e: + return WebexResponse( + success=False, + error=str(e), + message="Failed to list organizations", + ) + + def get_organization(self, org_id: str) -> WebexResponse: + """Get details of a specific organization. + + Args: + org_id: The organization ID + + Returns: + WebexResponse with organization details + """ + try: + org = self._sdk.organizations.details(org_id) # type: ignore[no-untyped-call] + data = self._serialize_object(org) + return WebexResponse( + success=True, + data=data, + message="Successfully retrieved organization", + ) + except Exception as e: + return WebexResponse( + success=False, + error=str(e), + message="Failed to get organization", + ) + + # ========================================================================= + # WEBHOOK OPERATIONS + # ========================================================================= + + def list_webhooks( + self, + max_results: int | None = None, + ) -> WebexResponse: + """List webhooks. + + Args: + max_results: Maximum number of results to return + + Returns: + WebexResponse with list of webhooks + """ + try: + kwargs: dict[str, Any] = {} + if max_results is not None: + kwargs["max"] = max_results + + webhooks = cast(list[object], list(self._sdk.webhook.list(**kwargs))) # type: ignore[no-untyped-call] + data = self._serialize_list(webhooks) + return WebexResponse( + success=True, + data=data, + message=f"Found {len(data)} webhooks", + ) + except Exception as e: + return WebexResponse( + success=False, + error=str(e), + message="Failed to list webhooks", + ) + + # ========================================================================= + # RECORDING OPERATIONS + # ========================================================================= + + def list_recordings( + self, + from_date: str | None = None, + to_date: str | None = None, + max_results: int | None = None, + ) -> WebexResponse: + """List recordings. + + Args: + from_date: Filter recordings from this date (ISO 8601) + to_date: Filter recordings up to this date (ISO 8601) + max_results: Maximum number of results to return + + Returns: + WebexResponse with list of recordings + """ + try: + kwargs: dict[str, Any] = {} + if from_date: + kwargs["from_"] = from_date + if to_date: + kwargs["to_"] = to_date + if max_results is not None: + kwargs["max"] = max_results + + recordings = cast(list[object], list(self._sdk.recordings.list(**kwargs))) # type: ignore[no-untyped-call] + data = self._serialize_list(recordings) + return WebexResponse( + success=True, + data=data, + message=f"Found {len(data)} recordings", + ) + except Exception as e: + return WebexResponse( + success=False, + error=str(e), + message="Failed to list recordings", + ) + + # ========================================================================= + # HELPERS + # ========================================================================= + + @staticmethod + def _serialize_object(obj: object) -> dict[str, object]: + """Serialize a wxc_sdk model object to a dictionary. + + The wxc_sdk models use dataclass-like structures. This method + converts them to plain dicts for consistent response formatting. + + Args: + obj: A wxc_sdk model object + + Returns: + Dictionary representation of the object + """ + if obj is None: + return {} + if isinstance(obj, dict): + return obj # type: ignore[return-value] + if hasattr(obj, "model_dump"): + return obj.model_dump() # type: ignore[union-attr] + if hasattr(obj, "json"): + import json + + return json.loads(obj.json()) # type: ignore[union-attr] + if hasattr(obj, "__dict__"): + return { + k: v + for k, v in vars(obj).items() + if not k.startswith("_") + } + return {"value": obj} + + @classmethod + def _serialize_list(cls, items: list[object]) -> list[object]: + """Serialize a list of wxc_sdk model objects. + + Args: + items: List of wxc_sdk model objects + + Returns: + List of dictionary representations + """ + return [cls._serialize_object(item) for item in items] diff --git a/backend/python/app/sources/external/wordpress/example.py b/backend/python/app/sources/external/wordpress/example.py new file mode 100644 index 000000000..b760c0a30 --- /dev/null +++ b/backend/python/app/sources/external/wordpress/example.py @@ -0,0 +1,237 @@ +# ruff: noqa + +""" +WordPress API Usage Examples + +This example demonstrates how to use the WordPress DataSource to interact with +the WordPress REST API, covering: +- Authentication (OAuth2 for WordPress.com, Application Password for self-hosted, Token) +- Initializing the Client and DataSource +- Getting Current User +- Listing Posts and Pages +- Listing Categories +- Searching Content + +Prerequisites: +For OAuth2 (WordPress.com): +1. Create a WordPress.com OAuth app at https://developer.wordpress.com/apps/ +2. Set WORDPRESS_CLIENT_ID and WORDPRESS_CLIENT_SECRET environment variables +3. Set WORDPRESS_SITE_ID to your WordPress.com site ID or domain +4. The OAuth flow will automatically open a browser for authorization + +For Application Password (self-hosted): +1. Log in to your self-hosted WordPress admin +2. Go to Users > Profile > Application Passwords +3. Generate a new application password +4. Set WORDPRESS_SITE_URL, WORDPRESS_USERNAME, and WORDPRESS_APP_PASSWORD + +For Bearer Token: +1. Set WORDPRESS_ACCESS_TOKEN and WORDPRESS_SITE_URL environment variables +""" + +import asyncio +import json +import os + +from app.sources.client.wordpress.wordpress import ( + WordPressClient, + WordPressOAuthConfig, + WordPressApplicationPasswordConfig, + WordPressTokenConfig, + WordPressResponse, +) +from app.sources.external.wordpress.wordpress import WordPressDataSource +from app.sources.external.utils.oauth import perform_oauth_flow + +# --- Configuration --- +# OAuth2 credentials (highest priority - WordPress.com) +CLIENT_ID = os.getenv("WORDPRESS_CLIENT_ID") +CLIENT_SECRET = os.getenv("WORDPRESS_CLIENT_SECRET") +SITE_ID = os.getenv("WORDPRESS_SITE_ID") + +# Application Password (second priority - self-hosted) +SITE_URL = os.getenv("WORDPRESS_SITE_URL") +USERNAME = os.getenv("WORDPRESS_USERNAME") +APP_PASSWORD = os.getenv("WORDPRESS_APP_PASSWORD") + +# Bearer Token (third priority) +ACCESS_TOKEN = os.getenv("WORDPRESS_ACCESS_TOKEN") + +# OAuth redirect URI +REDIRECT_URI = os.getenv("WORDPRESS_REDIRECT_URI", "http://localhost:8080/callback") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: WordPressResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + # Handle list-type responses + if isinstance(data, list): + print(f" Found {len(data)} items.") + if data: + print(f" Sample: {json.dumps(data[0], indent=2)[:400]}...") + return + # Handle dict responses with common WordPress keys + for key in ("posts", "pages", "categories", "tags", "comments", + "users", "media", "results"): + if isinstance(data, dict) and key in data: + items = data[key] + print(f" Found {len(items)} {key}.") + if items: + print(f" Sample: {json.dumps(items[0], indent=2)[:400]}...") + return + # Generic response + print(f" Data: {json.dumps(data, indent=2)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + # 1. Initialize Client + print_section("Initializing WordPress Client") + + config = None + + # Priority 1: OAuth2 (WordPress.com) + if CLIENT_ID and CLIENT_SECRET and SITE_ID: + print(" Using OAuth2 authentication (WordPress.com)") + try: + print("Starting OAuth flow...") + # WordPress.com OAuth authorization URL + # Token endpoint: https://public-api.wordpress.com/oauth2/token + token_response = perform_oauth_flow( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + auth_endpoint="https://public-api.wordpress.com/oauth2/authorize", + token_endpoint="https://public-api.wordpress.com/oauth2/token", + redirect_uri=REDIRECT_URI, + scopes=[], # WordPress.com scopes are configured in the app settings + scope_delimiter=" ", + auth_method="body", # WordPress.com sends credentials in POST body + ) + + access_token = token_response.get("access_token") + if not access_token: + raise Exception("No access_token found in OAuth response") + + config = WordPressOAuthConfig( + access_token=access_token, + site_id=SITE_ID, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + ) + print(" OAuth authentication successful") + except Exception as e: + print(f" OAuth flow failed: {e}") + print(" Falling back to other authentication methods...") + + # Priority 2: Application Password (self-hosted) + if config is None and SITE_URL and USERNAME and APP_PASSWORD: + print(" Using Application Password authentication (self-hosted)") + config = WordPressApplicationPasswordConfig( + site_url=SITE_URL, + username=USERNAME, + application_password=APP_PASSWORD, + ) + + # Priority 3: Bearer Token + if config is None and ACCESS_TOKEN and SITE_URL: + print(" Using Bearer Token authentication") + config = WordPressTokenConfig( + token=ACCESS_TOKEN, + site_url=SITE_URL, + ) + + if config is None: + print(" No valid authentication method found.") + print(" Please set one of the following:") + print(" - WORDPRESS_CLIENT_ID, WORDPRESS_CLIENT_SECRET, and WORDPRESS_SITE_ID (for OAuth2 / WordPress.com)") + print(" - WORDPRESS_SITE_URL, WORDPRESS_USERNAME, and WORDPRESS_APP_PASSWORD (for Application Password)") + print(" - WORDPRESS_ACCESS_TOKEN and WORDPRESS_SITE_URL (for Bearer Token)") + return + + client = WordPressClient.build_with_config(config) + data_source = WordPressDataSource(client) + print("Client initialized successfully.") + + try: + # 2. Get Current User + print_section("Current User") + user_resp = await data_source.get_current_user() + print_result("Get Current User", user_resp) + + # 3. List Posts + print_section("Posts") + posts_resp = await data_source.list_posts(per_page=5) + print_result("List Posts", posts_resp) + + # 4. List Pages + print_section("Pages") + pages_resp = await data_source.list_pages(per_page=5) + print_result("List Pages", pages_resp) + + # 5. List Categories + print_section("Categories") + categories_resp = await data_source.list_categories(per_page=10) + print_result("List Categories", categories_resp) + + # 6. List Tags + print_section("Tags") + tags_resp = await data_source.list_tags(per_page=10) + print_result("List Tags", tags_resp) + + # 7. Search Content + print_section("Search") + search_resp = await data_source.search_content(search="hello") + print_result("Search Content", search_resp) + + # 8. Get a specific post if available + if posts_resp.success and posts_resp.data: + data = posts_resp.data + posts = data if isinstance(data, list) else [] + if posts: + post_id = str(posts[0].get("id", "")) + if post_id: + print_section(f"Post Details: {posts[0].get('title', {}).get('rendered', 'N/A')}") + post_resp = await data_source.get_post(post_id=post_id) + print_result("Get Post", post_resp) + + # 9. Get Comments for the post + print_section("Post Comments") + comments_resp = await data_source.list_comments(post=int(post_id)) + print_result("List Comments", comments_resp) + + # 10. List Users + print_section("Users") + users_resp = await data_source.list_users(per_page=5) + print_result("List Users", users_resp) + + # 11. List Post Types + print_section("Post Types") + types_resp = await data_source.list_post_types() + print_result("List Post Types", types_resp) + + finally: + # Cleanup: Close the HTTP client session + print("\nClosing client connection...") + inner_client = client.get_client() + if hasattr(inner_client, "close"): + await inner_client.close() + + print("\n" + "=" * 80) + print(" All WordPress API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/wordpress/wordpress.py b/backend/python/app/sources/external/wordpress/wordpress.py new file mode 100644 index 000000000..a48ea0504 --- /dev/null +++ b/backend/python/app/sources/external/wordpress/wordpress.py @@ -0,0 +1,1711 @@ +""" +WordPress REST API DataSource - Auto-generated API wrapper + +Generated from WordPress REST API v2 documentation. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.http.http_request import HTTPRequest +from app.sources.client.wordpress.wordpress import WordPressClient, WordPressResponse + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class WordPressDataSource: + """WordPress REST API DataSource + + Provides async wrapper methods for WordPress REST API v2 operations: + - Posts CRUD + - Pages CRUD + - Categories and Tags + - Comments + - Users + - Media + - Post Types, Statuses, Taxonomies + - Search + + The base URL is determined by the WordPressClient's configured + authentication method (WordPress.com OAuth or self-hosted). + + All methods return WordPressResponse objects. + """ + + def __init__(self, client: WordPressClient) -> None: + """Initialize with WordPressClient. + + Args: + client: WordPressClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'WordPressDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> WordPressClient: + """Return the underlying WordPressClient.""" + return self._client + + async def list_posts( + self, + page: int | None = None, + per_page: int | None = None, + search: str | None = None, + after: str | None = None, + before: str | None = None, + author: str | None = None, + categories: str | None = None, + tags: str | None = None, + status: str | None = None, + orderby: str | None = None, + order: str | None = None + ) -> WordPressResponse: + """List all posts + + Args: + page: Current page of the collection (default 1) + per_page: Maximum number of items per page (default 10, max 100) + search: Limit results to those matching a search string + after: Limit to posts published after a given ISO8601 date + before: Limit to posts published before a given ISO8601 date + author: Limit to posts by one or more author IDs (comma-separated) + categories: Limit to posts in specific category IDs (comma-separated) + tags: Limit to posts with specific tag IDs (comma-separated) + status: Limit to posts with a specific status (publish, draft, pending, etc.) + orderby: Sort by attribute (date, relevance, id, include, title, slug) + order: Sort order (asc or desc) + + Returns: + WordPressResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + if search is not None: + query_params['search'] = search + if after is not None: + query_params['after'] = after + if before is not None: + query_params['before'] = before + if author is not None: + query_params['author'] = author + if categories is not None: + query_params['categories'] = categories + if tags is not None: + query_params['tags'] = tags + if status is not None: + query_params['status'] = status + if orderby is not None: + query_params['orderby'] = orderby + if order is not None: + query_params['order'] = order + + url = self.base_url + "/posts" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WordPressResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_posts" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return WordPressResponse(success=False, error=str(e), message="Failed to execute list_posts") + + async def get_post( + self, + post_id: str + ) -> WordPressResponse: + """Get a specific post by ID + + Args: + post_id: The post ID + + Returns: + WordPressResponse with operation result + """ + url = self.base_url + "/posts/{post_id}".format(post_id=post_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WordPressResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_post" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return WordPressResponse(success=False, error=str(e), message="Failed to execute get_post") + + async def create_post( + self, + title: str, + content: str | None = None, + status: str | None = None, + excerpt: str | None = None, + author: int | None = None, + categories: list[int] | None = None, + tags: list[int] | None = None, + format_: str | None = None, + slug: str | None = None, + comment_status: str | None = None, + ping_status: str | None = None, + featured_media: int | None = None + ) -> WordPressResponse: + """Create a new post + + Args: + title: The title for the post + content: The content for the post + status: Post status (publish, draft, pending, private) + excerpt: The excerpt for the post + author: The ID of the author + categories: Category IDs for the post + tags: Tag IDs for the post + format_: Post format (standard, aside, chat, gallery, link, image, quote, status, video, audio) + slug: Alphanumeric identifier for the post + comment_status: Whether comments are open (open or closed) + ping_status: Whether pings are accepted (open or closed) + featured_media: The ID of the featured media + + Returns: + WordPressResponse with operation result + """ + url = self.base_url + "/posts" + + body: dict[str, Any] = {} + body['title'] = title + if content is not None: + body['content'] = content + if status is not None: + body['status'] = status + if excerpt is not None: + body['excerpt'] = excerpt + if author is not None: + body['author'] = author + if categories is not None: + body['categories'] = categories + if tags is not None: + body['tags'] = tags + if format_ is not None: + body['format'] = format_ + if slug is not None: + body['slug'] = slug + if comment_status is not None: + body['comment_status'] = comment_status + if ping_status is not None: + body['ping_status'] = ping_status + if featured_media is not None: + body['featured_media'] = featured_media + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WordPressResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed create_post" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return WordPressResponse(success=False, error=str(e), message="Failed to execute create_post") + + async def update_post( + self, + post_id: str, + title: str | None = None, + content: str | None = None, + status: str | None = None, + excerpt: str | None = None, + author: int | None = None, + categories: list[int] | None = None, + tags: list[int] | None = None, + slug: str | None = None, + comment_status: str | None = None, + featured_media: int | None = None + ) -> WordPressResponse: + """Update an existing post + + Args: + post_id: The post ID + title: The title for the post + content: The content for the post + status: Post status (publish, draft, pending, private) + excerpt: The excerpt for the post + author: The ID of the author + categories: Category IDs for the post + tags: Tag IDs for the post + slug: Alphanumeric identifier for the post + comment_status: Whether comments are open (open or closed) + featured_media: The ID of the featured media + + Returns: + WordPressResponse with operation result + """ + url = self.base_url + "/posts/{post_id}".format(post_id=post_id) + + body: dict[str, Any] = {} + if title is not None: + body['title'] = title + if content is not None: + body['content'] = content + if status is not None: + body['status'] = status + if excerpt is not None: + body['excerpt'] = excerpt + if author is not None: + body['author'] = author + if categories is not None: + body['categories'] = categories + if tags is not None: + body['tags'] = tags + if slug is not None: + body['slug'] = slug + if comment_status is not None: + body['comment_status'] = comment_status + if featured_media is not None: + body['featured_media'] = featured_media + + try: + request = HTTPRequest( + method="PUT", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WordPressResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed update_post" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return WordPressResponse(success=False, error=str(e), message="Failed to execute update_post") + + async def delete_post( + self, + post_id: str + ) -> WordPressResponse: + """Delete a post + + Args: + post_id: The post ID + + Returns: + WordPressResponse with operation result + """ + url = self.base_url + "/posts/{post_id}".format(post_id=post_id) + + try: + request = HTTPRequest( + method="DELETE", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WordPressResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed delete_post" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return WordPressResponse(success=False, error=str(e), message="Failed to execute delete_post") + + async def list_pages( + self, + page: int | None = None, + per_page: int | None = None, + search: str | None = None, + after: str | None = None, + before: str | None = None, + author: str | None = None, + status: str | None = None, + orderby: str | None = None, + order: str | None = None + ) -> WordPressResponse: + """List all pages + + Args: + page: Current page of the collection (default 1) + per_page: Maximum number of items per page (default 10, max 100) + search: Limit results to those matching a search string + after: Limit to pages published after a given ISO8601 date + before: Limit to pages published before a given ISO8601 date + author: Limit to pages by one or more author IDs (comma-separated) + status: Limit to pages with a specific status + orderby: Sort by attribute (date, relevance, id, include, title, slug, menu_order) + order: Sort order (asc or desc) + + Returns: + WordPressResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + if search is not None: + query_params['search'] = search + if after is not None: + query_params['after'] = after + if before is not None: + query_params['before'] = before + if author is not None: + query_params['author'] = author + if status is not None: + query_params['status'] = status + if orderby is not None: + query_params['orderby'] = orderby + if order is not None: + query_params['order'] = order + + url = self.base_url + "/pages" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WordPressResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_pages" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return WordPressResponse(success=False, error=str(e), message="Failed to execute list_pages") + + async def get_page( + self, + page_id: str + ) -> WordPressResponse: + """Get a specific page by ID + + Args: + page_id: The page ID + + Returns: + WordPressResponse with operation result + """ + url = self.base_url + "/pages/{page_id}".format(page_id=page_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WordPressResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_page" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return WordPressResponse(success=False, error=str(e), message="Failed to execute get_page") + + async def create_page( + self, + title: str, + content: str | None = None, + status: str | None = None, + excerpt: str | None = None, + author: int | None = None, + parent: int | None = None, + menu_order: int | None = None, + slug: str | None = None, + comment_status: str | None = None, + featured_media: int | None = None + ) -> WordPressResponse: + """Create a new page + + Args: + title: The title for the page + content: The content for the page + status: Page status (publish, draft, pending, private) + excerpt: The excerpt for the page + author: The ID of the author + parent: Parent page ID + menu_order: Page order in menu + slug: Alphanumeric identifier for the page + comment_status: Whether comments are open (open or closed) + featured_media: The ID of the featured media + + Returns: + WordPressResponse with operation result + """ + url = self.base_url + "/pages" + + body: dict[str, Any] = {} + body['title'] = title + if content is not None: + body['content'] = content + if status is not None: + body['status'] = status + if excerpt is not None: + body['excerpt'] = excerpt + if author is not None: + body['author'] = author + if parent is not None: + body['parent'] = parent + if menu_order is not None: + body['menu_order'] = menu_order + if slug is not None: + body['slug'] = slug + if comment_status is not None: + body['comment_status'] = comment_status + if featured_media is not None: + body['featured_media'] = featured_media + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WordPressResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed create_page" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return WordPressResponse(success=False, error=str(e), message="Failed to execute create_page") + + async def update_page( + self, + page_id: str, + title: str | None = None, + content: str | None = None, + status: str | None = None, + excerpt: str | None = None, + author: int | None = None, + parent: int | None = None, + menu_order: int | None = None, + slug: str | None = None, + featured_media: int | None = None + ) -> WordPressResponse: + """Update an existing page + + Args: + page_id: The page ID + title: The title for the page + content: The content for the page + status: Page status (publish, draft, pending, private) + excerpt: The excerpt for the page + author: The ID of the author + parent: Parent page ID + menu_order: Page order in menu + slug: Alphanumeric identifier for the page + featured_media: The ID of the featured media + + Returns: + WordPressResponse with operation result + """ + url = self.base_url + "/pages/{page_id}".format(page_id=page_id) + + body: dict[str, Any] = {} + if title is not None: + body['title'] = title + if content is not None: + body['content'] = content + if status is not None: + body['status'] = status + if excerpt is not None: + body['excerpt'] = excerpt + if author is not None: + body['author'] = author + if parent is not None: + body['parent'] = parent + if menu_order is not None: + body['menu_order'] = menu_order + if slug is not None: + body['slug'] = slug + if featured_media is not None: + body['featured_media'] = featured_media + + try: + request = HTTPRequest( + method="PUT", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WordPressResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed update_page" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return WordPressResponse(success=False, error=str(e), message="Failed to execute update_page") + + async def delete_page( + self, + page_id: str + ) -> WordPressResponse: + """Delete a page + + Args: + page_id: The page ID + + Returns: + WordPressResponse with operation result + """ + url = self.base_url + "/pages/{page_id}".format(page_id=page_id) + + try: + request = HTTPRequest( + method="DELETE", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WordPressResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed delete_page" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return WordPressResponse(success=False, error=str(e), message="Failed to execute delete_page") + + async def list_categories( + self, + page: int | None = None, + per_page: int | None = None, + search: str | None = None, + parent: int | None = None, + orderby: str | None = None, + order: str | None = None + ) -> WordPressResponse: + """List all categories + + Args: + page: Current page of the collection (default 1) + per_page: Maximum number of items per page (default 10, max 100) + search: Limit results to those matching a search string + parent: Limit to categories with a specific parent ID + orderby: Sort by attribute (id, include, name, slug, count, description) + order: Sort order (asc or desc) + + Returns: + WordPressResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + if search is not None: + query_params['search'] = search + if parent is not None: + query_params['parent'] = str(parent) + if orderby is not None: + query_params['orderby'] = orderby + if order is not None: + query_params['order'] = order + + url = self.base_url + "/categories" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WordPressResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_categories" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return WordPressResponse(success=False, error=str(e), message="Failed to execute list_categories") + + async def get_category( + self, + category_id: str + ) -> WordPressResponse: + """Get a specific category by ID + + Args: + category_id: The category ID + + Returns: + WordPressResponse with operation result + """ + url = self.base_url + "/categories/{category_id}".format(category_id=category_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WordPressResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_category" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return WordPressResponse(success=False, error=str(e), message="Failed to execute get_category") + + async def create_category( + self, + name: str, + description: str | None = None, + slug: str | None = None, + parent: int | None = None + ) -> WordPressResponse: + """Create a new category + + Args: + name: The name of the category + description: Category description + slug: Alphanumeric identifier for the category + parent: Parent category ID + + Returns: + WordPressResponse with operation result + """ + url = self.base_url + "/categories" + + body: dict[str, Any] = {} + body['name'] = name + if description is not None: + body['description'] = description + if slug is not None: + body['slug'] = slug + if parent is not None: + body['parent'] = parent + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WordPressResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed create_category" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return WordPressResponse(success=False, error=str(e), message="Failed to execute create_category") + + async def update_category( + self, + category_id: str, + name: str | None = None, + description: str | None = None, + slug: str | None = None, + parent: int | None = None + ) -> WordPressResponse: + """Update a category + + Args: + category_id: The category ID + name: The name of the category + description: Category description + slug: Alphanumeric identifier for the category + parent: Parent category ID + + Returns: + WordPressResponse with operation result + """ + url = self.base_url + "/categories/{category_id}".format(category_id=category_id) + + body: dict[str, Any] = {} + if name is not None: + body['name'] = name + if description is not None: + body['description'] = description + if slug is not None: + body['slug'] = slug + if parent is not None: + body['parent'] = parent + + try: + request = HTTPRequest( + method="PUT", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WordPressResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed update_category" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return WordPressResponse(success=False, error=str(e), message="Failed to execute update_category") + + async def delete_category( + self, + category_id: str + ) -> WordPressResponse: + """Delete a category + + Args: + category_id: The category ID + + Returns: + WordPressResponse with operation result + """ + url = self.base_url + "/categories/{category_id}".format(category_id=category_id) + + try: + request = HTTPRequest( + method="DELETE", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WordPressResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed delete_category" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return WordPressResponse(success=False, error=str(e), message="Failed to execute delete_category") + + async def list_tags( + self, + page: int | None = None, + per_page: int | None = None, + search: str | None = None, + orderby: str | None = None, + order: str | None = None + ) -> WordPressResponse: + """List all tags + + Args: + page: Current page of the collection (default 1) + per_page: Maximum number of items per page (default 10, max 100) + search: Limit results to those matching a search string + orderby: Sort by attribute (id, include, name, slug, count, description) + order: Sort order (asc or desc) + + Returns: + WordPressResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + if search is not None: + query_params['search'] = search + if orderby is not None: + query_params['orderby'] = orderby + if order is not None: + query_params['order'] = order + + url = self.base_url + "/tags" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WordPressResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_tags" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return WordPressResponse(success=False, error=str(e), message="Failed to execute list_tags") + + async def get_tag( + self, + tag_id: str + ) -> WordPressResponse: + """Get a specific tag by ID + + Args: + tag_id: The tag ID + + Returns: + WordPressResponse with operation result + """ + url = self.base_url + "/tags/{tag_id}".format(tag_id=tag_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WordPressResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_tag" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return WordPressResponse(success=False, error=str(e), message="Failed to execute get_tag") + + async def create_tag( + self, + name: str, + description: str | None = None, + slug: str | None = None + ) -> WordPressResponse: + """Create a new tag + + Args: + name: The name of the tag + description: Tag description + slug: Alphanumeric identifier for the tag + + Returns: + WordPressResponse with operation result + """ + url = self.base_url + "/tags" + + body: dict[str, Any] = {} + body['name'] = name + if description is not None: + body['description'] = description + if slug is not None: + body['slug'] = slug + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WordPressResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed create_tag" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return WordPressResponse(success=False, error=str(e), message="Failed to execute create_tag") + + async def update_tag( + self, + tag_id: str, + name: str | None = None, + description: str | None = None, + slug: str | None = None + ) -> WordPressResponse: + """Update a tag + + Args: + tag_id: The tag ID + name: The name of the tag + description: Tag description + slug: Alphanumeric identifier for the tag + + Returns: + WordPressResponse with operation result + """ + url = self.base_url + "/tags/{tag_id}".format(tag_id=tag_id) + + body: dict[str, Any] = {} + if name is not None: + body['name'] = name + if description is not None: + body['description'] = description + if slug is not None: + body['slug'] = slug + + try: + request = HTTPRequest( + method="PUT", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WordPressResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed update_tag" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return WordPressResponse(success=False, error=str(e), message="Failed to execute update_tag") + + async def delete_tag( + self, + tag_id: str + ) -> WordPressResponse: + """Delete a tag + + Args: + tag_id: The tag ID + + Returns: + WordPressResponse with operation result + """ + url = self.base_url + "/tags/{tag_id}".format(tag_id=tag_id) + + try: + request = HTTPRequest( + method="DELETE", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WordPressResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed delete_tag" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return WordPressResponse(success=False, error=str(e), message="Failed to execute delete_tag") + + async def list_comments( + self, + page: int | None = None, + per_page: int | None = None, + search: str | None = None, + after: str | None = None, + before: str | None = None, + post: int | None = None, + author: str | None = None, + status: str | None = None, + orderby: str | None = None, + order: str | None = None + ) -> WordPressResponse: + """List all comments + + Args: + page: Current page of the collection (default 1) + per_page: Maximum number of items per page (default 10, max 100) + search: Limit results to those matching a search string + after: Limit to comments published after a given ISO8601 date + before: Limit to comments published before a given ISO8601 date + post: Limit to comments for a specific post ID + author: Limit to comments by a specific author ID + status: Limit to comments with a specific status (approve, hold, spam, trash) + orderby: Sort by attribute (date, date_gmt, id, include, post, parent, type) + order: Sort order (asc or desc) + + Returns: + WordPressResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + if search is not None: + query_params['search'] = search + if after is not None: + query_params['after'] = after + if before is not None: + query_params['before'] = before + if post is not None: + query_params['post'] = str(post) + if author is not None: + query_params['author'] = author + if status is not None: + query_params['status'] = status + if orderby is not None: + query_params['orderby'] = orderby + if order is not None: + query_params['order'] = order + + url = self.base_url + "/comments" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WordPressResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_comments" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return WordPressResponse(success=False, error=str(e), message="Failed to execute list_comments") + + async def get_comment( + self, + comment_id: str + ) -> WordPressResponse: + """Get a specific comment by ID + + Args: + comment_id: The comment ID + + Returns: + WordPressResponse with operation result + """ + url = self.base_url + "/comments/{comment_id}".format(comment_id=comment_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WordPressResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_comment" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return WordPressResponse(success=False, error=str(e), message="Failed to execute get_comment") + + async def create_comment( + self, + post: int, + content: str, + author: int | None = None, + author_name: str | None = None, + author_email: str | None = None, + author_url: str | None = None, + parent: int | None = None, + status: str | None = None + ) -> WordPressResponse: + """Create a new comment + + Args: + post: The ID of the post the comment is for + content: The content of the comment + author: The ID of the comment author + author_name: Display name of the comment author + author_email: Email of the comment author + author_url: URL of the comment author + parent: Parent comment ID for threaded comments + status: Comment status (approve, hold, spam, trash) + + Returns: + WordPressResponse with operation result + """ + url = self.base_url + "/comments" + + body: dict[str, Any] = {} + body['post'] = post + body['content'] = content + if author is not None: + body['author'] = author + if author_name is not None: + body['author_name'] = author_name + if author_email is not None: + body['author_email'] = author_email + if author_url is not None: + body['author_url'] = author_url + if parent is not None: + body['parent'] = parent + if status is not None: + body['status'] = status + + try: + request = HTTPRequest( + method="POST", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WordPressResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed create_comment" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return WordPressResponse(success=False, error=str(e), message="Failed to execute create_comment") + + async def update_comment( + self, + comment_id: str, + content: str | None = None, + status: str | None = None, + author: int | None = None + ) -> WordPressResponse: + """Update a comment + + Args: + comment_id: The comment ID + content: The content of the comment + status: Comment status (approve, hold, spam, trash) + author: The ID of the comment author + + Returns: + WordPressResponse with operation result + """ + url = self.base_url + "/comments/{comment_id}".format(comment_id=comment_id) + + body: dict[str, Any] = {} + if content is not None: + body['content'] = content + if status is not None: + body['status'] = status + if author is not None: + body['author'] = author + + try: + request = HTTPRequest( + method="PUT", + url=url, + headers={"Content-Type": "application/json"}, + body=body, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WordPressResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed update_comment" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return WordPressResponse(success=False, error=str(e), message="Failed to execute update_comment") + + async def delete_comment( + self, + comment_id: str + ) -> WordPressResponse: + """Delete a comment + + Args: + comment_id: The comment ID + + Returns: + WordPressResponse with operation result + """ + url = self.base_url + "/comments/{comment_id}".format(comment_id=comment_id) + + try: + request = HTTPRequest( + method="DELETE", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WordPressResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed delete_comment" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return WordPressResponse(success=False, error=str(e), message="Failed to execute delete_comment") + + async def list_users( + self, + page: int | None = None, + per_page: int | None = None, + search: str | None = None, + roles: str | None = None, + orderby: str | None = None, + order: str | None = None + ) -> WordPressResponse: + """List all users + + Args: + page: Current page of the collection (default 1) + per_page: Maximum number of items per page (default 10, max 100) + search: Limit results to those matching a search string + roles: Limit to users with specific roles (comma-separated) + orderby: Sort by attribute (id, include, name, registered_date, slug, email, url) + order: Sort order (asc or desc) + + Returns: + WordPressResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + if search is not None: + query_params['search'] = search + if roles is not None: + query_params['roles'] = roles + if orderby is not None: + query_params['orderby'] = orderby + if order is not None: + query_params['order'] = order + + url = self.base_url + "/users" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WordPressResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_users" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return WordPressResponse(success=False, error=str(e), message="Failed to execute list_users") + + async def get_user( + self, + user_id: str + ) -> WordPressResponse: + """Get a specific user by ID + + Args: + user_id: The user ID + + Returns: + WordPressResponse with operation result + """ + url = self.base_url + "/users/{user_id}".format(user_id=user_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WordPressResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_user" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return WordPressResponse(success=False, error=str(e), message="Failed to execute get_user") + + async def get_current_user( + self + ) -> WordPressResponse: + """Get the current authenticated user + + Returns: + WordPressResponse with operation result + """ + url = self.base_url + "/users/me" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WordPressResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_current_user" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return WordPressResponse(success=False, error=str(e), message="Failed to execute get_current_user") + + async def list_media( + self, + page: int | None = None, + per_page: int | None = None, + search: str | None = None, + after: str | None = None, + before: str | None = None, + media_type: str | None = None, + mime_type: str | None = None, + orderby: str | None = None, + order: str | None = None + ) -> WordPressResponse: + """List all media items + + Args: + page: Current page of the collection (default 1) + per_page: Maximum number of items per page (default 10, max 100) + search: Limit results to those matching a search string + after: Limit to media uploaded after a given ISO8601 date + before: Limit to media uploaded before a given ISO8601 date + media_type: Limit to a specific media type (image, video, text, application, audio) + mime_type: Limit to a specific MIME type + orderby: Sort by attribute (date, relevance, id, include, title, slug) + order: Sort order (asc or desc) + + Returns: + WordPressResponse with operation result + """ + query_params: dict[str, Any] = {} + if page is not None: + query_params['page'] = str(page) + if per_page is not None: + query_params['per_page'] = str(per_page) + if search is not None: + query_params['search'] = search + if after is not None: + query_params['after'] = after + if before is not None: + query_params['before'] = before + if media_type is not None: + query_params['media_type'] = media_type + if mime_type is not None: + query_params['mime_type'] = mime_type + if orderby is not None: + query_params['orderby'] = orderby + if order is not None: + query_params['order'] = order + + url = self.base_url + "/media" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WordPressResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_media" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return WordPressResponse(success=False, error=str(e), message="Failed to execute list_media") + + async def get_media_item( + self, + media_id: str + ) -> WordPressResponse: + """Get a specific media item by ID + + Args: + media_id: The media item ID + + Returns: + WordPressResponse with operation result + """ + url = self.base_url + "/media/{media_id}".format(media_id=media_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WordPressResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_media_item" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return WordPressResponse(success=False, error=str(e), message="Failed to execute get_media_item") + + async def delete_media_item( + self, + media_id: str + ) -> WordPressResponse: + """Delete a media item + + Args: + media_id: The media item ID + + Returns: + WordPressResponse with operation result + """ + url = self.base_url + "/media/{media_id}".format(media_id=media_id) + + try: + request = HTTPRequest( + method="DELETE", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WordPressResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed delete_media_item" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return WordPressResponse(success=False, error=str(e), message="Failed to execute delete_media_item") + + async def list_post_types( + self + ) -> WordPressResponse: + """List all post types + + Returns: + WordPressResponse with operation result + """ + url = self.base_url + "/types" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WordPressResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_post_types" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return WordPressResponse(success=False, error=str(e), message="Failed to execute list_post_types") + + async def get_post_type( + self, + type_slug: str + ) -> WordPressResponse: + """Get a specific post type by slug + + Args: + type_slug: The post type slug (e.g., post, page) + + Returns: + WordPressResponse with operation result + """ + url = self.base_url + "/types/{type_slug}".format(type_slug=type_slug) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WordPressResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_post_type" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return WordPressResponse(success=False, error=str(e), message="Failed to execute get_post_type") + + async def list_post_statuses( + self + ) -> WordPressResponse: + """List all post statuses + + Returns: + WordPressResponse with operation result + """ + url = self.base_url + "/statuses" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WordPressResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_post_statuses" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return WordPressResponse(success=False, error=str(e), message="Failed to execute list_post_statuses") + + async def get_post_status( + self, + status_slug: str + ) -> WordPressResponse: + """Get a specific post status by slug + + Args: + status_slug: The status slug (e.g., publish, draft, pending) + + Returns: + WordPressResponse with operation result + """ + url = self.base_url + "/statuses/{status_slug}".format(status_slug=status_slug) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WordPressResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_post_status" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return WordPressResponse(success=False, error=str(e), message="Failed to execute get_post_status") + + async def list_taxonomies( + self + ) -> WordPressResponse: + """List all taxonomies + + Returns: + WordPressResponse with operation result + """ + url = self.base_url + "/taxonomies" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WordPressResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed list_taxonomies" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return WordPressResponse(success=False, error=str(e), message="Failed to execute list_taxonomies") + + async def get_taxonomy( + self, + taxonomy_slug: str + ) -> WordPressResponse: + """Get a specific taxonomy by slug + + Args: + taxonomy_slug: The taxonomy slug (e.g., category, post_tag) + + Returns: + WordPressResponse with operation result + """ + url = self.base_url + "/taxonomies/{taxonomy_slug}".format(taxonomy_slug=taxonomy_slug) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WordPressResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_taxonomy" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return WordPressResponse(success=False, error=str(e), message="Failed to execute get_taxonomy") + + async def search_content( + self, + search: str, + type_: str | None = None, + subtype: str | None = None, + per_page: int | None = None, + page: int | None = None + ) -> WordPressResponse: + """Search site content across multiple types + + Args: + search: The search term (required) + type_: Limit to a specific object type (post, term, post-format) + subtype: Limit to specific subtypes (post, page, category, tag, or any) + per_page: Maximum number of items per page (default 10, max 100) + page: Current page of the collection (default 1) + + Returns: + WordPressResponse with operation result + """ + query_params: dict[str, Any] = {} + query_params['search'] = search + if type_ is not None: + query_params['type'] = type_ + if subtype is not None: + query_params['subtype'] = subtype + if per_page is not None: + query_params['per_page'] = str(per_page) + if page is not None: + query_params['page'] = str(page) + + url = self.base_url + "/search" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WordPressResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed search_content" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return WordPressResponse(success=False, error=str(e), message="Failed to execute search_content") diff --git a/backend/python/app/sources/external/workplace/example.py b/backend/python/app/sources/external/workplace/example.py new file mode 100644 index 000000000..462bb12f2 --- /dev/null +++ b/backend/python/app/sources/external/workplace/example.py @@ -0,0 +1,193 @@ +# ruff: noqa + +""" +Facebook Workplace (Meta Workplace) API Usage Examples + +This example demonstrates how to use the Workplace DataSource to interact with +the Facebook Workplace API, covering: +- Authentication (Access Token from admin panel) +- Initializing the Client and DataSource +- Getting Current User +- Listing Community Members +- Listing Community Groups +- Getting Group Feed and Members +- Getting Posts and Comments +- Community Feeds + +Prerequisites: +1. Go to the Workplace admin panel +2. Create a custom integration and generate an access token +3. Set WORKPLACE_ACCESS_TOKEN environment variable +""" + +import asyncio +import json +import os + +from app.sources.client.workplace.workplace import ( + WorkplaceClient, + WorkplaceTokenConfig, + WorkplaceResponse, +) +from app.sources.external.workplace.workplace import WorkplaceDataSource + +# --- Configuration --- +ACCESS_TOKEN = os.getenv("WORKPLACE_ACCESS_TOKEN") + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: WorkplaceResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + data = response.data + # Handle paginated list-type responses (Graph API uses "data" key) + if isinstance(data, dict) and "data" in data: + items = data["data"] + if isinstance(items, list): + print(f" Found {len(items)} items.") + if items: + print(f" Sample: {json.dumps(items[0], indent=2)[:400]}...") + return + # Generic response + print(f" Data: {json.dumps(data, indent=2)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +async def main() -> None: + # 1. Initialize Client + print_section("Initializing Workplace Client") + + if not ACCESS_TOKEN: + print(" No valid authentication method found.") + print(" Please set WORKPLACE_ACCESS_TOKEN environment variable") + print(" (generated from the Workplace admin panel)") + return + + print(" Using Access Token (Bearer) authentication") + config = WorkplaceTokenConfig(token=ACCESS_TOKEN) + client = WorkplaceClient.build_with_config(config) + data_source = WorkplaceDataSource(client) + print("Client initialized successfully.") + + try: + # 2. Get Current User + print_section("Current User") + me_resp = await data_source.get_me(fields="id,name,email") + print_result("Get Me", me_resp) + + # 3. Get Community Members + print_section("Community Members") + members_resp = await data_source.get_community_members( + limit=5, fields="id,name,email" + ) + print_result("Get Community Members", members_resp) + + # Get a specific user if available + user_id = None + if members_resp.success and members_resp.data: + members_data = members_resp.data.get("data", []) + if isinstance(members_data, list) and members_data: + user_id = str(members_data[0].get("id", "")) + print(f" Using User: {members_data[0].get('name', 'N/A')} (ID: {user_id})") + + if user_id: + print_section(f"User Details: {user_id}") + user_resp = await data_source.get_user( + user_id=user_id, fields="id,name,email,department" + ) + print_result("Get User", user_resp) + + # Get User Feed + print_section("User Feed") + feed_resp = await data_source.get_user_feed(user_id=user_id, limit=3) + print_result("Get User Feed", feed_resp) + + # 4. Get Community Groups + print_section("Community Groups") + groups_resp = await data_source.get_community_groups( + limit=5, fields="id,name,description,privacy" + ) + print_result("Get Community Groups", groups_resp) + + # Explore first group if available + group_id = None + if groups_resp.success and groups_resp.data: + groups_data = groups_resp.data.get("data", []) + if isinstance(groups_data, list) and groups_data: + group_id = str(groups_data[0].get("id", "")) + print(f" Using Group: {groups_data[0].get('name', 'N/A')} (ID: {group_id})") + + if group_id: + # Get Group Details + print_section(f"Group Details: {group_id}") + group_resp = await data_source.get_group( + group_id=group_id, fields="id,name,description,privacy,member_count" + ) + print_result("Get Group", group_resp) + + # Get Group Feed + print_section("Group Feed") + group_feed_resp = await data_source.get_group_feed( + group_id=group_id, limit=3 + ) + print_result("Get Group Feed", group_feed_resp) + + # Get Group Members + print_section("Group Members") + group_members_resp = await data_source.get_group_members( + group_id=group_id, limit=5 + ) + print_result("Get Group Members", group_members_resp) + + # Get a post from group feed if available + post_id = None + if group_feed_resp.success and group_feed_resp.data: + feed_data = group_feed_resp.data.get("data", []) + if isinstance(feed_data, list) and feed_data: + post_id = str(feed_data[0].get("id", "")) + print(f" Using Post ID: {post_id}") + + if post_id: + # Get Post Details + print_section(f"Post Details: {post_id}") + post_resp = await data_source.get_post( + post_id=post_id, fields="id,message,created_time,from" + ) + print_result("Get Post", post_resp) + + # Get Post Comments + print_section("Post Comments") + comments_resp = await data_source.get_post_comments( + post_id=post_id, limit=5 + ) + print_result("Get Post Comments", comments_resp) + + # 5. Get Community Feeds + print_section("Community Feeds") + community_feeds_resp = await data_source.get_community_feeds(limit=5) + print_result("Get Community Feeds", community_feeds_resp) + + finally: + # Cleanup: Close the HTTP client session + print("\nClosing client connection...") + inner_client = client.get_client() + if hasattr(inner_client, "close"): + await inner_client.close() + + print("\n" + "=" * 80) + print(" All Workplace API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/python/app/sources/external/workplace/run_generator.py b/backend/python/app/sources/external/workplace/run_generator.py new file mode 100644 index 000000000..2838db4e9 --- /dev/null +++ b/backend/python/app/sources/external/workplace/run_generator.py @@ -0,0 +1,288 @@ +# ruff: noqa +""" +Facebook Workplace (Meta Workplace) DataSource Code Generator + +This script generates the WorkplaceDataSource class with all API endpoint +wrapper methods based on the Facebook Graph API v18.0 specification for Workplace. + +The generated code follows the pattern established by ClickUp and other +connectors in this project, using HTTPRequest/HTTPResponse for all API calls. + +Usage: + python -m app.sources.external.workplace.run_generator + +Output: + Prints the generated Python source code for the WorkplaceDataSource class + to stdout. Redirect to a file to save: + + python -m app.sources.external.workplace.run_generator > \ + app/sources/external/workplace/workplace.py +""" + +from __future__ import annotations + +ENDPOINTS = [ + { + "name": "get_me", + "method": "GET", + "path": "/me", + "doc": "Get the current authenticated user", + "path_params": [], + "query_params": [ + ("fields", "str | None", "fields", "Comma-separated list of fields to include"), + ], + "body_params": [], + }, + { + "name": "get_community_members", + "method": "GET", + "path": "/community/members", + "doc": "Get community members", + "path_params": [], + "query_params": [ + ("limit", "int | None", "limit", "Maximum number of results per page"), + ("after", "str | None", "after", "Cursor for pagination (next page)"), + ("fields", "str | None", "fields", "Comma-separated list of fields to include"), + ], + "body_params": [], + }, + { + "name": "get_user", + "method": "GET", + "path": "/{user_id}", + "doc": "Get a specific user by ID", + "path_params": [("user_id", "str", "The user ID")], + "query_params": [ + ("fields", "str | None", "fields", "Comma-separated list of fields to include"), + ], + "body_params": [], + }, + { + "name": "get_user_feed", + "method": "GET", + "path": "/{user_id}/feed", + "doc": "Get a user's feed", + "path_params": [("user_id", "str", "The user ID")], + "query_params": [ + ("limit", "int | None", "limit", "Maximum number of results per page"), + ("after", "str | None", "after", "Cursor for pagination (next page)"), + ("fields", "str | None", "fields", "Comma-separated list of fields to include"), + ], + "body_params": [], + }, + { + "name": "get_community_groups", + "method": "GET", + "path": "/community/groups", + "doc": "Get community groups", + "path_params": [], + "query_params": [ + ("limit", "int | None", "limit", "Maximum number of results per page"), + ("after", "str | None", "after", "Cursor for pagination (next page)"), + ("fields", "str | None", "fields", "Comma-separated list of fields to include"), + ], + "body_params": [], + }, + { + "name": "get_group", + "method": "GET", + "path": "/{group_id}", + "doc": "Get a specific group by ID", + "path_params": [("group_id", "str", "The group ID")], + "query_params": [ + ("fields", "str | None", "fields", "Comma-separated list of fields to include"), + ], + "body_params": [], + }, + { + "name": "get_group_feed", + "method": "GET", + "path": "/{group_id}/feed", + "doc": "Get a group's feed", + "path_params": [("group_id", "str", "The group ID")], + "query_params": [ + ("limit", "int | None", "limit", "Maximum number of results per page"), + ("after", "str | None", "after", "Cursor for pagination (next page)"), + ("fields", "str | None", "fields", "Comma-separated list of fields to include"), + ], + "body_params": [], + }, + { + "name": "get_group_members", + "method": "GET", + "path": "/{group_id}/members", + "doc": "Get members of a group", + "path_params": [("group_id", "str", "The group ID")], + "query_params": [ + ("limit", "int | None", "limit", "Maximum number of results per page"), + ("after", "str | None", "after", "Cursor for pagination (next page)"), + ("fields", "str | None", "fields", "Comma-separated list of fields to include"), + ], + "body_params": [], + }, + { + "name": "get_post", + "method": "GET", + "path": "/{post_id}", + "doc": "Get a specific post by ID", + "path_params": [("post_id", "str", "The post ID")], + "query_params": [ + ("fields", "str | None", "fields", "Comma-separated list of fields to include"), + ], + "body_params": [], + }, + { + "name": "get_post_comments", + "method": "GET", + "path": "/{post_id}/comments", + "doc": "Get comments on a specific post", + "path_params": [("post_id", "str", "The post ID")], + "query_params": [ + ("limit", "int | None", "limit", "Maximum number of results per page"), + ("after", "str | None", "after", "Cursor for pagination (next page)"), + ("fields", "str | None", "fields", "Comma-separated list of fields to include"), + ], + "body_params": [], + }, + { + "name": "get_community_feeds", + "method": "GET", + "path": "/community/feeds", + "doc": "Get community feeds", + "path_params": [], + "query_params": [ + ("limit", "int | None", "limit", "Maximum number of results per page"), + ("after", "str | None", "after", "Cursor for pagination (next page)"), + ("fields", "str | None", "fields", "Comma-separated list of fields to include"), + ], + "body_params": [], + }, +] + + +def generate_method(ep: dict) -> str: + """Generate a single async method for an endpoint.""" + name = ep["name"] + method = ep["method"] + path = ep["path"] + doc = ep["doc"] + path_params = ep.get("path_params", []) + query_params = ep.get("query_params", []) + + sig_parts = ["self"] + for pp_name, pp_type, _pp_doc in path_params: + sig_parts.append(f"{pp_name}: {pp_type}") + + optional_qp = [q for q in query_params if "None" in q[1]] + if optional_qp: + sig_parts.append("*") + for qp_name, qp_type, _qp_api, _qp_doc in optional_qp: + sig_parts.append(f"{qp_name}: {qp_type} = None") + + sig = ",\n ".join(sig_parts) + + doc_args = [] + for pp_name, _pp_type, pp_doc in path_params: + doc_args.append(f" {pp_name}: {pp_doc}") + for qp_name, _qp_type, _qp_api, qp_doc in query_params: + doc_args.append(f" {qp_name}: {qp_doc}") + + args_section = "" + if doc_args: + args_section = "\n\n Args:\n" + "\n".join(doc_args) + + qp_block = "" + if query_params: + qp_block = "\n query_params: dict[str, Any] = {}\n" + for qp_name, qp_type, qp_api, _ in query_params: + if "int" in qp_type: + qp_block += f" if {qp_name} is not None:\n query_params['{qp_api}'] = str({qp_name})\n" + else: + qp_block += f" if {qp_name} is not None:\n query_params['{qp_api}'] = {qp_name}\n" + + if path_params: + format_args = ", ".join(f"{p[0]}={p[0]}" for p in path_params) + url_line = f' url = self.base_url + "{path}".format({format_args})' + else: + url_line = f' url = self.base_url + "{path}"' + + req_kwargs = f'method="{method}",\n url=url,\n headers={{"Content-Type": "application/json"}}' + if query_params: + req_kwargs += ",\n query=query_params" + + return f''' async def {name}( + {sig} + ) -> WorkplaceResponse: + """{doc}{args_section} + + Returns: + WorkplaceResponse with operation result + """ +{qp_block}{url_line} + + try: + request = HTTPRequest( + {req_kwargs}, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WorkplaceResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed {name}" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {{response.status}}" + ) + except Exception as e: + return WorkplaceResponse(success=False, error=str(e), message="Failed to execute {name}") +''' + + +def generate_datasource() -> str: + """Generate the full WorkplaceDataSource module.""" + header = '''# ruff: noqa +""" +Facebook Workplace (Meta Workplace) REST API DataSource - Auto-generated API wrapper + +Generated from Facebook Graph API v18.0 documentation for Workplace. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.workplace.workplace import WorkplaceClient, WorkplaceResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class WorkplaceDataSource: + """Facebook Workplace REST API DataSource + + Provides async wrapper methods for Facebook Workplace API operations. + All methods return WorkplaceResponse objects. + """ + + def __init__(self, client: WorkplaceClient) -> None: + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'WorkplaceDataSource': + return self + + def get_client(self) -> WorkplaceClient: + return self._client + +''' + methods = "\n".join(generate_method(ep) for ep in ENDPOINTS) + return header + methods + + +if __name__ == "__main__": + print(generate_datasource()) diff --git a/backend/python/app/sources/external/workplace/workplace.py b/backend/python/app/sources/external/workplace/workplace.py new file mode 100644 index 000000000..db739994c --- /dev/null +++ b/backend/python/app/sources/external/workplace/workplace.py @@ -0,0 +1,551 @@ +# ruff: noqa +""" +Facebook Workplace (Meta Workplace) REST API DataSource - Auto-generated API wrapper + +Generated from Facebook Graph API v18.0 documentation for Workplace. +Uses HTTP client for direct REST API interactions. +All methods have explicit parameter signatures. +""" + +from __future__ import annotations + +from typing import Any + +from app.sources.client.workplace.workplace import WorkplaceClient, WorkplaceResponse +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class WorkplaceDataSource: + """Facebook Workplace REST API DataSource + + Provides async wrapper methods for Facebook Workplace API operations: + - Current user (me) + - Community members + - User profiles and feeds + - Community groups + - Group feeds and members + - Posts and comments + - Community feeds + + The base URL is https://graph.facebook.com/v18.0 by default. + + All methods return WorkplaceResponse objects. + """ + + def __init__(self, client: WorkplaceClient) -> None: + """Initialize with WorkplaceClient. + + Args: + client: WorkplaceClient instance with configured authentication + """ + self._client = client + self.http = client.get_client() + try: + self.base_url = self.http.get_base_url().rstrip('/') + except AttributeError as exc: + raise ValueError('HTTP client does not have get_base_url method') from exc + + def get_data_source(self) -> 'WorkplaceDataSource': + """Return the data source instance.""" + return self + + def get_client(self) -> WorkplaceClient: + """Return the underlying WorkplaceClient.""" + return self._client + + # ----------------------------------------------------------------------- + # Current User + # ----------------------------------------------------------------------- + + async def get_me( + self, + *, + fields: str | None = None, + ) -> WorkplaceResponse: + """Get the current authenticated user + + Args: + fields: Comma-separated list of fields to include + + Returns: + WorkplaceResponse with operation result + """ + query_params: dict[str, Any] = {} + if fields is not None: + query_params['fields'] = fields + + url = self.base_url + "/me" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WorkplaceResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_me" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return WorkplaceResponse(success=False, error=str(e), message="Failed to execute get_me") + + # ----------------------------------------------------------------------- + # Community Members + # ----------------------------------------------------------------------- + + async def get_community_members( + self, + *, + limit: int | None = None, + after: str | None = None, + fields: str | None = None, + ) -> WorkplaceResponse: + """Get community members + + Args: + limit: Maximum number of results per page + after: Cursor for pagination (next page) + fields: Comma-separated list of fields to include + + Returns: + WorkplaceResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if after is not None: + query_params['after'] = after + if fields is not None: + query_params['fields'] = fields + + url = self.base_url + "/community/members" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WorkplaceResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_community_members" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return WorkplaceResponse(success=False, error=str(e), message="Failed to execute get_community_members") + + # ----------------------------------------------------------------------- + # Users + # ----------------------------------------------------------------------- + + async def get_user( + self, + user_id: str, + *, + fields: str | None = None, + ) -> WorkplaceResponse: + """Get a specific user by ID + + Args: + user_id: The user ID + fields: Comma-separated list of fields to include + + Returns: + WorkplaceResponse with operation result + """ + query_params: dict[str, Any] = {} + if fields is not None: + query_params['fields'] = fields + + url = self.base_url + "/{user_id}".format(user_id=user_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WorkplaceResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_user" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return WorkplaceResponse(success=False, error=str(e), message="Failed to execute get_user") + + async def get_user_feed( + self, + user_id: str, + *, + limit: int | None = None, + after: str | None = None, + fields: str | None = None, + ) -> WorkplaceResponse: + """Get a user's feed + + Args: + user_id: The user ID + limit: Maximum number of results per page + after: Cursor for pagination (next page) + fields: Comma-separated list of fields to include + + Returns: + WorkplaceResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if after is not None: + query_params['after'] = after + if fields is not None: + query_params['fields'] = fields + + url = self.base_url + "/{user_id}/feed".format(user_id=user_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WorkplaceResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_user_feed" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return WorkplaceResponse(success=False, error=str(e), message="Failed to execute get_user_feed") + + # ----------------------------------------------------------------------- + # Community Groups + # ----------------------------------------------------------------------- + + async def get_community_groups( + self, + *, + limit: int | None = None, + after: str | None = None, + fields: str | None = None, + ) -> WorkplaceResponse: + """Get community groups + + Args: + limit: Maximum number of results per page + after: Cursor for pagination (next page) + fields: Comma-separated list of fields to include + + Returns: + WorkplaceResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if after is not None: + query_params['after'] = after + if fields is not None: + query_params['fields'] = fields + + url = self.base_url + "/community/groups" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WorkplaceResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_community_groups" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return WorkplaceResponse(success=False, error=str(e), message="Failed to execute get_community_groups") + + # ----------------------------------------------------------------------- + # Groups + # ----------------------------------------------------------------------- + + async def get_group( + self, + group_id: str, + *, + fields: str | None = None, + ) -> WorkplaceResponse: + """Get a specific group by ID + + Args: + group_id: The group ID + fields: Comma-separated list of fields to include + + Returns: + WorkplaceResponse with operation result + """ + query_params: dict[str, Any] = {} + if fields is not None: + query_params['fields'] = fields + + url = self.base_url + "/{group_id}".format(group_id=group_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WorkplaceResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_group" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return WorkplaceResponse(success=False, error=str(e), message="Failed to execute get_group") + + async def get_group_feed( + self, + group_id: str, + *, + limit: int | None = None, + after: str | None = None, + fields: str | None = None, + ) -> WorkplaceResponse: + """Get a group's feed + + Args: + group_id: The group ID + limit: Maximum number of results per page + after: Cursor for pagination (next page) + fields: Comma-separated list of fields to include + + Returns: + WorkplaceResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if after is not None: + query_params['after'] = after + if fields is not None: + query_params['fields'] = fields + + url = self.base_url + "/{group_id}/feed".format(group_id=group_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WorkplaceResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_group_feed" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return WorkplaceResponse(success=False, error=str(e), message="Failed to execute get_group_feed") + + async def get_group_members( + self, + group_id: str, + *, + limit: int | None = None, + after: str | None = None, + fields: str | None = None, + ) -> WorkplaceResponse: + """Get members of a group + + Args: + group_id: The group ID + limit: Maximum number of results per page + after: Cursor for pagination (next page) + fields: Comma-separated list of fields to include + + Returns: + WorkplaceResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if after is not None: + query_params['after'] = after + if fields is not None: + query_params['fields'] = fields + + url = self.base_url + "/{group_id}/members".format(group_id=group_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WorkplaceResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_group_members" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return WorkplaceResponse(success=False, error=str(e), message="Failed to execute get_group_members") + + # ----------------------------------------------------------------------- + # Posts + # ----------------------------------------------------------------------- + + async def get_post( + self, + post_id: str, + *, + fields: str | None = None, + ) -> WorkplaceResponse: + """Get a specific post by ID + + Args: + post_id: The post ID + fields: Comma-separated list of fields to include + + Returns: + WorkplaceResponse with operation result + """ + query_params: dict[str, Any] = {} + if fields is not None: + query_params['fields'] = fields + + url = self.base_url + "/{post_id}".format(post_id=post_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WorkplaceResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_post" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return WorkplaceResponse(success=False, error=str(e), message="Failed to execute get_post") + + async def get_post_comments( + self, + post_id: str, + *, + limit: int | None = None, + after: str | None = None, + fields: str | None = None, + ) -> WorkplaceResponse: + """Get comments on a specific post + + Args: + post_id: The post ID + limit: Maximum number of results per page + after: Cursor for pagination (next page) + fields: Comma-separated list of fields to include + + Returns: + WorkplaceResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if after is not None: + query_params['after'] = after + if fields is not None: + query_params['fields'] = fields + + url = self.base_url + "/{post_id}/comments".format(post_id=post_id) + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WorkplaceResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_post_comments" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return WorkplaceResponse(success=False, error=str(e), message="Failed to execute get_post_comments") + + # ----------------------------------------------------------------------- + # Community Feeds + # ----------------------------------------------------------------------- + + async def get_community_feeds( + self, + *, + limit: int | None = None, + after: str | None = None, + fields: str | None = None, + ) -> WorkplaceResponse: + """Get community feeds + + Args: + limit: Maximum number of results per page + after: Cursor for pagination (next page) + fields: Comma-separated list of fields to include + + Returns: + WorkplaceResponse with operation result + """ + query_params: dict[str, Any] = {} + if limit is not None: + query_params['limit'] = str(limit) + if after is not None: + query_params['after'] = after + if fields is not None: + query_params['fields'] = fields + + url = self.base_url + "/community/feeds" + + try: + request = HTTPRequest( + method="GET", + url=url, + headers={"Content-Type": "application/json"}, + query=query_params, + ) + response = await self.http.execute(request) # type: ignore[reportUnknownMemberType] + response_data = response.json() if response.text() else None + return WorkplaceResponse( + success=response.status < HTTP_ERROR_THRESHOLD, + data=response_data, + message="Successfully executed get_community_feeds" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {response.status}" + ) + except Exception as e: + return WorkplaceResponse(success=False, error=str(e), message="Failed to execute get_community_feeds") diff --git a/backend/python/app/sources/external/zoho/example.py b/backend/python/app/sources/external/zoho/example.py new file mode 100644 index 000000000..4fe043c7e --- /dev/null +++ b/backend/python/app/sources/external/zoho/example.py @@ -0,0 +1,146 @@ +# ruff: noqa +""" +Zoho CRM API Usage Examples + +This example demonstrates how to use the Zoho CRM DataSource to interact +with the Zoho CRM API, covering: +- Authentication (OAuth with grant_token or refresh_token) +- Initializing the Client and DataSource +- Listing modules, users, roles, profiles +- CRUD operations on records +- Searching records +- Organization info + +Prerequisites: +1. Create a Zoho CRM OAuth app at https://api-console.zoho.com/ +2. Set environment variables: + - ZOHO_CLIENT_ID: OAuth client ID + - ZOHO_CLIENT_SECRET: OAuth client secret + - ZOHO_GRANT_TOKEN: Grant token (for initial auth) OR + - ZOHO_REFRESH_TOKEN: Refresh token (for subsequent auth) + - ZOHO_DOMAIN: Data center domain (US, EU, IN, CN, AU, JP, CA) - defaults to US + +API Reference: https://www.zoho.com/crm/developer/docs/api/v7/ +""" + +import json +import os + +from app.sources.client.zoho.zoho import ( + ZohoClient, + ZohoGrantTokenConfig, + ZohoRefreshTokenConfig, + ZohoResponse, +) +from app.sources.external.zoho.zoho import ZohoDataSource + + +def print_section(title: str): + print(f"\n{'-'*80}") + print(f"| {title}") + print(f"{'-'*80}") + + +def print_result(name: str, response: ZohoResponse, show_data: bool = True): + if response.success: + print(f" {name}: Success") + if show_data and response.data: + if isinstance(response.data, list): + print(f" Found {len(response.data)} items") + if response.data: + print(f" Sample: {str(response.data[0])[:400]}...") + elif isinstance(response.data, dict): + print(f" Data: {json.dumps(response.data, indent=2, default=str)[:500]}...") + else: + print(f" {name}: Failed") + print(f" Error: {response.error}") + if response.message: + print(f" Message: {response.message}") + + +def main() -> None: + """Example usage of Zoho CRM API.""" + CLIENT_ID = os.getenv("ZOHO_CLIENT_ID") + CLIENT_SECRET = os.getenv("ZOHO_CLIENT_SECRET") + GRANT_TOKEN = os.getenv("ZOHO_GRANT_TOKEN") + REFRESH_TOKEN = os.getenv("ZOHO_REFRESH_TOKEN") + DOMAIN = os.getenv("ZOHO_DOMAIN", "US") + + if not CLIENT_ID or not CLIENT_SECRET: + print("Please set ZOHO_CLIENT_ID and ZOHO_CLIENT_SECRET environment variables") + return + + if not GRANT_TOKEN and not REFRESH_TOKEN: + print("Please set ZOHO_GRANT_TOKEN or ZOHO_REFRESH_TOKEN environment variable") + return + + # Initialize Zoho CRM client + print_section("Initializing Zoho CRM Client") + config = None + if REFRESH_TOKEN: + print(" Using refresh token authentication") + config = ZohoRefreshTokenConfig( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + refresh_token=REFRESH_TOKEN, + domain=DOMAIN, + ) + elif GRANT_TOKEN: + print(" Using grant token authentication") + config = ZohoGrantTokenConfig( + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + grant_token=GRANT_TOKEN, + domain=DOMAIN, + ) + + if config is None: + print(" No valid authentication method found.") + return + + client = ZohoClient.build_with_config(config) + data_source = ZohoDataSource(client) + print(" Client initialized successfully.") + + # 1. Organization info + print_section("Organization Info") + org_resp = data_source.get_organization() + print_result("Get Organization", org_resp) + + # 2. List modules + print_section("Modules") + modules_resp = data_source.list_modules() + print_result("List Modules", modules_resp) + + # 3. List users + print_section("Users") + users_resp = data_source.list_users() + print_result("List Users", users_resp) + + # 4. List roles + print_section("Roles") + roles_resp = data_source.list_roles() + print_result("List Roles", roles_resp) + + # 5. List profiles + print_section("Profiles") + profiles_resp = data_source.list_profiles() + print_result("List Profiles", profiles_resp) + + # 6. List records from Leads module + print_section("Leads Records") + leads_resp = data_source.list_records("Leads", per_page=5) + print_result("List Leads", leads_resp) + + # 7. Search records + print_section("Search Leads") + search_resp = data_source.search_records("Leads", word="test") + print_result("Search Leads", search_resp) + + print("\n" + "=" * 80) + print(" All Zoho CRM API operations tested!") + print("=" * 80) + + +if __name__ == "__main__": + main() diff --git a/backend/python/app/sources/external/zoho/zoho.py b/backend/python/app/sources/external/zoho/zoho.py new file mode 100644 index 000000000..90ab49e5b --- /dev/null +++ b/backend/python/app/sources/external/zoho/zoho.py @@ -0,0 +1,512 @@ +""" +Zoho CRM DataSource - API wrapper using the official Zoho CRM SDK. + +Provides typed wrapper methods for common Zoho CRM operations including +records, modules, users, roles, profiles, and organizations. + +All methods return ZohoResponse objects. +""" + +from __future__ import annotations + +from typing import Any, cast + +from app.sources.client.zoho.zoho import ZohoClient, ZohoClientViaOAuth, ZohoResponse + + +class ZohoDataSource: + """Zoho CRM DataSource + + Typed wrapper over the Zoho CRM SDK for common business operations. + + Accepts either a ZohoClient or a ZohoClientViaOAuth instance. + + Coverage: + - Records: list, get, create, update, delete, search + - Modules: list, get + - Users: list, get + - Roles: list + - Profiles: list + - Organizations: list, get + """ + + def __init__(self, client_or_wrapper: ZohoClient | ZohoClientViaOAuth) -> None: + """Initialize with a Zoho CRM client. + + Args: + client_or_wrapper: ZohoClient or ZohoClientViaOAuth instance + """ + if isinstance(client_or_wrapper, ZohoClient): + self._sdk: ZohoClientViaOAuth = client_or_wrapper.get_client() + else: + self._sdk = client_or_wrapper + self._sdk.ensure_initialized() + + def get_data_source(self) -> "ZohoDataSource": + """Return the data source instance.""" + return self + + # ========================================================================= + # RECORD OPERATIONS + # ========================================================================= + + def list_records( + self, + module: str, + page: int | None = None, + per_page: int | None = None, + fields: list[str] | None = None, + ) -> ZohoResponse: + """List records from a module. + + Args: + module: Module API name (e.g., 'Leads', 'Contacts', 'Deals') + page: Page number (1-based) + per_page: Records per page (max 200) + fields: List of field API names to return + + Returns: + ZohoResponse with list of records + """ + try: + record_ops = self._sdk.get_record_operations(module) + param_instance = self._build_get_records_param(page, per_page, fields) + resp =record_ops.get_records(param_instance) # type: ignore[no-untyped-call] + return self._parse_sdk_response(resp, "list_records") # type: ignore[reportUnknownArgumentType] + except Exception as e: + return ZohoResponse( + success=False, error=str(e), message="Failed to list records" + ) + + def get_record(self, module: str, record_id: str) -> ZohoResponse: + """Get a single record by ID. + + Args: + module: Module API name + record_id: Record ID + + Returns: + ZohoResponse with record data + """ + try: + record_ops = self._sdk.get_record_operations(module) + resp =record_ops.get_record(int(record_id)) # type: ignore[no-untyped-call] + return self._parse_sdk_response(resp, "get_record") # type: ignore[reportUnknownArgumentType] + except Exception as e: + return ZohoResponse( + success=False, error=str(e), message="Failed to get record" + ) + + def create_record( + self, + module: str, + data: dict[str, Any], + ) -> ZohoResponse: + """Create a record in a module. + + Args: + module: Module API name + data: Record field data as a dictionary + + Returns: + ZohoResponse with created record info + """ + try: + from zohocrmsdk.src.com.zoho.crm.api.record import ( # type: ignore[import-untyped] + BodyWrapper, + Record, + ) + + record = Record() # type: ignore[no-untyped-call] + for key, value in data.items(): + record.add_key_value(key, value) # type: ignore[no-untyped-call] + + body = BodyWrapper() # type: ignore[no-untyped-call] + body.set_data([record]) # type: ignore[no-untyped-call] + + record_ops = self._sdk.get_record_operations(module) + resp =record_ops.create_records(body) # type: ignore[no-untyped-call] + return self._parse_sdk_response(resp, "create_record") # type: ignore[reportUnknownArgumentType] + except Exception as e: + return ZohoResponse( + success=False, error=str(e), message="Failed to create record" + ) + + def update_record( + self, + module: str, + record_id: str, + data: dict[str, Any], + ) -> ZohoResponse: + """Update a record in a module. + + Args: + module: Module API name + record_id: Record ID to update + data: Record field data to update + + Returns: + ZohoResponse with updated record info + """ + try: + from zohocrmsdk.src.com.zoho.crm.api.record import ( # type: ignore[import-untyped] + BodyWrapper, + Record, + ) + + record = Record() # type: ignore[no-untyped-call] + record.set_id(int(record_id)) # type: ignore[no-untyped-call] + for key, value in data.items(): + record.add_key_value(key, value) # type: ignore[no-untyped-call] + + body = BodyWrapper() # type: ignore[no-untyped-call] + body.set_data([record]) # type: ignore[no-untyped-call] + + record_ops = self._sdk.get_record_operations(module) + resp =record_ops.update_records(body) # type: ignore[no-untyped-call] + return self._parse_sdk_response(resp, "update_record") # type: ignore[reportUnknownArgumentType] + except Exception as e: + return ZohoResponse( + success=False, error=str(e), message="Failed to update record" + ) + + def delete_record(self, module: str, record_id: str) -> ZohoResponse: + """Delete a record from a module. + + Args: + module: Module API name + record_id: Record ID to delete + + Returns: + ZohoResponse with deletion result + """ + try: + from zohocrmsdk.src.com.zoho.crm.api import ( + ParameterMap, # type: ignore[import-untyped] + ) + from zohocrmsdk.src.com.zoho.crm.api.record import ( # type: ignore[import-untyped] + DeleteRecordsParam, + ) + + param = ParameterMap() # type: ignore[no-untyped-call] + param.add(DeleteRecordsParam.ids, record_id) # type: ignore[no-untyped-call] + + record_ops = self._sdk.get_record_operations(module) + resp =record_ops.delete_records(param) # type: ignore[no-untyped-call] + return self._parse_sdk_response(resp, "delete_record") # type: ignore[reportUnknownArgumentType] + except Exception as e: + return ZohoResponse( + success=False, error=str(e), message="Failed to delete record" + ) + + def search_records( + self, + module: str, + criteria: str | None = None, + email: str | None = None, + phone: str | None = None, + word: str | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> ZohoResponse: + """Search records in a module. + + Args: + module: Module API name + criteria: Search criteria string (e.g., '(Last_Name:equals:Burns)') + email: Search by email + phone: Search by phone + word: Search by keyword + page: Page number + per_page: Records per page + + Returns: + ZohoResponse with matching records + """ + try: + from zohocrmsdk.src.com.zoho.crm.api import ( + ParameterMap, # type: ignore[import-untyped] + ) + from zohocrmsdk.src.com.zoho.crm.api.record import ( # type: ignore[import-untyped] + SearchRecordsParam, + ) + + param = ParameterMap() # type: ignore[no-untyped-call] + if criteria: + param.add(SearchRecordsParam.criteria, criteria) # type: ignore[no-untyped-call] + if email: + param.add(SearchRecordsParam.email, email) # type: ignore[no-untyped-call] + if phone: + param.add(SearchRecordsParam.phone, phone) # type: ignore[no-untyped-call] + if word: + param.add(SearchRecordsParam.word, word) # type: ignore[no-untyped-call] + if page is not None: + param.add(SearchRecordsParam.page, page) # type: ignore[no-untyped-call] + if per_page is not None: + param.add(SearchRecordsParam.per_page, per_page) # type: ignore[no-untyped-call] + + record_ops = self._sdk.get_record_operations(module) + resp =record_ops.search_records(param) # type: ignore[no-untyped-call] + return self._parse_sdk_response(resp, "search_records") # type: ignore[reportUnknownArgumentType] + except Exception as e: + return ZohoResponse( + success=False, error=str(e), message="Failed to search records" + ) + + # ========================================================================= + # MODULE OPERATIONS + # ========================================================================= + + def list_modules(self) -> ZohoResponse: + """List all available modules. + + Returns: + ZohoResponse with list of modules + """ + try: + modules_ops = self._sdk.get_modules_operations() + resp =modules_ops.get_modules() # type: ignore[no-untyped-call] + return self._parse_sdk_response(resp, "list_modules") # type: ignore[reportUnknownArgumentType] + except Exception as e: + return ZohoResponse( + success=False, error=str(e), message="Failed to list modules" + ) + + def get_module(self, module_api_name: str) -> ZohoResponse: + """Get details of a specific module. + + Args: + module_api_name: Module API name + + Returns: + ZohoResponse with module details + """ + try: + modules_ops = self._sdk.get_modules_operations() + resp =modules_ops.get_module(module_api_name) # type: ignore[no-untyped-call] + return self._parse_sdk_response(resp, "get_module") # type: ignore[reportUnknownArgumentType] + except Exception as e: + return ZohoResponse( + success=False, error=str(e), message="Failed to get module" + ) + + # ========================================================================= + # USER OPERATIONS + # ========================================================================= + + def list_users(self, user_type: str | None = None) -> ZohoResponse: + """List users in the organization. + + Args: + user_type: Filter by user type (e.g., 'AllUsers', 'ActiveUsers', + 'DeactiveUsers', 'AdminUsers') + + Returns: + ZohoResponse with list of users + """ + try: + from zohocrmsdk.src.com.zoho.crm.api import ( + ParameterMap, # type: ignore[import-untyped] + ) + from zohocrmsdk.src.com.zoho.crm.api.users import ( + GetUsersParam, # type: ignore[import-untyped] + ) + + users_ops = self._sdk.get_users_operations() + + param = ParameterMap() # type: ignore[no-untyped-call] + if user_type: + param.add(GetUsersParam.type, user_type) # type: ignore[no-untyped-call] + + resp =users_ops.get_users(param) # type: ignore[no-untyped-call] + return self._parse_sdk_response(resp, "list_users") # type: ignore[reportUnknownArgumentType] + except Exception as e: + return ZohoResponse( + success=False, error=str(e), message="Failed to list users" + ) + + def get_user(self, user_id: str) -> ZohoResponse: + """Get a single user by ID. + + Args: + user_id: User ID + + Returns: + ZohoResponse with user details + """ + try: + users_ops = self._sdk.get_users_operations() + resp =users_ops.get_user(int(user_id)) # type: ignore[no-untyped-call] + return self._parse_sdk_response(resp, "get_user") # type: ignore[reportUnknownArgumentType] + except Exception as e: + return ZohoResponse( + success=False, error=str(e), message="Failed to get user" + ) + + # ========================================================================= + # ROLE OPERATIONS + # ========================================================================= + + def list_roles(self) -> ZohoResponse: + """List all roles in the organization. + + Returns: + ZohoResponse with list of roles + """ + try: + roles_ops = self._sdk.get_roles_operations() + resp =roles_ops.get_roles() # type: ignore[no-untyped-call] + return self._parse_sdk_response(resp, "list_roles") # type: ignore[reportUnknownArgumentType] + except Exception as e: + return ZohoResponse( + success=False, error=str(e), message="Failed to list roles" + ) + + # ========================================================================= + # PROFILE OPERATIONS + # ========================================================================= + + def list_profiles(self) -> ZohoResponse: + """List all profiles in the organization. + + Returns: + ZohoResponse with list of profiles + """ + try: + profiles_ops = self._sdk.get_profiles_operations() + resp =profiles_ops.get_profiles() # type: ignore[no-untyped-call] + return self._parse_sdk_response(resp, "list_profiles") # type: ignore[reportUnknownArgumentType] + except Exception as e: + return ZohoResponse( + success=False, error=str(e), message="Failed to list profiles" + ) + + # ========================================================================= + # ORGANIZATION OPERATIONS + # ========================================================================= + + def list_organizations(self) -> ZohoResponse: + """List organization information. + + Note: Zoho CRM returns a single organization. This method wraps + get_organization for API consistency. + + Returns: + ZohoResponse with organization data + """ + return self.get_organization() + + def get_organization(self) -> ZohoResponse: + """Get the current organization details. + + Returns: + ZohoResponse with organization details + """ + try: + org_ops = self._sdk.get_org_operations() + resp =org_ops.get_organization() # type: ignore[no-untyped-call] + return self._parse_sdk_response(resp, "get_organization") # type: ignore[reportUnknownArgumentType] + except Exception as e: + return ZohoResponse( + success=False, + error=str(e), + message="Failed to get organization", + ) + + # ========================================================================= + # HELPERS + # ========================================================================= + + @staticmethod + def _build_get_records_param( + page: int | None = None, + per_page: int | None = None, + fields: list[str] | None = None, + ) -> object: + """Build a ParameterMap for get_records. + + Args: + page: Page number + per_page: Records per page + fields: List of field API names + + Returns: + ParameterMap instance + """ + from zohocrmsdk.src.com.zoho.crm.api import ( + ParameterMap, # type: ignore[import-untyped] + ) + from zohocrmsdk.src.com.zoho.crm.api.record import ( + GetRecordsParam, # type: ignore[import-untyped] + ) + + param = ParameterMap() # type: ignore[no-untyped-call] + if page is not None: + param.add(GetRecordsParam.page, page) # type: ignore[no-untyped-call] + if per_page is not None: + param.add(GetRecordsParam.per_page, per_page) # type: ignore[no-untyped-call] + if fields: + param.add(GetRecordsParam.fields, ",".join(fields)) # type: ignore[no-untyped-call] + return param + + @staticmethod + def _parse_sdk_response(response: object, operation: str) -> ZohoResponse: + """Parse a Zoho CRM SDK response into a ZohoResponse. + + The SDK returns an APIResponse object with status_code and object fields. + + Args: + response: SDK API response object + operation: Name of the operation for error messages + + Returns: + ZohoResponse instance + """ + if response is None: + return ZohoResponse( + success=False, + message=f"No response from {operation}", + ) + + try: + status_code = getattr(response, "status_code", None) + if callable(status_code): + status_code = status_code() + + response_object = getattr(response, "object", None) + if callable(response_object): + response_object = response_object() + + is_success = status_code is not None and int(cast(Any, status_code)) < 400 + + # Try to extract data from the response object + data: dict[str, object] | list[object] | None = None + if response_object is not None: + if hasattr(response_object, "get_data"): + raw_data = response_object.get_data() # type: ignore[union-attr] + if isinstance(raw_data, list): + data = cast(list[object], raw_data) + elif isinstance(raw_data, dict): + data = cast(dict[str, object], raw_data) + else: + data = {"result": raw_data} + elif hasattr(response_object, "__dict__"): + data = cast(dict[str, object], vars(response_object)) + else: + data = {"result": response_object} + + return ZohoResponse( + success=is_success, + data=data, + message=( + f"Successfully executed {operation}" + if is_success + else f"Failed to execute {operation} (status: {status_code})" + ), + ) + except Exception as e: + return ZohoResponse( + success=False, + error=str(e), + message=f"Failed to parse response from {operation}", + ) diff --git a/backend/python/code-generator/aha.py b/backend/python/code-generator/aha.py new file mode 100644 index 000000000..fefadcdad --- /dev/null +++ b/backend/python/code-generator/aha.py @@ -0,0 +1,692 @@ +# ruff: noqa +""" +Aha! REST API Code Generator + +Generates AhaDataSource class covering Aha! API v1: +- User profile and management +- Product management +- Feature CRUD operations +- Idea management +- Release management +- Goal operations +- Epic management +- Integration listing + +The generated DataSource accepts an AhaClient and uses the client's +configured subdomain-based base URL. Methods are generated for all +API endpoints. + +All methods have explicit parameter signatures with no **kwargs usage. +""" + +import sys +from pathlib import Path +from typing import Dict, List, Optional + +# ================================================================================ +# Aha! API Endpoints - organized by resource +# +# Each endpoint defines: +# method: HTTP verb +# path: URL path (appended to base_url which is https://{subdomain}.aha.io/api/v1) +# description: Human-readable description +# parameters: Dict of param_name -> {type, location (path/query/body), description} +# required: List of required parameter names +# version: API version tag +# ================================================================================ + +AHA_API_ENDPOINTS = { + # ================================================================================ + # USERS + # ================================================================================ + "get_current_user": { + "method": "GET", + "path": "/me", + "description": "Get the current authenticated user details", + "parameters": {}, + "required": [], + "version": "v1", + }, + "list_users": { + "method": "GET", + "path": "/users", + "description": "List all users in the account", + "parameters": { + "page": {"type": "Optional[int]", "location": "query", "description": "Page number for pagination"}, + "per_page": {"type": "Optional[int]", "location": "query", "description": "Number of results per page"}, + }, + "required": [], + "version": "v1", + }, + "get_user": { + "method": "GET", + "path": "/users/{user_id}", + "description": "Get a specific user by ID", + "parameters": { + "user_id": {"type": "str", "location": "path", "description": "The user ID"}, + }, + "required": ["user_id"], + "version": "v1", + }, + + # ================================================================================ + # PRODUCTS + # ================================================================================ + "list_products": { + "method": "GET", + "path": "/products", + "description": "List all products in the account", + "parameters": { + "page": {"type": "Optional[int]", "location": "query", "description": "Page number for pagination"}, + "per_page": {"type": "Optional[int]", "location": "query", "description": "Number of results per page"}, + }, + "required": [], + "version": "v1", + }, + "get_product": { + "method": "GET", + "path": "/products/{product_id}", + "description": "Get a specific product by ID", + "parameters": { + "product_id": {"type": "str", "location": "path", "description": "The product ID"}, + }, + "required": ["product_id"], + "version": "v1", + }, + + # ================================================================================ + # FEATURES + # ================================================================================ + "list_product_features": { + "method": "GET", + "path": "/products/{product_id}/features", + "description": "List all features for a product", + "parameters": { + "product_id": {"type": "str", "location": "path", "description": "The product ID"}, + "page": {"type": "Optional[int]", "location": "query", "description": "Page number for pagination"}, + "per_page": {"type": "Optional[int]", "location": "query", "description": "Number of results per page"}, + "q": {"type": "Optional[str]", "location": "query", "description": "Search query string"}, + "assigned_to_user": {"type": "Optional[str]", "location": "query", "description": "Filter by assigned user"}, + }, + "required": ["product_id"], + "version": "v1", + }, + "get_feature": { + "method": "GET", + "path": "/features/{feature_id}", + "description": "Get a specific feature by ID", + "parameters": { + "feature_id": {"type": "str", "location": "path", "description": "The feature ID"}, + }, + "required": ["feature_id"], + "version": "v1", + }, + "create_feature": { + "method": "POST", + "path": "/products/{product_id}/features", + "description": "Create a new feature in a product", + "parameters": { + "product_id": {"type": "str", "location": "path", "description": "The product ID"}, + "name": {"type": "str", "location": "body", "description": "The name of the feature"}, + "description": {"type": "Optional[str]", "location": "body", "description": "The feature description"}, + "workflow_status": {"type": "Optional[str]", "location": "body", "description": "The workflow status"}, + "assigned_to_user": {"type": "Optional[str]", "location": "body", "description": "User to assign the feature to"}, + "due_date": {"type": "Optional[str]", "location": "body", "description": "Due date in YYYY-MM-DD format"}, + "start_date": {"type": "Optional[str]", "location": "body", "description": "Start date in YYYY-MM-DD format"}, + "release": {"type": "Optional[str]", "location": "body", "description": "Release to associate the feature with"}, + "tags": {"type": "Optional[str]", "location": "body", "description": "Comma-separated list of tags"}, + }, + "required": ["product_id", "name"], + "version": "v1", + }, + "update_feature": { + "method": "PUT", + "path": "/features/{feature_id}", + "description": "Update an existing feature", + "parameters": { + "feature_id": {"type": "str", "location": "path", "description": "The feature ID"}, + "name": {"type": "Optional[str]", "location": "body", "description": "The name of the feature"}, + "description": {"type": "Optional[str]", "location": "body", "description": "The feature description"}, + "workflow_status": {"type": "Optional[str]", "location": "body", "description": "The workflow status"}, + "assigned_to_user": {"type": "Optional[str]", "location": "body", "description": "User to assign the feature to"}, + "due_date": {"type": "Optional[str]", "location": "body", "description": "Due date in YYYY-MM-DD format"}, + "start_date": {"type": "Optional[str]", "location": "body", "description": "Start date in YYYY-MM-DD format"}, + "release": {"type": "Optional[str]", "location": "body", "description": "Release to associate the feature with"}, + "tags": {"type": "Optional[str]", "location": "body", "description": "Comma-separated list of tags"}, + }, + "required": ["feature_id"], + "version": "v1", + }, + + # ================================================================================ + # IDEAS + # ================================================================================ + "list_product_ideas": { + "method": "GET", + "path": "/products/{product_id}/ideas", + "description": "List all ideas for a product", + "parameters": { + "product_id": {"type": "str", "location": "path", "description": "The product ID"}, + "page": {"type": "Optional[int]", "location": "query", "description": "Page number for pagination"}, + "per_page": {"type": "Optional[int]", "location": "query", "description": "Number of results per page"}, + }, + "required": ["product_id"], + "version": "v1", + }, + "get_idea": { + "method": "GET", + "path": "/ideas/{idea_id}", + "description": "Get a specific idea by ID", + "parameters": { + "idea_id": {"type": "str", "location": "path", "description": "The idea ID"}, + }, + "required": ["idea_id"], + "version": "v1", + }, + + # ================================================================================ + # RELEASES + # ================================================================================ + "list_product_releases": { + "method": "GET", + "path": "/products/{product_id}/releases", + "description": "List all releases for a product", + "parameters": { + "product_id": {"type": "str", "location": "path", "description": "The product ID"}, + "page": {"type": "Optional[int]", "location": "query", "description": "Page number for pagination"}, + "per_page": {"type": "Optional[int]", "location": "query", "description": "Number of results per page"}, + }, + "required": ["product_id"], + "version": "v1", + }, + "get_release": { + "method": "GET", + "path": "/releases/{release_id}", + "description": "Get a specific release by ID", + "parameters": { + "release_id": {"type": "str", "location": "path", "description": "The release ID"}, + }, + "required": ["release_id"], + "version": "v1", + }, + + # ================================================================================ + # GOALS + # ================================================================================ + "list_product_goals": { + "method": "GET", + "path": "/products/{product_id}/goals", + "description": "List all goals for a product", + "parameters": { + "product_id": {"type": "str", "location": "path", "description": "The product ID"}, + }, + "required": ["product_id"], + "version": "v1", + }, + "get_goal": { + "method": "GET", + "path": "/goals/{goal_id}", + "description": "Get a specific goal by ID", + "parameters": { + "goal_id": {"type": "str", "location": "path", "description": "The goal ID"}, + }, + "required": ["goal_id"], + "version": "v1", + }, + + # ================================================================================ + # EPICS + # ================================================================================ + "list_product_epics": { + "method": "GET", + "path": "/products/{product_id}/epics", + "description": "List all epics for a product", + "parameters": { + "product_id": {"type": "str", "location": "path", "description": "The product ID"}, + "page": {"type": "Optional[int]", "location": "query", "description": "Page number for pagination"}, + "per_page": {"type": "Optional[int]", "location": "query", "description": "Number of results per page"}, + }, + "required": ["product_id"], + "version": "v1", + }, + "get_epic": { + "method": "GET", + "path": "/epics/{epic_id}", + "description": "Get a specific epic by ID", + "parameters": { + "epic_id": {"type": "str", "location": "path", "description": "The epic ID"}, + }, + "required": ["epic_id"], + "version": "v1", + }, + + # ================================================================================ + # INTEGRATIONS + # ================================================================================ + "list_product_integrations": { + "method": "GET", + "path": "/products/{product_id}/integrations", + "description": "List all integrations for a product", + "parameters": { + "product_id": {"type": "str", "location": "path", "description": "The product ID"}, + }, + "required": ["product_id"], + "version": "v1", + }, +} + + +class AhaDataSourceGenerator: + """Generator for comprehensive Aha! REST API datasource class. + + Generates methods for Aha! API v1 endpoints. + The generated DataSource class accepts an AhaClient whose base URL + is https://{subdomain}.aha.io/api/v1. + """ + + def __init__(self): + self.generated_methods: List[Dict[str, str]] = [] + + def _sanitize_parameter_name(self, name: str) -> str: + """Sanitize parameter names to be valid Python identifiers.""" + sanitized = name.replace("-", "_").replace(".", "_").replace("/", "_") + if sanitized and not (sanitized[0].isalpha() or sanitized[0] == "_"): + sanitized = f"param_{sanitized}" + return sanitized + + def _build_query_params(self, endpoint_info: Dict) -> List[str]: + """Build query parameter handling code.""" + lines = [" query_params: dict[str, Any] = {}"] + + for param_name, param_info in endpoint_info["parameters"].items(): + if param_info["location"] == "query": + sanitized_name = self._sanitize_parameter_name(param_name) + + if "Optional[bool]" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}'] = str({sanitized_name}).lower()", + ]) + elif "Optional[int]" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}'] = str({sanitized_name})", + ]) + elif "List[" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}[]'] = {sanitized_name}", + ]) + else: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}'] = {sanitized_name}", + ]) + + return lines + + def _build_path_formatting(self, path: str, endpoint_info: Dict) -> str: + """Build URL path with parameter substitution.""" + path_params = [ + name + for name, info in endpoint_info["parameters"].items() + if info["location"] == "path" + ] + + if path_params: + format_dict = ", ".join( + f"{param}={self._sanitize_parameter_name(param)}" + for param in path_params + ) + return f' url = self.base_url + "{path}".format({format_dict})' + else: + return f' url = self.base_url + "{path}"' + + def _build_request_body(self, endpoint_info: Dict) -> List[str]: + """Build request body handling.""" + body_params = { + name: info + for name, info in endpoint_info["parameters"].items() + if info["location"] == "body" + } + + if not body_params: + return [] + + lines = [" body: dict[str, Any] = {}"] + + for param_name, param_info in body_params.items(): + sanitized_name = self._sanitize_parameter_name(param_name) + + if param_name in endpoint_info["required"]: + lines.append(f" body['{param_name}'] = {sanitized_name}") + else: + lines.extend([ + f" if {sanitized_name} is not None:", + f" body['{param_name}'] = {sanitized_name}", + ]) + + return lines + + @staticmethod + def _modernize_type(type_str: str) -> str: + """Convert typing-style annotations to modern Python 3.10+ syntax.""" + if type_str.startswith("Optional[") and type_str.endswith("]"): + inner = type_str[len("Optional["):-1] + inner = AhaDataSourceGenerator._modernize_type(inner) + return f"{inner} | None" + if type_str.startswith("Dict["): + inner = type_str[len("Dict["):-1] + parts = AhaDataSourceGenerator._split_type_args(inner) + modernized = ", ".join( + AhaDataSourceGenerator._modernize_type(p.strip()) for p in parts + ) + return f"dict[{modernized}]" + if type_str == "Dict": + return "dict" + if type_str.startswith("List["): + inner = type_str[len("List["):-1] + parts = AhaDataSourceGenerator._split_type_args(inner) + modernized = ", ".join( + AhaDataSourceGenerator._modernize_type(p.strip()) for p in parts + ) + return f"list[{modernized}]" + if type_str == "List": + return "list" + return type_str + + @staticmethod + def _split_type_args(s: str) -> List[str]: + """Split type arguments respecting nested brackets.""" + parts = [] + depth = 0 + current = "" + for ch in s: + if ch == "[": + depth += 1 + elif ch == "]": + depth -= 1 + if ch == "," and depth == 0: + parts.append(current.strip()) + current = "" + else: + current += ch + if current.strip(): + parts.append(current.strip()) + return parts + + def _generate_method_signature(self, method_name: str, endpoint_info: Dict) -> str: + """Generate method signature with explicit parameters.""" + params = ["self"] + has_any_bool = False + + # Collect required params, split into non-bool and bool groups + required_non_bool: List[str] = [] + required_bool: List[str] = [] + for param_name in endpoint_info["required"]: + if param_name in endpoint_info["parameters"]: + param_info = endpoint_info["parameters"][param_name] + sanitized_name = self._sanitize_parameter_name(param_name) + modern_type = self._modernize_type(param_info["type"]) + param_str = f"{sanitized_name}: {modern_type}" + if "bool" in param_info.get("type", ""): + required_bool.append(param_str) + has_any_bool = True + else: + required_non_bool.append(param_str) + + # Collect optional parameters + optional_params: List[str] = [] + for param_name, param_info in endpoint_info["parameters"].items(): + if param_name not in endpoint_info["required"]: + sanitized_name = self._sanitize_parameter_name(param_name) + modern_type = self._modernize_type(param_info["type"]) + if "| None" not in modern_type: + modern_type = f"{modern_type} | None" + optional_params.append(f"{sanitized_name}: {modern_type} = None") + if "bool" in param_info.get("type", ""): + has_any_bool = True + + # Build signature: non-bool required first, then * if needed, then bool required + optional + params.extend(required_non_bool) + if has_any_bool and (required_bool or optional_params): + params.append("*") + params.extend(required_bool) + params.extend(optional_params) + + signature_params = ",\n ".join(params) + return f" async def {method_name}(\n {signature_params}\n ) -> AhaResponse:" + + def _generate_method_docstring(self, endpoint_info: Dict) -> List[str]: + """Generate method docstring.""" + version = endpoint_info.get("version", "v1") + lines = [f' """{endpoint_info["description"]} (API {version})', ""] + + if endpoint_info["parameters"]: + lines.append(" Args:") + for param_name, param_info in endpoint_info["parameters"].items(): + sanitized_name = self._sanitize_parameter_name(param_name) + lines.append( + f" {sanitized_name}: {param_info['description']}" + ) + lines.append("") + + lines.extend([ + " Returns:", + " AhaResponse with operation result", + ' """', + ]) + + return lines + + def _generate_method(self, method_name: str, endpoint_info: Dict) -> str: + """Generate a complete method for an API endpoint.""" + lines = [] + + # Method signature + lines.append(self._generate_method_signature(method_name, endpoint_info)) + + # Docstring + lines.extend(self._generate_method_docstring(endpoint_info)) + + # Query parameters + has_query = any( + info["location"] == "query" + for info in endpoint_info["parameters"].values() + ) + if has_query: + query_lines = self._build_query_params(endpoint_info) + lines.extend(query_lines) + lines.append("") + + # URL construction + lines.append(self._build_path_formatting(endpoint_info["path"], endpoint_info)) + + # Request body + body_lines = self._build_request_body(endpoint_info) + if body_lines: + lines.append("") + lines.extend(body_lines) + + # Request construction and execution + lines.append("") + lines.append(" try:") + lines.append(" request = HTTPRequest(") + lines.append(f' method="{endpoint_info["method"]}",') + lines.append(" url=url,") + lines.append(' headers={"Content-Type": "application/json"},') + if has_query: + lines.append(" query=query_params,") + if body_lines: + lines.append(" body=body,") + lines.append(" )") + lines.extend([ + " response = await self.http.execute(request) # type: ignore[reportUnknownMemberType]", + " response_data = response.json() if response.text() else None", + " return AhaResponse(", + " success=response.status < HTTP_ERROR_THRESHOLD,", + " data=response_data,", + f' message="Successfully executed {method_name}" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {{response.status}}"', + " )", + " except Exception as e:", + f' return AhaResponse(success=False, error=str(e), message="Failed to execute {method_name}")', + ]) + + self.generated_methods.append({ + "name": method_name, + "endpoint": endpoint_info["path"], + "method": endpoint_info["method"], + "description": endpoint_info["description"], + "version": endpoint_info.get("version", "v1"), + }) + + return "\n".join(lines) + + def generate_aha_datasource(self) -> str: + """Generate the complete Aha! datasource class.""" + + class_lines = [ + '"""', + "Aha! REST API DataSource - Auto-generated API wrapper", + "", + "Generated from Aha! REST API v1 documentation.", + "Uses HTTP client for direct REST API interactions.", + "All methods have explicit parameter signatures.", + '"""', + "", + "from __future__ import annotations", + "", + "from typing import Any", + "", + "from app.sources.client.aha.aha import AhaClient, AhaResponse", + "from app.sources.client.http.http_request import HTTPRequest", + "", + "# HTTP status code constant", + "HTTP_ERROR_THRESHOLD = 400", + "", + "", + "class AhaDataSource:", + ' """Aha! REST API DataSource', + "", + " Provides async wrapper methods for Aha! REST API operations:", + " - User profile and management", + " - Product management", + " - Feature CRUD operations", + " - Idea management", + " - Release management", + " - Goal operations", + " - Epic management", + " - Integration listing", + "", + " The base URL is https://{subdomain}.aha.io/api/v1.", + "", + " All methods return AhaResponse objects.", + ' """', + "", + " def __init__(self, client: AhaClient) -> None:", + ' """Initialize with AhaClient.', + "", + " Args:", + " client: AhaClient instance with configured authentication and subdomain", + ' """', + " self._client = client", + " self.http = client.get_client()", + " try:", + " self.base_url = self.http.get_base_url().rstrip('/')", + " except AttributeError as exc:", + " raise ValueError('HTTP client does not have get_base_url method') from exc", + "", + " def get_data_source(self) -> 'AhaDataSource':", + ' """Return the data source instance."""', + " return self", + "", + " def get_client(self) -> AhaClient:", + ' """Return the underlying AhaClient."""', + " return self._client", + "", + ] + + # Generate all API methods + for method_name, endpoint_info in AHA_API_ENDPOINTS.items(): + class_lines.append(self._generate_method(method_name, endpoint_info)) + class_lines.append("") + + return "\n".join(class_lines) + + def save_to_file(self, filename: Optional[str] = None) -> None: + """Generate and save the Aha! datasource to a file.""" + if filename is None: + filename = "aha.py" + + script_dir = Path(__file__).parent if __file__ else Path(".") + aha_dir = script_dir.parent / "app" / "sources" / "external" / "aha" + aha_dir.mkdir(parents=True, exist_ok=True) + + full_path = aha_dir / filename + + class_code = self.generate_aha_datasource() + + full_path.write_text(class_code, encoding="utf-8") + + print(f"Generated Aha! data source with {len(self.generated_methods)} methods") + print(f"Saved to: {full_path}") + + # Print summary by category + resource_categories = { + "User": 0, + "Product": 0, + "Feature": 0, + "Idea": 0, + "Release": 0, + "Goal": 0, + "Epic": 0, + "Integration": 0, + } + + for method in self.generated_methods: + name = method["name"] + if "user" in name: + resource_categories["User"] += 1 + elif "product" in name and "feature" not in name and "idea" not in name and "release" not in name and "goal" not in name and "epic" not in name and "integration" not in name: + resource_categories["Product"] += 1 + elif "feature" in name: + resource_categories["Feature"] += 1 + elif "idea" in name: + resource_categories["Idea"] += 1 + elif "release" in name: + resource_categories["Release"] += 1 + elif "goal" in name: + resource_categories["Goal"] += 1 + elif "epic" in name: + resource_categories["Epic"] += 1 + elif "integration" in name: + resource_categories["Integration"] += 1 + + print(f"\nMethods by Resource:") + for category, count in resource_categories.items(): + if count > 0: + print(f" - {category}: {count}") + + +def main(): + """Main function for Aha! data source generator.""" + import argparse + + parser = argparse.ArgumentParser( + description="Generate Aha! REST API data source" + ) + parser.add_argument("--filename", "-f", help="Output filename (optional)") + + args = parser.parse_args() + + try: + generator = AhaDataSourceGenerator() + generator.save_to_file(args.filename) + return 0 + except Exception as e: + print(f"Failed to generate Aha! data source: {e}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/python/code-generator/amplitude.py b/backend/python/code-generator/amplitude.py new file mode 100644 index 000000000..2dc9b1ccc --- /dev/null +++ b/backend/python/code-generator/amplitude.py @@ -0,0 +1,739 @@ +# ruff: noqa +""" +Amplitude REST API Code Generator + +Generates AmplitudeDataSource class covering Amplitude API v2 and v3: +- Event Segmentation +- User Search and Activity +- User Deletion Management +- Raw Data Export +- Event Upload +- Cohorts +- Charts +- Annotations +- Releases +- Taxonomy (Event Types, User Properties, Event Properties) + +The generated DataSource accepts an AmplitudeClient and uses the client's +configured base URLs for v2 and v3 endpoints. + +All methods have explicit parameter signatures with no **kwargs usage. + +Usage: + python code-generator/amplitude.py + python code-generator/amplitude.py --filename amplitude.py + +Output: + app/sources/external/amplitude/amplitude.py +""" + +import sys +from pathlib import Path +from typing import Dict, List, Optional + +# ================================================================================ +# Amplitude API Endpoints - organized by resource category +# +# Each endpoint defines: +# method: HTTP verb +# path: URL path (appended to base_url) +# description: Human-readable description +# parameters: Dict of param_name -> {type, location (path/query/body), description} +# required: List of required parameter names +# base: Which API base URL to use ("v2" or "v3") +# ================================================================================ + +AMPLITUDE_API_ENDPOINTS = { + # ================================================================================ + # EVENT SEGMENTATION (v2) + # ================================================================================ + "get_event_segmentation": { + "method": "GET", + "path": "/events/segmentation", + "description": "Get event segmentation data for analytics queries", + "parameters": { + "e": {"type": "str", "location": "query", "description": "Event JSON object (required). Defines the event to segment on"}, + "start": {"type": "str", "location": "query", "description": "Start date (required), e.g. '20230101'"}, + "end": {"type": "str", "location": "query", "description": "End date (required), e.g. '20230131'"}, + "m": {"type": "str", "location": "query", "description": "Metric type (e.g. 'uniques', 'totals', 'avg')"}, + "i": {"type": "str", "location": "query", "description": "Interval: '-300000', '3600000', '86400000', or '604800000'"}, + "g": {"type": "str", "location": "query", "description": "Group by property"}, + "limit": {"type": "Optional[int]", "location": "query", "description": "Limit the number of group by values returned"}, + }, + "required": ["e", "start", "end"], + "base": "v2", + }, + + # ================================================================================ + # USER SEARCH (v2) + # ================================================================================ + "search_user": { + "method": "GET", + "path": "/usersearch", + "description": "Search for a user by email or Amplitude ID", + "parameters": { + "user": {"type": "str", "location": "query", "description": "User email address or Amplitude ID (required)"}, + }, + "required": ["user"], + "base": "v2", + }, + + # ================================================================================ + # USER ACTIVITY (v2) + # ================================================================================ + "get_user_activity": { + "method": "GET", + "path": "/useractivity", + "description": "Get a user's event activity", + "parameters": { + "user": {"type": "str", "location": "query", "description": "Amplitude user ID (required)"}, + "offset": {"type": "Optional[int]", "location": "query", "description": "Offset for pagination"}, + "limit": {"type": "Optional[int]", "location": "query", "description": "Number of events to return (max 1000)"}, + }, + "required": ["user"], + "base": "v2", + }, + + # ================================================================================ + # USER DELETIONS (v2) + # ================================================================================ + "get_user_deletion_jobs": { + "method": "GET", + "path": "/deletions/users", + "description": "Get user deletion jobs within a date range", + "parameters": { + "start_day": {"type": "Optional[str]", "location": "query", "description": "Start date for deletion jobs (e.g. '2023-01-01')"}, + "end_day": {"type": "Optional[str]", "location": "query", "description": "End date for deletion jobs (e.g. '2023-01-31')"}, + }, + "required": [], + "base": "v2", + }, + "create_user_deletion": { + "method": "POST", + "path": "/deletions/users", + "description": "Create a user deletion job to delete user data", + "parameters": { + "amplitude_ids": {"type": "list[int] | None", "location": "body", "description": "List of Amplitude user IDs to delete"}, + "user_ids": {"type": "list[str] | None", "location": "body", "description": "List of user IDs to delete"}, + "requester": {"type": "Optional[str]", "location": "body", "description": "Email of the requester"}, + }, + "required": [], + "base": "v2", + }, + + # ================================================================================ + # RAW DATA EXPORT (v2) + # ================================================================================ + "export_raw_data": { + "method": "GET", + "path": "/export", + "description": "Export raw event data for a date range (returns zipped JSON)", + "parameters": { + "start": {"type": "str", "location": "query", "description": "Start date hour (required), e.g. '20230101T00'"}, + "end": {"type": "str", "location": "query", "description": "End date hour (required), e.g. '20230102T00'"}, + }, + "required": ["start", "end"], + "base": "v2", + }, + + # ================================================================================ + # EVENT UPLOAD (v2) + # ================================================================================ + "upload_events": { + "method": "POST", + "path": "/events/upload", + "description": "Upload events to Amplitude (batch upload)", + "parameters": { + "api_key": {"type": "str", "location": "body", "description": "Amplitude API key"}, + "events": {"type": "list[dict[str, Any]]", "location": "body", "description": "List of event objects to upload"}, + }, + "required": ["api_key", "events"], + "base": "v2", + }, + + # ================================================================================ + # COHORTS (v3) + # ================================================================================ + "list_cohorts": { + "method": "GET", + "path": "/cohorts", + "description": "List all cohorts in the project", + "parameters": {}, + "required": [], + "base": "v3", + }, + "get_cohort": { + "method": "GET", + "path": "/cohorts/{cohort_id}", + "description": "Get details of a specific cohort", + "parameters": { + "cohort_id": {"type": "str", "location": "path", "description": "The cohort ID"}, + }, + "required": ["cohort_id"], + "base": "v3", + }, + + # ================================================================================ + # CHARTS (v3) + # ================================================================================ + "query_chart": { + "method": "POST", + "path": "/charts/{chart_id}/query", + "description": "Query a saved chart by ID", + "parameters": { + "chart_id": {"type": "str", "location": "path", "description": "The chart ID"}, + }, + "required": ["chart_id"], + "base": "v3", + }, + + # ================================================================================ + # ANNOTATIONS (v2) + # ================================================================================ + "list_annotations": { + "method": "GET", + "path": "/annotations", + "description": "List all annotations", + "parameters": {}, + "required": [], + "base": "v2", + }, + "create_annotation": { + "method": "POST", + "path": "/annotations", + "description": "Create a new annotation", + "parameters": { + "date": {"type": "str", "location": "body", "description": "Date of the annotation (e.g. '2023-01-15')"}, + "label": {"type": "str", "location": "body", "description": "Label/title of the annotation"}, + "details": {"type": "Optional[str]", "location": "body", "description": "Additional details for the annotation"}, + }, + "required": ["date", "label"], + "base": "v2", + }, + + # ================================================================================ + # RELEASES (v2) + # ================================================================================ + "list_releases": { + "method": "GET", + "path": "/releases", + "description": "List all releases", + "parameters": {}, + "required": [], + "base": "v2", + }, + "create_release": { + "method": "POST", + "path": "/releases", + "description": "Create a new release", + "parameters": { + "version": {"type": "str", "location": "body", "description": "Release version string"}, + "release_start": {"type": "str", "location": "body", "description": "Release start date (e.g. '2023-01-15')"}, + "release_end": {"type": "Optional[str]", "location": "body", "description": "Release end date (e.g. '2023-01-16')"}, + "title": {"type": "Optional[str]", "location": "body", "description": "Title of the release"}, + "description": {"type": "Optional[str]", "location": "body", "description": "Description of the release"}, + "platforms": {"type": "list[str] | None", "location": "body", "description": "List of platforms for this release"}, + "created_by": {"type": "Optional[str]", "location": "body", "description": "Email of the release creator"}, + "chart_id": {"type": "Optional[str]", "location": "body", "description": "Chart ID to associate with the release"}, + }, + "required": ["version", "release_start"], + "base": "v2", + }, + + # ================================================================================ + # TAXONOMY - EVENT TYPES (v2) + # ================================================================================ + "list_event_types": { + "method": "GET", + "path": "/taxonomy/event-type", + "description": "List all event types in the project's taxonomy", + "parameters": {}, + "required": [], + "base": "v2", + }, + "get_event_type": { + "method": "GET", + "path": "/taxonomy/event-type/{event_type}", + "description": "Get a specific event type from the taxonomy", + "parameters": { + "event_type": {"type": "str", "location": "path", "description": "The event type name"}, + }, + "required": ["event_type"], + "base": "v2", + }, + + # ================================================================================ + # TAXONOMY - USER PROPERTIES (v2) + # ================================================================================ + "list_user_properties": { + "method": "GET", + "path": "/taxonomy/user-property", + "description": "List all user properties in the project's taxonomy", + "parameters": {}, + "required": [], + "base": "v2", + }, + + # ================================================================================ + # TAXONOMY - EVENT PROPERTIES (v2) + # ================================================================================ + "list_event_properties": { + "method": "GET", + "path": "/taxonomy/event-property", + "description": "List all event properties in the project's taxonomy", + "parameters": {}, + "required": [], + "base": "v2", + }, +} + + +class AmplitudeDataSourceGenerator: + """Generator for comprehensive Amplitude REST API datasource class. + + Generates methods for both v2 and v3 Amplitude API endpoints. + The generated DataSource class accepts an AmplitudeClient whose + base URLs determine the API endpoints. + """ + + def __init__(self): + self.generated_methods: List[Dict[str, str]] = [] + + def _sanitize_parameter_name(self, name: str) -> str: + """Sanitize parameter names to be valid Python identifiers.""" + sanitized = name.replace("-", "_").replace(".", "_").replace("/", "_") + if sanitized and not (sanitized[0].isalpha() or sanitized[0] == "_"): + sanitized = f"param_{sanitized}" + return sanitized + + def _build_query_params(self, endpoint_info: Dict) -> List[str]: + """Build query parameter handling code.""" + lines = [" query_params: dict[str, Any] = {}"] + + for param_name, param_info in endpoint_info["parameters"].items(): + if param_info["location"] == "query": + sanitized_name = self._sanitize_parameter_name(param_name) + + if "Optional[bool]" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}'] = str({sanitized_name}).lower()", + ]) + elif "Optional[int]" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}'] = str({sanitized_name})", + ]) + elif "List[" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}[]'] = {sanitized_name}", + ]) + else: + # For required params, always add; for optional, check None + if param_name in endpoint_info.get("required", []): + lines.append(f" query_params['{param_name}'] = {sanitized_name}") + else: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}'] = {sanitized_name}", + ]) + + return lines + + def _build_path_formatting(self, path: str, endpoint_info: Dict) -> str: + """Build URL path with parameter substitution.""" + base = endpoint_info.get("base", "v2") + base_url_expr = "self.base_url" if base == "v2" else "self.base_url_v3" + + path_params = [ + name + for name, info in endpoint_info["parameters"].items() + if info["location"] == "path" + ] + + if path_params: + format_dict = ", ".join( + f"{param}={self._sanitize_parameter_name(param)}" + for param in path_params + ) + return f' url = {base_url_expr} + "{path}".format({format_dict})' + else: + return f' url = {base_url_expr} + "{path}"' + + def _build_request_body(self, endpoint_info: Dict) -> List[str]: + """Build request body handling.""" + body_params = { + name: info + for name, info in endpoint_info["parameters"].items() + if info["location"] == "body" + } + + if not body_params: + return [] + + lines = [" body: dict[str, Any] = {}"] + + for param_name, param_info in body_params.items(): + sanitized_name = self._sanitize_parameter_name(param_name) + + if param_name in endpoint_info["required"]: + lines.append(f" body['{param_name}'] = {sanitized_name}") + else: + lines.extend([ + f" if {sanitized_name} is not None:", + f" body['{param_name}'] = {sanitized_name}", + ]) + + return lines + + @staticmethod + def _modernize_type(type_str: str) -> str: + """Convert typing-style annotations to modern Python 3.10+ syntax. + + Optional[str] -> str | None, Dict[str, Any] -> dict[str, Any], + List[str] -> list[str], etc. + """ + if type_str.startswith("Optional[") and type_str.endswith("]"): + inner = type_str[len("Optional["):-1] + inner = AmplitudeDataSourceGenerator._modernize_type(inner) + return f"{inner} | None" + if type_str.startswith("Dict["): + inner = type_str[len("Dict["):-1] + parts = AmplitudeDataSourceGenerator._split_type_args(inner) + modernized = ", ".join( + AmplitudeDataSourceGenerator._modernize_type(p.strip()) for p in parts + ) + return f"dict[{modernized}]" + if type_str == "Dict": + return "dict" + if type_str.startswith("List["): + inner = type_str[len("List["):-1] + parts = AmplitudeDataSourceGenerator._split_type_args(inner) + modernized = ", ".join( + AmplitudeDataSourceGenerator._modernize_type(p.strip()) for p in parts + ) + return f"list[{modernized}]" + if type_str == "List": + return "list" + return type_str + + @staticmethod + def _split_type_args(s: str) -> List[str]: + """Split type arguments respecting nested brackets.""" + parts = [] + depth = 0 + current = "" + for ch in s: + if ch == "[": + depth += 1 + elif ch == "]": + depth -= 1 + if ch == "," and depth == 0: + parts.append(current.strip()) + current = "" + else: + current += ch + if current.strip(): + parts.append(current.strip()) + return parts + + def _generate_method_signature(self, method_name: str, endpoint_info: Dict) -> str: + """Generate method signature with explicit parameters.""" + params = ["self"] + has_any_bool = False + + # Collect required params, split into non-bool and bool groups + required_non_bool: List[str] = [] + required_bool: List[str] = [] + for param_name in endpoint_info["required"]: + if param_name in endpoint_info["parameters"]: + param_info = endpoint_info["parameters"][param_name] + sanitized_name = self._sanitize_parameter_name(param_name) + modern_type = self._modernize_type(param_info["type"]) + param_str = f"{sanitized_name}: {modern_type}" + if "bool" in param_info.get("type", ""): + required_bool.append(param_str) + has_any_bool = True + else: + required_non_bool.append(param_str) + + # Collect optional parameters + optional_params: List[str] = [] + for param_name, param_info in endpoint_info["parameters"].items(): + if param_name not in endpoint_info["required"]: + sanitized_name = self._sanitize_parameter_name(param_name) + modern_type = self._modernize_type(param_info["type"]) + if "| None" not in modern_type: + modern_type = f"{modern_type} | None" + optional_params.append(f"{sanitized_name}: {modern_type} = None") + if "bool" in param_info.get("type", ""): + has_any_bool = True + + # Build signature: non-bool required first, then * if needed, then bool required + optional + params.extend(required_non_bool) + if has_any_bool and (required_bool or optional_params): + params.append("*") + params.extend(required_bool) + params.extend(optional_params) + + signature_params = ",\n ".join(params) + return f" async def {method_name}(\n {signature_params}\n ) -> AmplitudeResponse:" + + def _generate_method_docstring(self, endpoint_info: Dict) -> List[str]: + """Generate method docstring.""" + base = endpoint_info.get("base", "v2") + lines = [f' """{endpoint_info["description"]} (API {base})', ""] + + if endpoint_info["parameters"]: + lines.append(" Args:") + for param_name, param_info in endpoint_info["parameters"].items(): + sanitized_name = self._sanitize_parameter_name(param_name) + lines.append( + f" {sanitized_name}: {param_info['description']}" + ) + lines.append("") + + lines.extend([ + " Returns:", + " AmplitudeResponse with operation result", + ' """', + ]) + + return lines + + def _generate_method(self, method_name: str, endpoint_info: Dict) -> str: + """Generate a complete method for an API endpoint.""" + lines = [] + + # Method signature + lines.append(self._generate_method_signature(method_name, endpoint_info)) + + # Docstring + lines.extend(self._generate_method_docstring(endpoint_info)) + + # Query parameters + has_query = any( + info["location"] == "query" + for info in endpoint_info["parameters"].values() + ) + if has_query: + query_lines = self._build_query_params(endpoint_info) + lines.extend(query_lines) + lines.append("") + + # URL construction + lines.append(self._build_path_formatting(endpoint_info["path"], endpoint_info)) + + # Request body + body_lines = self._build_request_body(endpoint_info) + if body_lines: + lines.append("") + lines.extend(body_lines) + + # Request construction and execution + lines.append("") + lines.append(" try:") + lines.append(" request = HTTPRequest(") + lines.append(f' method="{endpoint_info["method"]}",') + lines.append(" url=url,") + lines.append(' headers={"Content-Type": "application/json"},') + if has_query: + lines.append(" query=query_params,") + if body_lines: + lines.append(" body=body,") + lines.append(" )") + lines.extend([ + " response = await self.http.execute(request) # type: ignore[reportUnknownMemberType]", + " response_data = response.json() if response.text() else None", + " return AmplitudeResponse(", + " success=response.status < HTTP_ERROR_THRESHOLD,", + " data=response_data,", + f' message="Successfully executed {method_name}" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {{response.status}}"', + " )", + " except Exception as e:", + f' return AmplitudeResponse(success=False, error=str(e), message="Failed to execute {method_name}")', + ]) + + self.generated_methods.append({ + "name": method_name, + "endpoint": endpoint_info["path"], + "method": endpoint_info["method"], + "description": endpoint_info["description"], + "base": endpoint_info.get("base", "v2"), + }) + + return "\n".join(lines) + + def generate_amplitude_datasource(self) -> str: + """Generate the complete Amplitude datasource class.""" + + class_lines = [ + '"""', + "Amplitude REST API DataSource - Auto-generated API wrapper", + "", + "Generated from Amplitude REST API v2/v3 documentation.", + "Uses HTTP client for direct REST API interactions.", + "All methods have explicit parameter signatures.", + '"""', + "", + "from __future__ import annotations", + "", + "from typing import Any", + "", + "from app.sources.client.amplitude.amplitude import AmplitudeClient, AmplitudeResponse", + "from app.sources.client.http.http_request import HTTPRequest", + "", + "# HTTP status code constant", + "HTTP_ERROR_THRESHOLD = 400", + "", + "", + "class AmplitudeDataSource:", + ' """Amplitude REST API DataSource', + "", + " Provides async wrapper methods for Amplitude REST API operations:", + " - Event Segmentation queries", + " - User Search and Activity", + " - User Deletion management", + " - Raw Data Export", + " - Event Upload", + " - Cohort management", + " - Chart queries", + " - Annotations and Releases", + " - Taxonomy (Event Types, User Properties, Event Properties)", + "", + " Uses two base URLs:", + " - v2: https://amplitude.com/api/2", + " - v3: https://analytics.amplitude.com/api/3", + "", + " All methods return AmplitudeResponse objects.", + ' """', + "", + " def __init__(self, client: AmplitudeClient) -> None:", + ' """Initialize with AmplitudeClient.', + "", + " Args:", + " client: AmplitudeClient instance with configured authentication", + ' """', + " self._client = client", + " self.http = client.get_client()", + " try:", + " self.base_url = self.http.get_base_url().rstrip('/')", + " except AttributeError as exc:", + " raise ValueError('HTTP client does not have get_base_url method') from exc", + " try:", + " self.base_url_v3 = self.http.get_base_url_v3().rstrip('/')", + " except AttributeError:", + " self.base_url_v3 = 'https://analytics.amplitude.com/api/3'", + "", + " def get_data_source(self) -> 'AmplitudeDataSource':", + ' """Return the data source instance."""', + " return self", + "", + " def get_client(self) -> AmplitudeClient:", + ' """Return the underlying AmplitudeClient."""', + " return self._client", + "", + ] + + # Generate all API methods + for method_name, endpoint_info in AMPLITUDE_API_ENDPOINTS.items(): + class_lines.append(self._generate_method(method_name, endpoint_info)) + class_lines.append("") + + return "\n".join(class_lines) + + def save_to_file(self, filename: Optional[str] = None) -> None: + """Generate and save the Amplitude datasource to a file.""" + if filename is None: + filename = "amplitude.py" + + script_dir = Path(__file__).parent if __file__ else Path(".") + amplitude_dir = script_dir.parent / "app" / "sources" / "external" / "amplitude" + amplitude_dir.mkdir(parents=True, exist_ok=True) + + full_path = amplitude_dir / filename + + class_code = self.generate_amplitude_datasource() + + full_path.write_text(class_code, encoding="utf-8") + + print(f"Generated Amplitude data source with {len(self.generated_methods)} methods") + print(f"Saved to: {full_path}") + + # Print summary by API version + categories = {} + for method in self.generated_methods: + base = method["base"] + key = f"API {base}" + categories[key] = categories.get(key, 0) + 1 + + print(f"\nMethods by API version:") + for category, count in sorted(categories.items()): + print(f" - {category}: {count}") + + # Print resource summary + resource_categories = { + "Event Segmentation": 0, + "User Search/Activity": 0, + "User Deletion": 0, + "Raw Data Export": 0, + "Event Upload": 0, + "Cohorts": 0, + "Charts": 0, + "Annotations": 0, + "Releases": 0, + "Taxonomy": 0, + } + + for method in self.generated_methods: + name = method["name"] + if "segmentation" in name: + resource_categories["Event Segmentation"] += 1 + elif "search" in name or "activity" in name: + resource_categories["User Search/Activity"] += 1 + elif "deletion" in name: + resource_categories["User Deletion"] += 1 + elif "export" in name: + resource_categories["Raw Data Export"] += 1 + elif "upload" in name: + resource_categories["Event Upload"] += 1 + elif "cohort" in name: + resource_categories["Cohorts"] += 1 + elif "chart" in name: + resource_categories["Charts"] += 1 + elif "annotation" in name: + resource_categories["Annotations"] += 1 + elif "release" in name: + resource_categories["Releases"] += 1 + elif "event_type" in name or "user_propert" in name or "event_propert" in name: + resource_categories["Taxonomy"] += 1 + + print(f"\nMethods by Resource:") + for category, count in resource_categories.items(): + if count > 0: + print(f" - {category}: {count}") + + +def main(): + """Main function for Amplitude data source generator.""" + import argparse + + parser = argparse.ArgumentParser( + description="Generate Amplitude REST API data source" + ) + parser.add_argument("--filename", "-f", help="Output filename (optional)") + + args = parser.parse_args() + + try: + generator = AmplitudeDataSourceGenerator() + generator.save_to_file(args.filename) + return 0 + except Exception as e: + print(f"Failed to generate Amplitude data source: {e}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/python/code-generator/bamboohr.py b/backend/python/code-generator/bamboohr.py new file mode 100644 index 000000000..155ce8448 --- /dev/null +++ b/backend/python/code-generator/bamboohr.py @@ -0,0 +1,682 @@ +# ruff: noqa +""" +BambooHR REST API Code Generator + +Generates BambooHRDataSource class covering BambooHR API v1: +- Employee directory and management +- Employee files +- Metadata (fields, tables, lists, users) +- Custom reports +- Time off requests and policies +- Changed employees tracking +- Applicant tracking (applications, job summaries) + +The generated DataSource accepts a BambooHRClient and uses the client's +configured base URL. Methods are generated for all API endpoints. + +All methods have explicit parameter signatures with no **kwargs usage. +""" + +import sys +from pathlib import Path +from typing import Dict, List, Optional + +# ================================================================================ +# BambooHR API Endpoints - organized by resource +# +# Each endpoint defines: +# method: HTTP verb +# path: URL path (appended to base_url which already includes /api/gateway.php/{domain}/v1) +# description: Human-readable description +# parameters: Dict of param_name -> {type, location (path/query/body), description} +# required: List of required parameter names +# version: Which API version this endpoint belongs to +# ================================================================================ + +BAMBOOHR_API_ENDPOINTS = { + # ================================================================================ + # EMPLOYEES + # ================================================================================ + "get_employee_directory": { + "method": "GET", + "path": "/employees/directory", + "description": "Get employee directory listing all active employees", + "parameters": {}, + "required": [], + "version": "v1", + }, + "get_employee": { + "method": "GET", + "path": "/employees/{employee_id}", + "description": "Get a single employee by ID", + "parameters": { + "employee_id": {"type": "str", "location": "path", "description": "The employee ID"}, + "fields": {"type": "Optional[str]", "location": "query", "description": "Comma-separated list of fields to return"}, + }, + "required": ["employee_id"], + "version": "v1", + }, + "add_employee": { + "method": "POST", + "path": "/employees/", + "description": "Add a new employee", + "parameters": { + "employee_data": {"type": "dict[str, Any]", "location": "body", "description": "Employee data fields (firstName, lastName, etc.)"}, + }, + "required": ["employee_data"], + "version": "v1", + }, + "update_employee": { + "method": "PUT", + "path": "/employees/{employee_id}", + "description": "Update an existing employee", + "parameters": { + "employee_id": {"type": "str", "location": "path", "description": "The employee ID"}, + "employee_data": {"type": "dict[str, Any]", "location": "body", "description": "Employee data fields to update"}, + }, + "required": ["employee_id", "employee_data"], + "version": "v1", + }, + "get_changed_employees": { + "method": "GET", + "path": "/employees/changed", + "description": "Get employees that have changed since a given date", + "parameters": { + "since": {"type": "str", "location": "query", "description": "ISO 8601 date string (e.g., 2024-01-01T00:00:00Z)"}, + "change_type": {"type": "Optional[str]", "location": "query", "description": "Type of changes to return (e.g., 'inserted', 'updated', 'deleted')", "api_name": "type"}, + }, + "required": ["since"], + "version": "v1", + }, + + # ================================================================================ + # EMPLOYEE FILES + # ================================================================================ + "list_employee_files": { + "method": "GET", + "path": "/employees/{employee_id}/files/view/", + "description": "List all files for an employee", + "parameters": { + "employee_id": {"type": "str", "location": "path", "description": "The employee ID"}, + }, + "required": ["employee_id"], + "version": "v1", + }, + + # ================================================================================ + # METADATA + # ================================================================================ + "get_metadata_fields": { + "method": "GET", + "path": "/meta/fields/", + "description": "Get list of all metadata fields", + "parameters": {}, + "required": [], + "version": "v1", + }, + "get_metadata_tables": { + "method": "GET", + "path": "/meta/tables/", + "description": "Get list of all metadata tables", + "parameters": {}, + "required": [], + "version": "v1", + }, + "get_metadata_lists": { + "method": "GET", + "path": "/meta/lists/", + "description": "Get list of all metadata lists (dropdown options)", + "parameters": {}, + "required": [], + "version": "v1", + }, + "get_metadata_users": { + "method": "GET", + "path": "/meta/users/", + "description": "Get list of all users with access to BambooHR", + "parameters": {}, + "required": [], + "version": "v1", + }, + + # ================================================================================ + # REPORTS + # ================================================================================ + "run_custom_report": { + "method": "POST", + "path": "/reports/custom", + "description": "Run a custom report with specified fields and filters", + "parameters": { + "output_format": {"type": "Optional[str]", "location": "query", "description": "Output format (e.g., 'JSON', 'CSV', 'XLS', 'XML', 'PDF')", "api_name": "format"}, + "report_data": {"type": "dict[str, Any]", "location": "body", "description": "Report configuration (fields, filters, title, etc.)"}, + }, + "required": ["report_data"], + "version": "v1", + }, + "get_company_report": { + "method": "GET", + "path": "/reports/{report_id}", + "description": "Get a saved company report by ID", + "parameters": { + "report_id": {"type": "str", "location": "path", "description": "The report ID"}, + "output_format": {"type": "Optional[str]", "location": "query", "description": "Output format (e.g., 'JSON', 'CSV', 'XLS', 'XML', 'PDF')", "api_name": "format"}, + "fd": {"type": "Optional[str]", "location": "query", "description": "Set to 'yes' to include field data in the response"}, + }, + "required": ["report_id"], + "version": "v1", + }, + + # ================================================================================ + # TIME OFF + # ================================================================================ + "get_time_off_requests": { + "method": "GET", + "path": "/time_off/requests/", + "description": "Get time off requests within a date range", + "parameters": { + "start": {"type": "Optional[str]", "location": "query", "description": "Start date (YYYY-MM-DD)"}, + "end": {"type": "Optional[str]", "location": "query", "description": "End date (YYYY-MM-DD)"}, + "status": {"type": "Optional[str]", "location": "query", "description": "Filter by status (approved, denied, superceded, requested, canceled)"}, + "action": {"type": "Optional[str]", "location": "query", "description": "Filter by action (view, approve)"}, + "employeeId": {"type": "Optional[str]", "location": "query", "description": "Filter by employee ID"}, + "time_off_type": {"type": "Optional[str]", "location": "query", "description": "Filter by time off type ID", "api_name": "type"}, + }, + "required": [], + "version": "v1", + }, + "get_time_off_policies": { + "method": "GET", + "path": "/time_off/policies/", + "description": "Get list of time off policies", + "parameters": {}, + "required": [], + "version": "v1", + }, + + # ================================================================================ + # APPLICANT TRACKING + # ================================================================================ + "list_applications": { + "method": "GET", + "path": "/applicant_tracking/applications", + "description": "List applicant tracking applications", + "parameters": { + "page": {"type": "Optional[int]", "location": "query", "description": "Page number for pagination"}, + "jobId": {"type": "Optional[str]", "location": "query", "description": "Filter by job ID"}, + "applicationStatusId": {"type": "Optional[str]", "location": "query", "description": "Filter by application status ID"}, + "applicationStatus": {"type": "Optional[str]", "location": "query", "description": "Filter by application status name"}, + "jobStatusGroups": {"type": "Optional[str]", "location": "query", "description": "Filter by job status groups (e.g., 'Active', 'Inactive')"}, + "newSince": {"type": "Optional[str]", "location": "query", "description": "Filter applications created since this date (ISO 8601)"}, + "sortBy": {"type": "Optional[str]", "location": "query", "description": "Sort field (e.g., 'created_date', 'first_name', 'last_name')"}, + "sortOrder": {"type": "Optional[str]", "location": "query", "description": "Sort order ('ASC' or 'DESC')"}, + }, + "required": [], + "version": "v1", + }, + "get_application": { + "method": "GET", + "path": "/applicant_tracking/applications/{application_id}", + "description": "Get a specific applicant tracking application", + "parameters": { + "application_id": {"type": "str", "location": "path", "description": "The application ID"}, + }, + "required": ["application_id"], + "version": "v1", + }, + "get_job_summaries": { + "method": "GET", + "path": "/applicant_tracking/job_summaries", + "description": "Get job summaries for applicant tracking", + "parameters": {}, + "required": [], + "version": "v1", + }, +} + + +class BambooHRDataSourceGenerator: + """Generator for comprehensive BambooHR REST API datasource class. + + Generates methods for BambooHR API v1 endpoints. + The generated DataSource class accepts a BambooHRClient whose base URL + is determined by the company domain. + """ + + def __init__(self): + self.generated_methods: List[Dict[str, str]] = [] + + def _sanitize_parameter_name(self, name: str) -> str: + """Sanitize parameter names to be valid Python identifiers.""" + sanitized = name.replace("-", "_").replace(".", "_").replace("/", "_") + if sanitized and not (sanitized[0].isalpha() or sanitized[0] == "_"): + sanitized = f"param_{sanitized}" + return sanitized + + @staticmethod + def _modernize_type(type_str: str) -> str: + """Convert typing-style annotations to modern Python 3.10+ syntax. + + Optional[str] -> str | None, Dict[str, Any] -> dict[str, Any], + List[str] -> list[str], etc. + """ + if type_str.startswith("Optional[") and type_str.endswith("]"): + inner = type_str[len("Optional["):-1] + inner = BambooHRDataSourceGenerator._modernize_type(inner) + return f"{inner} | None" + if type_str.startswith("Dict["): + inner = type_str[len("Dict["):-1] + parts = BambooHRDataSourceGenerator._split_type_args(inner) + modernized = ", ".join( + BambooHRDataSourceGenerator._modernize_type(p.strip()) for p in parts + ) + return f"dict[{modernized}]" + if type_str == "Dict": + return "dict" + if type_str.startswith("List["): + inner = type_str[len("List["):-1] + parts = BambooHRDataSourceGenerator._split_type_args(inner) + modernized = ", ".join( + BambooHRDataSourceGenerator._modernize_type(p.strip()) for p in parts + ) + return f"list[{modernized}]" + if type_str == "List": + return "list" + return type_str + + @staticmethod + def _split_type_args(s: str) -> List[str]: + """Split type arguments respecting nested brackets.""" + parts = [] + depth = 0 + current = "" + for ch in s: + if ch == "[": + depth += 1 + elif ch == "]": + depth -= 1 + if ch == "," and depth == 0: + parts.append(current.strip()) + current = "" + else: + current += ch + if current.strip(): + parts.append(current.strip()) + return parts + + def _generate_method_signature(self, method_name: str, endpoint_info: Dict) -> str: + """Generate method signature with explicit parameters.""" + params = ["self"] + has_any_bool = False + + # Collect required params, split into non-bool and bool groups + required_non_bool: List[str] = [] + required_bool: List[str] = [] + for param_name in endpoint_info["required"]: + if param_name in endpoint_info["parameters"]: + param_info = endpoint_info["parameters"][param_name] + sanitized_name = self._sanitize_parameter_name(param_name) + modern_type = self._modernize_type(param_info["type"]) + param_str = f"{sanitized_name}: {modern_type}" + if "bool" in param_info.get("type", ""): + required_bool.append(param_str) + has_any_bool = True + else: + required_non_bool.append(param_str) + + # Collect optional parameters + optional_params: List[str] = [] + for param_name, param_info in endpoint_info["parameters"].items(): + if param_name not in endpoint_info["required"]: + sanitized_name = self._sanitize_parameter_name(param_name) + modern_type = self._modernize_type(param_info["type"]) + if "| None" not in modern_type: + modern_type = f"{modern_type} | None" + optional_params.append(f"{sanitized_name}: {modern_type} = None") + if "bool" in param_info.get("type", ""): + has_any_bool = True + + # Build signature: non-bool required first, then * if needed, then bool required + optional + params.extend(required_non_bool) + if has_any_bool and (required_bool or optional_params): + params.append("*") + params.extend(required_bool) + params.extend(optional_params) + + signature_params = ",\n ".join(params) + return f" async def {method_name}(\n {signature_params}\n ) -> BambooHRResponse:" + + def _generate_method_docstring(self, endpoint_info: Dict) -> List[str]: + """Generate method docstring.""" + version = endpoint_info.get("version", "v1") + lines = [f' """{endpoint_info["description"]} (API {version})', ""] + + if endpoint_info["parameters"]: + lines.append(" Args:") + for param_name, param_info in endpoint_info["parameters"].items(): + sanitized_name = self._sanitize_parameter_name(param_name) + lines.append( + f" {sanitized_name}: {param_info['description']}" + ) + lines.append("") + + lines.extend([ + " Returns:", + " BambooHRResponse with operation result", + ' """', + ]) + + return lines + + def _build_query_params(self, endpoint_info: Dict) -> List[str]: + """Build query parameter handling code.""" + lines = [" query_params: dict[str, Any] = {}"] + required = endpoint_info.get("required", []) + + for param_name, param_info in endpoint_info["parameters"].items(): + if param_info["location"] == "query": + sanitized_name = self._sanitize_parameter_name(param_name) + # Use api_name if provided, otherwise use the parameter name + api_name = param_info.get("api_name", param_name) + is_required = param_name in required + + if is_required: + # Required params are always present, no None check needed + if "bool" in param_info["type"]: + lines.append( + f" query_params['{api_name}'] = str({sanitized_name}).lower()" + ) + elif "int" in param_info["type"]: + lines.append( + f" query_params['{api_name}'] = str({sanitized_name})" + ) + else: + lines.append( + f" query_params['{api_name}'] = {sanitized_name}" + ) + elif "Optional[bool]" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{api_name}'] = str({sanitized_name}).lower()", + ]) + elif "Optional[int]" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{api_name}'] = str({sanitized_name})", + ]) + elif "List[" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{api_name}[]'] = {sanitized_name}", + ]) + else: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{api_name}'] = {sanitized_name}", + ]) + + return lines + + def _build_path_formatting(self, path: str, endpoint_info: Dict) -> str: + """Build URL path with parameter substitution.""" + path_params = [ + name + for name, info in endpoint_info["parameters"].items() + if info["location"] == "path" + ] + + if path_params: + format_dict = ", ".join( + f"{param}={self._sanitize_parameter_name(param)}" + for param in path_params + ) + return f' url = self.base_url + "{path}".format({format_dict})' + else: + return f' url = self.base_url + "{path}"' + + def _build_request_body(self, endpoint_info: Dict) -> List[str]: + """Build request body handling.""" + body_params = { + name: info + for name, info in endpoint_info["parameters"].items() + if info["location"] == "body" + } + + if not body_params: + return [] + + lines = [" body: dict[str, Any] = {}"] + + for param_name, param_info in body_params.items(): + sanitized_name = self._sanitize_parameter_name(param_name) + + if param_name in endpoint_info["required"]: + # For dict body params that represent the entire body, use update + if "dict[" in self._modernize_type(param_info["type"]): + lines.append(f" body.update({sanitized_name})") + else: + lines.append(f" body['{param_name}'] = {sanitized_name}") + else: + lines.extend([ + f" if {sanitized_name} is not None:", + f" body['{param_name}'] = {sanitized_name}", + ]) + + return lines + + def _generate_method(self, method_name: str, endpoint_info: Dict) -> str: + """Generate a complete method for an API endpoint.""" + lines = [] + + # Method signature + lines.append(self._generate_method_signature(method_name, endpoint_info)) + + # Docstring + lines.extend(self._generate_method_docstring(endpoint_info)) + + # Query parameters + has_query = any( + info["location"] == "query" + for info in endpoint_info["parameters"].values() + ) + if has_query: + query_lines = self._build_query_params(endpoint_info) + lines.extend(query_lines) + lines.append("") + + # URL construction + lines.append(self._build_path_formatting(endpoint_info["path"], endpoint_info)) + + # Request body + body_lines = self._build_request_body(endpoint_info) + if body_lines: + lines.append("") + lines.extend(body_lines) + + # Request construction and execution + lines.append("") + lines.append(" try:") + lines.append(" request = HTTPRequest(") + lines.append(f' method="{endpoint_info["method"]}",') + lines.append(" url=url,") + lines.append(' headers={"Accept": "application/json"},') + if has_query: + lines.append(" query=query_params,") + if body_lines: + lines.append(" body=body,") + lines.append(" )") + lines.extend([ + " response = await self.http.execute(request) # type: ignore[reportUnknownMemberType]", + " response_data = response.json() if response.text() else None", + " return BambooHRResponse(", + " success=response.status < HTTP_ERROR_THRESHOLD,", + " data=response_data,", + f' message="Successfully executed {method_name}" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {{response.status}}"', + " )", + " except Exception as e:", + f' return BambooHRResponse(success=False, error=str(e), message="Failed to execute {method_name}")', + ]) + + self.generated_methods.append({ + "name": method_name, + "endpoint": endpoint_info["path"], + "method": endpoint_info["method"], + "description": endpoint_info["description"], + "version": endpoint_info.get("version", "v1"), + }) + + return "\n".join(lines) + + def generate_bamboohr_datasource(self) -> str: + """Generate the complete BambooHR datasource class.""" + + class_lines = [ + '"""', + "BambooHR REST API DataSource - Auto-generated API wrapper", + "", + "Generated from BambooHR REST API v1 documentation.", + "Uses HTTP client for direct REST API interactions.", + "All methods have explicit parameter signatures.", + '"""', + "", + "from __future__ import annotations", + "", + "from typing import Any", + "", + "from app.sources.client.bamboohr.bamboohr import BambooHRClient, BambooHRResponse", + "from app.sources.client.http.http_request import HTTPRequest", + "", + "# HTTP status code constant", + "HTTP_ERROR_THRESHOLD = 400", + "", + "", + "class BambooHRDataSource:", + ' """BambooHR REST API DataSource', + "", + " Provides async wrapper methods for BambooHR REST API operations:", + " - Employee directory and management", + " - Employee files", + " - Metadata (fields, tables, lists, users)", + " - Custom reports and company reports", + " - Time off requests and policies", + " - Changed employees tracking", + " - Applicant tracking (applications, job summaries)", + "", + " The base URL is determined by the BambooHRClient's configured company domain.", + "", + " All methods return BambooHRResponse objects.", + ' """', + "", + " def __init__(self, client: BambooHRClient) -> None:", + ' """Initialize with BambooHRClient.', + "", + " Args:", + " client: BambooHRClient instance with configured authentication", + ' """', + " self._client = client", + " self.http = client.get_client()", + " try:", + " self.base_url = self.http.get_base_url().rstrip('/')", + " except AttributeError as exc:", + " raise ValueError('HTTP client does not have get_base_url method') from exc", + "", + " def get_data_source(self) -> 'BambooHRDataSource':", + ' """Return the data source instance."""', + " return self", + "", + " def get_client(self) -> BambooHRClient:", + ' """Return the underlying BambooHRClient."""', + " return self._client", + "", + ] + + # Generate all API methods + for method_name, endpoint_info in BAMBOOHR_API_ENDPOINTS.items(): + class_lines.append(self._generate_method(method_name, endpoint_info)) + class_lines.append("") + + return "\n".join(class_lines) + + def save_to_file(self, filename: Optional[str] = None) -> None: + """Generate and save the BambooHR datasource to a file.""" + if filename is None: + filename = "bamboohr.py" + + script_dir = Path(__file__).parent if __file__ else Path(".") + bamboohr_dir = script_dir.parent / "app" / "sources" / "external" / "bamboohr" + bamboohr_dir.mkdir(parents=True, exist_ok=True) + + full_path = bamboohr_dir / filename + + class_code = self.generate_bamboohr_datasource() + + full_path.write_text(class_code, encoding="utf-8") + + print(f"Generated BambooHR data source with {len(self.generated_methods)} methods") + print(f"Saved to: {full_path}") + + # Print summary by category + categories = {} + for method in self.generated_methods: + version = method["version"] + key = f"API {version}" + categories[key] = categories.get(key, 0) + 1 + + print(f"\nMethods by API version:") + for category, count in sorted(categories.items()): + print(f" - {category}: {count}") + + # Print resource summary + resource_categories = { + "Employee": 0, + "Employee Files": 0, + "Metadata": 0, + "Reports": 0, + "Time Off": 0, + "Applicant Tracking": 0, + } + + for method in self.generated_methods: + name = method["name"] + if "employee" in name and "file" not in name and "changed" not in name: + resource_categories["Employee"] += 1 + elif "file" in name: + resource_categories["Employee Files"] += 1 + elif "metadata" in name or "meta" in name: + resource_categories["Metadata"] += 1 + elif "report" in name: + resource_categories["Reports"] += 1 + elif "time_off" in name: + resource_categories["Time Off"] += 1 + elif "application" in name or "job" in name: + resource_categories["Applicant Tracking"] += 1 + elif "changed" in name: + resource_categories["Employee"] += 1 + + print(f"\nMethods by Resource:") + for category, count in resource_categories.items(): + if count > 0: + print(f" - {category}: {count}") + + +def main(): + """Main function for BambooHR data source generator.""" + import argparse + + parser = argparse.ArgumentParser( + description="Generate BambooHR REST API data source" + ) + parser.add_argument("--filename", "-f", help="Output filename (optional)") + + args = parser.parse_args() + + try: + generator = BambooHRDataSourceGenerator() + generator.save_to_file(args.filename) + return 0 + except Exception as e: + print(f"Failed to generate BambooHR data source: {e}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/python/code-generator/bigquery.py b/backend/python/code-generator/bigquery.py new file mode 100644 index 000000000..e7b27a102 --- /dev/null +++ b/backend/python/code-generator/bigquery.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +# ruff: noqa +from __future__ import annotations + +""" +BigQuery (google-cloud-bigquery) -- Code Generator (strict, no `Any`, no `None` passthrough) + +Emits a `BigQueryDataSource` with explicit, typed methods mapped to *real* google-cloud-bigquery APIs. +- No `Any` in signatures or implementation. +- Never forwards None to the SDK (filters optionals). +- Accepts either a raw `bigquery.Client` instance or any client exposing `.get_sdk() -> bigquery.Client`. + +SDK references: +- Query: client.query(query_string).result() +- List datasets: list(client.list_datasets()) +- Get dataset: client.get_dataset(dataset_id) +- List tables: list(client.list_tables(dataset_id)) +- Get table: client.get_table(table_ref) +- List jobs: list(client.list_jobs()) +- Get job: client.get_job(job_id) +- Create dataset: client.create_dataset(dataset) +- Delete dataset: client.delete_dataset(dataset_id, delete_contents=...) +- Create table: client.create_table(table) +- Delete table: client.delete_table(table_ref) +- Get table schema: client.get_table(table_ref).schema +""" + +import argparse +import textwrap +from typing import Dict, List, Optional, Tuple + +# ----------------------------- +# Configuration knobs (CLI-set) +# ----------------------------- + +DEFAULT_RESPONSE_IMPORT = "from app.sources.client.bigquery.bigquery import BigQueryResponse" +DEFAULT_CLASS_NAME = "BigQueryDataSource" +DEFAULT_OUT = "bigquery_data_source.py" + + +HEADER = '''\ +# ruff: noqa +from __future__ import annotations + +from google.cloud import bigquery # type: ignore[import-untyped] +from typing import Dict, List, Optional, Union, cast + +{response_import} + +class {class_name}: + """ + Strict, typed wrapper over google-cloud-bigquery for common BigQuery operations. + + Accepts either a google-cloud-bigquery `Client` instance *or* any object with `.get_sdk() -> bigquery.Client`. + """ + + def __init__(self, client_or_sdk: Union[bigquery.Client, object]) -> None: + super().__init__() + # Support a raw SDK or a wrapper that exposes `.get_sdk()` + if hasattr(client_or_sdk, "get_sdk"): + sdk_obj = getattr(client_or_sdk, "get_sdk")() + self._sdk: bigquery.Client = cast(bigquery.Client, sdk_obj) + else: + self._sdk = cast(bigquery.Client, client_or_sdk) + + # ---- helpers ---- + @staticmethod + def _params(**kwargs: object) -> Dict[str, object]: + # Filter out Nones to avoid overriding SDK defaults + out: Dict[str, object] = {} + for k, v in kwargs.items(): + if v is None: + continue + if isinstance(v, (list, dict)) and len(v) == 0: + continue + out[k] = v + return out +''' + +FOOTER = """ +""" + +# Each tuple: (signature, body, short_doc) +METHODS: List[Tuple[str, str, str]] = [] + +# ---------- Query ---------- +METHODS += [ + ( + "query(self, query_string: str, project: Optional[str] = None, location: Optional[str] = None) -> BigQueryResponse", + " job_config_kwargs = self._params()\n" + " query_kwargs = self._params(project=project, location=location)\n" + " query_job = self._sdk.query(query_string, **query_kwargs)\n" + " results = query_job.result()\n" + " rows = [dict(row) for row in results]\n" + " return BigQueryResponse(success=True, data=rows)", + "Execute a SQL query and return results as list of dicts.", + ), +] + +# ---------- Datasets ---------- +METHODS += [ + ( + "list_datasets(self, project: Optional[str] = None, max_results: Optional[int] = None) -> BigQueryResponse", + " params = self._params(project=project, max_results=max_results)\n" + " datasets = list(self._sdk.list_datasets(**params))\n" + " return BigQueryResponse(success=True, data=datasets)", + "List datasets in the project.", + ), + ( + "get_dataset(self, dataset_id: str) -> BigQueryResponse", + " dataset = self._sdk.get_dataset(dataset_id)\n" + " return BigQueryResponse(success=True, data=dataset)", + "Get a dataset by ID.", + ), + ( + "create_dataset(self, dataset_id: str, location: Optional[str] = None, description: Optional[str] = None) -> BigQueryResponse", + " dataset_ref = bigquery.Dataset(self._sdk.dataset(dataset_id))\n" + " if location is not None:\n" + " dataset_ref.location = location\n" + " if description is not None:\n" + " dataset_ref.description = description\n" + " dataset = self._sdk.create_dataset(dataset_ref)\n" + " return BigQueryResponse(success=True, data=dataset)", + "Create a new dataset.", + ), + ( + "delete_dataset(self, dataset_id: str, delete_contents: bool = False) -> BigQueryResponse", + " self._sdk.delete_dataset(dataset_id, delete_contents=delete_contents)\n" + " return BigQueryResponse(success=True, data=True)", + "Delete a dataset.", + ), +] + +# ---------- Tables ---------- +METHODS += [ + ( + "list_tables(self, dataset_id: str, max_results: Optional[int] = None) -> BigQueryResponse", + " params = self._params(max_results=max_results)\n" + " tables = list(self._sdk.list_tables(dataset_id, **params))\n" + " return BigQueryResponse(success=True, data=tables)", + "List tables in a dataset.", + ), + ( + "get_table(self, table_ref: str) -> BigQueryResponse", + " table = self._sdk.get_table(table_ref)\n" + " return BigQueryResponse(success=True, data=table)", + "Get a table by reference (dataset.table).", + ), + ( + "create_table(self, table_ref: str, schema: Optional[List[Dict[str, str]]] = None) -> BigQueryResponse", + " table = bigquery.Table(table_ref)\n" + " if schema is not None and len(schema) > 0:\n" + " fields = [bigquery.SchemaField(f['name'], f.get('type', 'STRING'), mode=f.get('mode', 'NULLABLE')) for f in schema]\n" + " table.schema = fields\n" + " result = self._sdk.create_table(table)\n" + " return BigQueryResponse(success=True, data=result)", + "Create a table with optional schema.", + ), + ( + "delete_table(self, table_ref: str) -> BigQueryResponse", + " self._sdk.delete_table(table_ref)\n" + " return BigQueryResponse(success=True, data=True)", + "Delete a table.", + ), + ( + "get_table_schema(self, table_ref: str) -> BigQueryResponse", + " table = self._sdk.get_table(table_ref)\n" + " schema = table.schema\n" + " return BigQueryResponse(success=True, data=schema)", + "Get the schema of a table.", + ), +] + +# ---------- Jobs ---------- +METHODS += [ + ( + "list_jobs(self, project: Optional[str] = None, max_results: Optional[int] = None, state_filter: Optional[str] = None) -> BigQueryResponse", + " params = self._params(project=project, max_results=max_results, state_filter=state_filter)\n" + " jobs = list(self._sdk.list_jobs(**params))\n" + " return BigQueryResponse(success=True, data=jobs)", + "List jobs in the project.", + ), + ( + "get_job(self, job_id: str, project: Optional[str] = None, location: Optional[str] = None) -> BigQueryResponse", + " params = self._params(project=project, location=location)\n" + " job = self._sdk.get_job(job_id, **params)\n" + " return BigQueryResponse(success=True, data=job)", + "Get a job by ID.", + ), +] + +# ------------------------- +# Code emission utilities +# ------------------------- + + +def _emit_method(sig: str, body: str, doc: str) -> str: + normalized_body = textwrap.indent(textwrap.dedent(body), " ") + return f' def {sig}:\n """{doc}"""\n{normalized_body}\n' + + +def build_class( + response_import: str = DEFAULT_RESPONSE_IMPORT, class_name: str = DEFAULT_CLASS_NAME +) -> str: + parts: List[str] = [] + header = HEADER.replace("{response_import}", response_import).replace( + "{class_name}", class_name + ) + parts.append(header) + for sig, body, doc in METHODS: + parts.append(_emit_method(sig, body, doc)) + parts.append(FOOTER) + return "".join(parts) + + +def write_output(path: str, code: str) -> None: + with open(path, "w", encoding="utf-8") as f: + f.write(code) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Generate BigQueryDataSource (google-cloud-bigquery)." + ) + parser.add_argument( + "--out", default=DEFAULT_OUT, help="Output path for the generated data source." + ) + parser.add_argument( + "--response-import", + default=DEFAULT_RESPONSE_IMPORT, + help="Import line to bring in BigQueryResponse.", + ) + parser.add_argument( + "--class-name", + default=DEFAULT_CLASS_NAME, + help="Name of the generated datasource class.", + ) + parser.add_argument( + "--print", + dest="do_print", + action="store_true", + help="Also print generated code to stdout.", + ) + args = parser.parse_args() + + code = build_class(response_import=args.response_import, class_name=args.class_name) + write_output(args.out, code) + if args.do_print: + print(code) + + +if __name__ == "__main__": + main() diff --git a/backend/python/code-generator/canva.py b/backend/python/code-generator/canva.py new file mode 100644 index 000000000..32d7636e8 --- /dev/null +++ b/backend/python/code-generator/canva.py @@ -0,0 +1,642 @@ +# ruff: noqa +""" +Canva Connect REST API Code Generator + +Generates CanvaDataSource class covering Canva Connect API v1: +- User profile +- Designs (list, get, create) +- Folders (list, get, create, items) +- Brand templates (list, get) +- Assets (list, upload) +- Comments (list, create) +- Exports (create, get status) + +The generated DataSource accepts a CanvaClient and uses the client's +configured base URL (https://api.canva.com/rest/v1). + +All methods have explicit parameter signatures with no **kwargs usage. + +API Reference: https://www.canva.dev/docs/connect/ +""" + +import sys +from pathlib import Path +from typing import Dict, List, Optional + +# ================================================================================ +# Canva Connect API Endpoints +# +# Each endpoint defines: +# method: HTTP verb +# path: URL path (appended to base_url which is https://api.canva.com/rest/v1) +# description: Human-readable description +# parameters: Dict of param_name -> {type, location (path/query/body), description} +# required: List of required parameter names +# ================================================================================ + +CANVA_API_ENDPOINTS = { + # ================================================================================ + # USER / PROFILE + # ================================================================================ + "get_current_user": { + "method": "GET", + "path": "/users/me", + "description": "Get the profile of the currently authenticated user", + "parameters": {}, + "required": [], + }, + + # ================================================================================ + # DESIGNS + # ================================================================================ + "list_designs": { + "method": "GET", + "path": "/designs", + "description": "List designs accessible by the authenticated user", + "parameters": { + "ownership": {"type": "Optional[str]", "location": "query", "description": "Filter by ownership (owned, shared, any)"}, + "sort_by": {"type": "Optional[str]", "location": "query", "description": "Sort field (relevance, modified_descending, modified_ascending, title_descending, title_ascending)"}, + "limit": {"type": "Optional[int]", "location": "query", "description": "Maximum number of results to return"}, + "continuation": {"type": "Optional[str]", "location": "query", "description": "Continuation token for pagination"}, + }, + "required": [], + }, + "get_design": { + "method": "GET", + "path": "/designs/{design_id}", + "description": "Get metadata for a specific design", + "parameters": { + "design_id": {"type": "str", "location": "path", "description": "The design ID"}, + }, + "required": ["design_id"], + }, + "create_design": { + "method": "POST", + "path": "/designs", + "description": "Create a new Canva design", + "parameters": { + "design_type": {"type": "Optional[str]", "location": "body", "description": "Type of design to create"}, + "title": {"type": "Optional[str]", "location": "body", "description": "Title for the new design"}, + "width": {"type": "Optional[int]", "location": "body", "description": "Width of the design in pixels"}, + "height": {"type": "Optional[int]", "location": "body", "description": "Height of the design in pixels"}, + "asset_id": {"type": "Optional[str]", "location": "body", "description": "Asset ID to use as design content"}, + }, + "required": [], + }, + + # ================================================================================ + # FOLDERS + # ================================================================================ + "list_folders": { + "method": "GET", + "path": "/folders", + "description": "List folders accessible by the authenticated user", + "parameters": { + "sort_by": {"type": "Optional[str]", "location": "query", "description": "Sort field (relevance, modified_descending, modified_ascending, title_descending, title_ascending)"}, + "limit": {"type": "Optional[int]", "location": "query", "description": "Maximum number of results to return"}, + "continuation": {"type": "Optional[str]", "location": "query", "description": "Continuation token for pagination"}, + }, + "required": [], + }, + "get_folder": { + "method": "GET", + "path": "/folders/{folder_id}", + "description": "Get metadata for a specific folder", + "parameters": { + "folder_id": {"type": "str", "location": "path", "description": "The folder ID"}, + }, + "required": ["folder_id"], + }, + "list_folder_items": { + "method": "GET", + "path": "/folders/{folder_id}/items", + "description": "List items within a specific folder", + "parameters": { + "folder_id": {"type": "str", "location": "path", "description": "The folder ID"}, + "item_types": {"type": "Optional[str]", "location": "query", "description": "Filter by item type (design, folder, image)"}, + "sort_by": {"type": "Optional[str]", "location": "query", "description": "Sort field (relevance, modified_descending, modified_ascending, title_descending, title_ascending)"}, + "limit": {"type": "Optional[int]", "location": "query", "description": "Maximum number of results to return"}, + "continuation": {"type": "Optional[str]", "location": "query", "description": "Continuation token for pagination"}, + }, + "required": ["folder_id"], + }, + "create_folder": { + "method": "POST", + "path": "/folders", + "description": "Create a new folder", + "parameters": { + "name": {"type": "str", "location": "body", "description": "Name of the folder"}, + "parent_folder_id": {"type": "Optional[str]", "location": "body", "description": "ID of the parent folder"}, + }, + "required": ["name"], + }, + + # ================================================================================ + # BRAND TEMPLATES + # ================================================================================ + "list_brand_templates": { + "method": "GET", + "path": "/brand-templates", + "description": "List brand templates accessible by the authenticated user", + "parameters": { + "dataset": {"type": "Optional[str]", "location": "query", "description": "Filter by dataset"}, + "ownership": {"type": "Optional[str]", "location": "query", "description": "Filter by ownership (owned, shared, any)"}, + "sort_by": {"type": "Optional[str]", "location": "query", "description": "Sort field"}, + "limit": {"type": "Optional[int]", "location": "query", "description": "Maximum number of results to return"}, + "continuation": {"type": "Optional[str]", "location": "query", "description": "Continuation token for pagination"}, + }, + "required": [], + }, + "get_brand_template": { + "method": "GET", + "path": "/brand-templates/{brand_template_id}", + "description": "Get metadata for a specific brand template", + "parameters": { + "brand_template_id": {"type": "str", "location": "path", "description": "The brand template ID"}, + }, + "required": ["brand_template_id"], + }, + + # ================================================================================ + # ASSETS + # ================================================================================ + "list_assets": { + "method": "GET", + "path": "/assets", + "description": "List assets accessible by the authenticated user", + "parameters": { + "sort_by": {"type": "Optional[str]", "location": "query", "description": "Sort field"}, + "limit": {"type": "Optional[int]", "location": "query", "description": "Maximum number of results to return"}, + "continuation": {"type": "Optional[str]", "location": "query", "description": "Continuation token for pagination"}, + }, + "required": [], + }, + "upload_asset": { + "method": "POST", + "path": "/assets/upload", + "description": "Upload an asset to Canva (multipart upload)", + "parameters": { + "name": {"type": "str", "location": "body", "description": "Name of the asset"}, + "folder_id": {"type": "Optional[str]", "location": "body", "description": "Target folder ID for the asset"}, + }, + "required": ["name"], + }, + + # ================================================================================ + # COMMENTS + # ================================================================================ + "list_design_comments": { + "method": "GET", + "path": "/comments/{design_id}", + "description": "List comments on a specific design", + "parameters": { + "design_id": {"type": "str", "location": "path", "description": "The design ID"}, + "limit": {"type": "Optional[int]", "location": "query", "description": "Maximum number of results to return"}, + "continuation": {"type": "Optional[str]", "location": "query", "description": "Continuation token for pagination"}, + }, + "required": ["design_id"], + }, + "create_design_comment": { + "method": "POST", + "path": "/comments/{design_id}", + "description": "Create a comment on a specific design", + "parameters": { + "design_id": {"type": "str", "location": "path", "description": "The design ID"}, + "message": {"type": "str", "location": "body", "description": "The comment message text"}, + }, + "required": ["design_id", "message"], + }, + + # ================================================================================ + # EXPORTS + # ================================================================================ + "create_export": { + "method": "POST", + "path": "/exports", + "description": "Create an export job to export a design", + "parameters": { + "design_id": {"type": "str", "location": "body", "description": "The design ID to export"}, + "format": {"type": "Optional[str]", "location": "body", "description": "Export format (pdf, jpg, png, gif, pptx, mp4)"}, + "quality": {"type": "Optional[str]", "location": "body", "description": "Export quality (regular, pro)"}, + "pages": {"type": "Optional[list[int]]", "location": "body", "description": "List of page indices to export"}, + "width": {"type": "Optional[int]", "location": "body", "description": "Target width in pixels"}, + "height": {"type": "Optional[int]", "location": "body", "description": "Target height in pixels"}, + }, + "required": ["design_id"], + }, + "get_export": { + "method": "GET", + "path": "/exports/{export_id}", + "description": "Get the status and result of an export job", + "parameters": { + "export_id": {"type": "str", "location": "path", "description": "The export job ID"}, + }, + "required": ["export_id"], + }, +} + + +class CanvaDataSourceGenerator: + """Generator for comprehensive Canva Connect REST API datasource class. + + Generates methods for Canva Connect API v1 endpoints. + The generated DataSource class accepts a CanvaClient whose base URL + setting determines the API endpoint. + """ + + def __init__(self): + self.generated_methods: List[Dict[str, str]] = [] + + def _sanitize_parameter_name(self, name: str) -> str: + """Sanitize parameter names to be valid Python identifiers.""" + sanitized = name.replace("-", "_").replace(".", "_").replace("/", "_") + if sanitized and not (sanitized[0].isalpha() or sanitized[0] == "_"): + sanitized = f"param_{sanitized}" + return sanitized + + def _build_query_params(self, endpoint_info: Dict) -> List[str]: + """Build query parameter handling code.""" + lines = [" query_params: dict[str, Any] = {}"] + + for param_name, param_info in endpoint_info["parameters"].items(): + if param_info["location"] == "query": + sanitized_name = self._sanitize_parameter_name(param_name) + + if "Optional[bool]" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}'] = str({sanitized_name}).lower()", + ]) + elif "Optional[int]" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}'] = str({sanitized_name})", + ]) + elif "List[" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}[]'] = {sanitized_name}", + ]) + else: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}'] = {sanitized_name}", + ]) + + return lines + + def _build_path_formatting(self, path: str, endpoint_info: Dict) -> str: + """Build URL path with parameter substitution.""" + path_params = [ + name + for name, info in endpoint_info["parameters"].items() + if info["location"] == "path" + ] + + if path_params: + format_dict = ", ".join( + f"{param}={self._sanitize_parameter_name(param)}" + for param in path_params + ) + return f' url = self.base_url + "{path}".format({format_dict})' + else: + return f' url = self.base_url + "{path}"' + + def _build_request_body(self, endpoint_info: Dict) -> List[str]: + """Build request body handling.""" + body_params = { + name: info + for name, info in endpoint_info["parameters"].items() + if info["location"] == "body" + } + + if not body_params: + return [] + + lines = [" body: dict[str, Any] = {}"] + + for param_name, param_info in body_params.items(): + sanitized_name = self._sanitize_parameter_name(param_name) + + if param_name in endpoint_info["required"]: + lines.append(f" body['{param_name}'] = {sanitized_name}") + else: + lines.extend([ + f" if {sanitized_name} is not None:", + f" body['{param_name}'] = {sanitized_name}", + ]) + + return lines + + @staticmethod + def _modernize_type(type_str: str) -> str: + """Convert typing-style annotations to modern Python 3.10+ syntax. + + Optional[str] -> str | None, Dict[str, Any] -> dict[str, Any], + List[str] -> list[str], etc. + """ + if type_str.startswith("Optional[") and type_str.endswith("]"): + inner = type_str[len("Optional["):-1] + inner = CanvaDataSourceGenerator._modernize_type(inner) + return f"{inner} | None" + if type_str.startswith("Dict["): + inner = type_str[len("Dict["):-1] + parts = CanvaDataSourceGenerator._split_type_args(inner) + modernized = ", ".join( + CanvaDataSourceGenerator._modernize_type(p.strip()) for p in parts + ) + return f"dict[{modernized}]" + if type_str == "Dict": + return "dict" + if type_str.startswith("List["): + inner = type_str[len("List["):-1] + parts = CanvaDataSourceGenerator._split_type_args(inner) + modernized = ", ".join( + CanvaDataSourceGenerator._modernize_type(p.strip()) for p in parts + ) + return f"list[{modernized}]" + if type_str == "List": + return "list" + return type_str + + @staticmethod + def _split_type_args(s: str) -> List[str]: + """Split type arguments respecting nested brackets.""" + parts = [] + depth = 0 + current = "" + for ch in s: + if ch == "[": + depth += 1 + elif ch == "]": + depth -= 1 + if ch == "," and depth == 0: + parts.append(current.strip()) + current = "" + else: + current += ch + if current.strip(): + parts.append(current.strip()) + return parts + + def _generate_method_signature(self, method_name: str, endpoint_info: Dict) -> str: + """Generate method signature with explicit parameters.""" + params = ["self"] + has_any_bool = False + + # Collect required params, split into non-bool and bool groups + required_non_bool: List[str] = [] + required_bool: List[str] = [] + for param_name in endpoint_info["required"]: + if param_name in endpoint_info["parameters"]: + param_info = endpoint_info["parameters"][param_name] + sanitized_name = self._sanitize_parameter_name(param_name) + modern_type = self._modernize_type(param_info["type"]) + param_str = f"{sanitized_name}: {modern_type}" + if "bool" in param_info.get("type", ""): + required_bool.append(param_str) + has_any_bool = True + else: + required_non_bool.append(param_str) + + # Collect optional parameters + optional_params: List[str] = [] + for param_name, param_info in endpoint_info["parameters"].items(): + if param_name not in endpoint_info["required"]: + sanitized_name = self._sanitize_parameter_name(param_name) + modern_type = self._modernize_type(param_info["type"]) + if "| None" not in modern_type: + modern_type = f"{modern_type} | None" + optional_params.append(f"{sanitized_name}: {modern_type} = None") + if "bool" in param_info.get("type", ""): + has_any_bool = True + + # Build signature: non-bool required first, then * if needed, then bool required + optional + params.extend(required_non_bool) + if has_any_bool and (required_bool or optional_params): + params.append("*") + params.extend(required_bool) + params.extend(optional_params) + + signature_params = ",\n ".join(params) + return f" async def {method_name}(\n {signature_params}\n ) -> CanvaResponse:" + + def _generate_method_docstring(self, endpoint_info: Dict) -> List[str]: + """Generate method docstring.""" + lines = [f' """{endpoint_info["description"]}', ""] + + if endpoint_info["parameters"]: + lines.append(" Args:") + for param_name, param_info in endpoint_info["parameters"].items(): + sanitized_name = self._sanitize_parameter_name(param_name) + lines.append( + f" {sanitized_name}: {param_info['description']}" + ) + lines.append("") + + lines.extend([ + " Returns:", + " CanvaResponse with operation result", + ' """', + ]) + + return lines + + def _generate_method(self, method_name: str, endpoint_info: Dict) -> str: + """Generate a complete method for an API endpoint.""" + lines = [] + + # Method signature + lines.append(self._generate_method_signature(method_name, endpoint_info)) + + # Docstring + lines.extend(self._generate_method_docstring(endpoint_info)) + + # Query parameters + has_query = any( + info["location"] == "query" + for info in endpoint_info["parameters"].values() + ) + if has_query: + query_lines = self._build_query_params(endpoint_info) + lines.extend(query_lines) + lines.append("") + + # URL construction + lines.append(self._build_path_formatting(endpoint_info["path"], endpoint_info)) + + # Request body + body_lines = self._build_request_body(endpoint_info) + if body_lines: + lines.append("") + lines.extend(body_lines) + + # Request construction and execution + lines.append("") + lines.append(" try:") + lines.append(" request = HTTPRequest(") + lines.append(f' method="{endpoint_info["method"]}",') + lines.append(" url=url,") + lines.append(' headers={"Content-Type": "application/json"},') + if has_query: + lines.append(" query=query_params,") + if body_lines: + lines.append(" body=body,") + lines.append(" )") + lines.extend([ + " response = await self.http.execute(request) # type: ignore[reportUnknownMemberType]", + " response_data = response.json() if response.text() else None", + " return CanvaResponse(", + " success=response.status < HTTP_ERROR_THRESHOLD,", + " data=response_data,", + f' message="Successfully executed {method_name}" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {{response.status}}"', + " )", + " except Exception as e:", + f' return CanvaResponse(success=False, error=str(e), message="Failed to execute {method_name}")', + ]) + + self.generated_methods.append({ + "name": method_name, + "endpoint": endpoint_info["path"], + "method": endpoint_info["method"], + "description": endpoint_info["description"], + }) + + return "\n".join(lines) + + def generate_canva_datasource(self) -> str: + """Generate the complete Canva datasource class.""" + + class_lines = [ + '"""', + "Canva Connect REST API DataSource - Auto-generated API wrapper", + "", + "Generated from Canva Connect REST API v1 documentation.", + "Uses HTTP client for direct REST API interactions.", + "All methods have explicit parameter signatures.", + '"""', + "", + "from __future__ import annotations", + "", + "from typing import Any", + "", + "from app.sources.client.canva.canva import CanvaClient, CanvaResponse", + "from app.sources.client.http.http_request import HTTPRequest", + "", + "# HTTP status code constant", + "HTTP_ERROR_THRESHOLD = 400", + "", + "", + "class CanvaDataSource:", + ' """Canva Connect REST API DataSource', + "", + " Provides async wrapper methods for Canva Connect REST API operations:", + " - User profile", + " - Designs (list, get, create)", + " - Folders (list, get, create, items)", + " - Brand templates (list, get)", + " - Assets (list, upload)", + " - Comments (list, create)", + " - Exports (create, get status)", + "", + " The base URL is determined by the CanvaClient's configured base URL.", + " All methods return CanvaResponse objects.", + ' """', + "", + " def __init__(self, client: CanvaClient) -> None:", + ' """Initialize with CanvaClient.', + "", + " Args:", + " client: CanvaClient instance with configured authentication", + ' """', + " self._client = client", + " self.http = client.get_client()", + " try:", + " self.base_url = self.http.get_base_url().rstrip('/')", + " except AttributeError as exc:", + " raise ValueError('HTTP client does not have get_base_url method') from exc", + "", + " def get_data_source(self) -> 'CanvaDataSource':", + ' """Return the data source instance."""', + " return self", + "", + " def get_client(self) -> CanvaClient:", + ' """Return the underlying CanvaClient."""', + " return self._client", + "", + ] + + # Generate all API methods + for method_name, endpoint_info in CANVA_API_ENDPOINTS.items(): + class_lines.append(self._generate_method(method_name, endpoint_info)) + class_lines.append("") + + return "\n".join(class_lines) + + def save_to_file(self, filename: Optional[str] = None) -> None: + """Generate and save the Canva datasource to a file.""" + if filename is None: + filename = "canva.py" + + script_dir = Path(__file__).parent if __file__ else Path(".") + canva_dir = script_dir.parent / "app" / "sources" / "external" / "canva" + canva_dir.mkdir(parents=True, exist_ok=True) + + full_path = canva_dir / filename + + class_code = self.generate_canva_datasource() + + # Strip trailing whitespace from every line + clean_lines = [line.rstrip() for line in class_code.split("\n")] + full_path.write_text("\n".join(clean_lines), encoding="utf-8") + + print(f"Generated Canva data source with {len(self.generated_methods)} methods") + print(f"Saved to: {full_path}") + + # Print resource summary + resource_categories = { + "User/Profile": 0, + "Design": 0, + "Folder": 0, + "Brand Template": 0, + "Asset": 0, + "Comment": 0, + "Export": 0, + } + + for method in self.generated_methods: + endpoint = method["endpoint"] + if "users" in endpoint: + resource_categories["User/Profile"] += 1 + elif "designs" in endpoint and "comments" not in endpoint: + resource_categories["Design"] += 1 + elif "folders" in endpoint: + resource_categories["Folder"] += 1 + elif "brand-templates" in endpoint: + resource_categories["Brand Template"] += 1 + elif "assets" in endpoint: + resource_categories["Asset"] += 1 + elif "comments" in endpoint: + resource_categories["Comment"] += 1 + elif "exports" in endpoint: + resource_categories["Export"] += 1 + + print(f"\nMethods by resource:") + for category, count in resource_categories.items(): + if count > 0: + print(f" - {category}: {count}") + + +def main() -> int: + """Main function for Canva data source generator.""" + try: + generator = CanvaDataSourceGenerator() + generator.save_to_file() + return 0 + except Exception as e: + print(f"Failed to generate Canva data source: {e}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/python/code-generator/coda.py b/backend/python/code-generator/coda.py new file mode 100644 index 000000000..afa2d5cef --- /dev/null +++ b/backend/python/code-generator/coda.py @@ -0,0 +1,762 @@ +# ruff: noqa +""" +Coda REST API Code Generator + +Generates CodaDataSource class covering Coda API v1: +- User / Account operations +- Doc CRUD and management +- Table and Row operations +- Column management +- Page operations +- Formula and Control access +- Permission management +- Category listing + +The generated DataSource accepts a CodaClient and uses the client's +base URL (https://coda.io/apis/v1) to construct request URLs. + +All methods have explicit parameter signatures with no **kwargs usage. +""" + +import sys +from pathlib import Path +from typing import Dict, List, Optional + +# ================================================================================ +# Coda API Endpoints - organized by resource +# +# Each endpoint defines: +# method: HTTP verb +# path: URL path (appended to base_url which is https://coda.io/apis/v1) +# description: Human-readable description +# parameters: Dict of param_name -> {type, location (path/query/body), description} +# required: List of required parameter names +# ================================================================================ + +CODA_API_ENDPOINTS = { + # ================================================================================ + # USER / ACCOUNT + # ================================================================================ + "whoami": { + "method": "GET", + "path": "/whoami", + "description": "Get information about the current user", + "parameters": {}, + "required": [], + }, + + # ================================================================================ + # DOCS + # ================================================================================ + "list_docs": { + "method": "GET", + "path": "/docs", + "description": "List available Coda docs", + "parameters": { + "is_owner": {"type": "Optional[bool]", "location": "query", "description": "Show only docs owned by the user"}, + "query": {"type": "Optional[str]", "location": "query", "description": "Search term to filter docs"}, + "source_doc": {"type": "Optional[str]", "location": "query", "description": "Show only docs copied from the specified source doc"}, + "is_starred": {"type": "Optional[bool]", "location": "query", "description": "Show only starred docs"}, + "in_gallery": {"type": "Optional[bool]", "location": "query", "description": "Show only docs in the gallery"}, + "workspace_id": {"type": "Optional[str]", "location": "query", "description": "Show only docs in the given workspace"}, + "folder_id": {"type": "Optional[str]", "location": "query", "description": "Show only docs in the given folder"}, + "limit": {"type": "Optional[int]", "location": "query", "description": "Maximum number of results to return"}, + "page_token": {"type": "Optional[str]", "location": "query", "description": "An opaque token for pagination"}, + }, + "required": [], + }, + "get_doc": { + "method": "GET", + "path": "/docs/{doc_id}", + "description": "Get info about a specific doc", + "parameters": { + "doc_id": {"type": "str", "location": "path", "description": "The ID of the doc"}, + }, + "required": ["doc_id"], + }, + "create_doc": { + "method": "POST", + "path": "/docs", + "description": "Create a new Coda doc", + "parameters": { + "title": {"type": "Optional[str]", "location": "body", "description": "Title of the new doc"}, + "source_doc": {"type": "Optional[str]", "location": "body", "description": "ID of a doc to copy"}, + "timezone": {"type": "Optional[str]", "location": "body", "description": "Timezone for the doc"}, + "folder_id": {"type": "Optional[str]", "location": "body", "description": "ID of the folder to create the doc in"}, + }, + "required": [], + }, + "delete_doc": { + "method": "DELETE", + "path": "/docs/{doc_id}", + "description": "Delete a doc", + "parameters": { + "doc_id": {"type": "str", "location": "path", "description": "The ID of the doc to delete"}, + }, + "required": ["doc_id"], + }, + + # ================================================================================ + # TABLES + # ================================================================================ + "list_tables": { + "method": "GET", + "path": "/docs/{doc_id}/tables", + "description": "List tables in a doc", + "parameters": { + "doc_id": {"type": "str", "location": "path", "description": "The ID of the doc"}, + "limit": {"type": "Optional[int]", "location": "query", "description": "Maximum number of results to return"}, + "page_token": {"type": "Optional[str]", "location": "query", "description": "An opaque token for pagination"}, + "sort_by": {"type": "Optional[str]", "location": "query", "description": "Sort order of the results"}, + "table_types": {"type": "Optional[str]", "location": "query", "description": "Comma-separated list of table types to include"}, + }, + "required": ["doc_id"], + }, + "get_table": { + "method": "GET", + "path": "/docs/{doc_id}/tables/{table_id_or_name}", + "description": "Get info about a specific table", + "parameters": { + "doc_id": {"type": "str", "location": "path", "description": "The ID of the doc"}, + "table_id_or_name": {"type": "str", "location": "path", "description": "The ID or name of the table"}, + }, + "required": ["doc_id", "table_id_or_name"], + }, + + # ================================================================================ + # ROWS + # ================================================================================ + "list_rows": { + "method": "GET", + "path": "/docs/{doc_id}/tables/{table_id_or_name}/rows", + "description": "List rows in a table", + "parameters": { + "doc_id": {"type": "str", "location": "path", "description": "The ID of the doc"}, + "table_id_or_name": {"type": "str", "location": "path", "description": "The ID or name of the table"}, + "limit": {"type": "Optional[int]", "location": "query", "description": "Maximum number of results to return"}, + "page_token": {"type": "Optional[str]", "location": "query", "description": "An opaque token for pagination"}, + "query": {"type": "Optional[str]", "location": "query", "description": "Search query to filter rows"}, + "sort_by": {"type": "Optional[str]", "location": "query", "description": "Sort order of the results"}, + "use_column_names": {"type": "Optional[bool]", "location": "query", "description": "Use column names instead of column IDs in the response"}, + "value_format": {"type": "Optional[str]", "location": "query", "description": "Format of cell values (simple, simpleWithArrays, rich)"}, + "visible_only": {"type": "Optional[bool]", "location": "query", "description": "Show only visible rows"}, + }, + "required": ["doc_id", "table_id_or_name"], + }, + "get_row": { + "method": "GET", + "path": "/docs/{doc_id}/tables/{table_id_or_name}/rows/{row_id_or_name}", + "description": "Get a specific row in a table", + "parameters": { + "doc_id": {"type": "str", "location": "path", "description": "The ID of the doc"}, + "table_id_or_name": {"type": "str", "location": "path", "description": "The ID or name of the table"}, + "row_id_or_name": {"type": "str", "location": "path", "description": "The ID or name of the row"}, + "use_column_names": {"type": "Optional[bool]", "location": "query", "description": "Use column names instead of column IDs"}, + "value_format": {"type": "Optional[str]", "location": "query", "description": "Format of cell values"}, + }, + "required": ["doc_id", "table_id_or_name", "row_id_or_name"], + }, + "insert_rows": { + "method": "POST", + "path": "/docs/{doc_id}/tables/{table_id_or_name}/rows", + "description": "Insert or upsert rows in a table", + "parameters": { + "doc_id": {"type": "str", "location": "path", "description": "The ID of the doc"}, + "table_id_or_name": {"type": "str", "location": "path", "description": "The ID or name of the table"}, + "rows": {"type": "list[dict[str, Any]]", "location": "body", "description": "Array of row objects to insert"}, + "key_columns": {"type": "Optional[list[str]]", "location": "body", "description": "Optional column IDs for upsert key matching"}, + }, + "required": ["doc_id", "table_id_or_name", "rows"], + }, + "update_row": { + "method": "PUT", + "path": "/docs/{doc_id}/tables/{table_id_or_name}/rows/{row_id_or_name}", + "description": "Update a specific row in a table", + "parameters": { + "doc_id": {"type": "str", "location": "path", "description": "The ID of the doc"}, + "table_id_or_name": {"type": "str", "location": "path", "description": "The ID or name of the table"}, + "row_id_or_name": {"type": "str", "location": "path", "description": "The ID or name of the row"}, + "row": {"type": "dict[str, Any]", "location": "body", "description": "Row object with cells to update"}, + }, + "required": ["doc_id", "table_id_or_name", "row_id_or_name", "row"], + }, + "delete_row": { + "method": "DELETE", + "path": "/docs/{doc_id}/tables/{table_id_or_name}/rows/{row_id_or_name}", + "description": "Delete a specific row from a table", + "parameters": { + "doc_id": {"type": "str", "location": "path", "description": "The ID of the doc"}, + "table_id_or_name": {"type": "str", "location": "path", "description": "The ID or name of the table"}, + "row_id_or_name": {"type": "str", "location": "path", "description": "The ID or name of the row to delete"}, + }, + "required": ["doc_id", "table_id_or_name", "row_id_or_name"], + }, + + # ================================================================================ + # COLUMNS + # ================================================================================ + "list_columns": { + "method": "GET", + "path": "/docs/{doc_id}/tables/{table_id_or_name}/columns", + "description": "List columns in a table", + "parameters": { + "doc_id": {"type": "str", "location": "path", "description": "The ID of the doc"}, + "table_id_or_name": {"type": "str", "location": "path", "description": "The ID or name of the table"}, + "limit": {"type": "Optional[int]", "location": "query", "description": "Maximum number of results to return"}, + "page_token": {"type": "Optional[str]", "location": "query", "description": "An opaque token for pagination"}, + "visible_only": {"type": "Optional[bool]", "location": "query", "description": "Show only visible columns"}, + }, + "required": ["doc_id", "table_id_or_name"], + }, + "get_column": { + "method": "GET", + "path": "/docs/{doc_id}/tables/{table_id_or_name}/columns/{column_id_or_name}", + "description": "Get info about a specific column", + "parameters": { + "doc_id": {"type": "str", "location": "path", "description": "The ID of the doc"}, + "table_id_or_name": {"type": "str", "location": "path", "description": "The ID or name of the table"}, + "column_id_or_name": {"type": "str", "location": "path", "description": "The ID or name of the column"}, + }, + "required": ["doc_id", "table_id_or_name", "column_id_or_name"], + }, + + # ================================================================================ + # PAGES + # ================================================================================ + "list_pages": { + "method": "GET", + "path": "/docs/{doc_id}/pages", + "description": "List pages in a doc", + "parameters": { + "doc_id": {"type": "str", "location": "path", "description": "The ID of the doc"}, + "limit": {"type": "Optional[int]", "location": "query", "description": "Maximum number of results to return"}, + "page_token": {"type": "Optional[str]", "location": "query", "description": "An opaque token for pagination"}, + }, + "required": ["doc_id"], + }, + "get_page": { + "method": "GET", + "path": "/docs/{doc_id}/pages/{page_id_or_name}", + "description": "Get info about a specific page", + "parameters": { + "doc_id": {"type": "str", "location": "path", "description": "The ID of the doc"}, + "page_id_or_name": {"type": "str", "location": "path", "description": "The ID or name of the page"}, + }, + "required": ["doc_id", "page_id_or_name"], + }, + "update_page": { + "method": "PUT", + "path": "/docs/{doc_id}/pages/{page_id_or_name}", + "description": "Update a page in a doc", + "parameters": { + "doc_id": {"type": "str", "location": "path", "description": "The ID of the doc"}, + "page_id_or_name": {"type": "str", "location": "path", "description": "The ID or name of the page"}, + "name": {"type": "Optional[str]", "location": "body", "description": "New name for the page"}, + "subtitle": {"type": "Optional[str]", "location": "body", "description": "New subtitle for the page"}, + "icon_name": {"type": "Optional[str]", "location": "body", "description": "Name of the icon for the page"}, + "image_url": {"type": "Optional[str]", "location": "body", "description": "URL of the cover image for the page"}, + }, + "required": ["doc_id", "page_id_or_name"], + }, + + # ================================================================================ + # FORMULAS + # ================================================================================ + "list_formulas": { + "method": "GET", + "path": "/docs/{doc_id}/formulas", + "description": "List named formulas in a doc", + "parameters": { + "doc_id": {"type": "str", "location": "path", "description": "The ID of the doc"}, + "limit": {"type": "Optional[int]", "location": "query", "description": "Maximum number of results to return"}, + "page_token": {"type": "Optional[str]", "location": "query", "description": "An opaque token for pagination"}, + "sort_by": {"type": "Optional[str]", "location": "query", "description": "Sort order of the results"}, + }, + "required": ["doc_id"], + }, + "get_formula": { + "method": "GET", + "path": "/docs/{doc_id}/formulas/{formula_id_or_name}", + "description": "Get info about a specific formula", + "parameters": { + "doc_id": {"type": "str", "location": "path", "description": "The ID of the doc"}, + "formula_id_or_name": {"type": "str", "location": "path", "description": "The ID or name of the formula"}, + }, + "required": ["doc_id", "formula_id_or_name"], + }, + + # ================================================================================ + # CONTROLS + # ================================================================================ + "list_controls": { + "method": "GET", + "path": "/docs/{doc_id}/controls", + "description": "List controls in a doc", + "parameters": { + "doc_id": {"type": "str", "location": "path", "description": "The ID of the doc"}, + "limit": {"type": "Optional[int]", "location": "query", "description": "Maximum number of results to return"}, + "page_token": {"type": "Optional[str]", "location": "query", "description": "An opaque token for pagination"}, + "sort_by": {"type": "Optional[str]", "location": "query", "description": "Sort order of the results"}, + }, + "required": ["doc_id"], + }, + "get_control": { + "method": "GET", + "path": "/docs/{doc_id}/controls/{control_id_or_name}", + "description": "Get info about a specific control", + "parameters": { + "doc_id": {"type": "str", "location": "path", "description": "The ID of the doc"}, + "control_id_or_name": {"type": "str", "location": "path", "description": "The ID or name of the control"}, + }, + "required": ["doc_id", "control_id_or_name"], + }, + + # ================================================================================ + # PERMISSIONS + # ================================================================================ + "list_permissions": { + "method": "GET", + "path": "/docs/{doc_id}/acl/permissions", + "description": "List permissions for a doc", + "parameters": { + "doc_id": {"type": "str", "location": "path", "description": "The ID of the doc"}, + }, + "required": ["doc_id"], + }, + + # ================================================================================ + # CATEGORIES + # ================================================================================ + "list_categories": { + "method": "GET", + "path": "/categories", + "description": "List available doc categories", + "parameters": {}, + "required": [], + }, +} + + +class CodaDataSourceGenerator: + """Generator for comprehensive Coda REST API datasource class. + + Generates methods for Coda API v1 endpoints. + The generated DataSource class accepts a CodaClient whose base URL + setting determines the API endpoint. + """ + + def __init__(self): + self.generated_methods: List[Dict[str, str]] = [] + + def _sanitize_parameter_name(self, name: str) -> str: + """Sanitize parameter names to be valid Python identifiers.""" + sanitized = name.replace("-", "_").replace(".", "_").replace("/", "_") + if sanitized and not (sanitized[0].isalpha() or sanitized[0] == "_"): + sanitized = f"param_{sanitized}" + return sanitized + + def _build_query_params(self, endpoint_info: Dict) -> List[str]: + """Build query parameter handling code.""" + lines = [" query_params: dict[str, Any] = {}"] + + for param_name, param_info in endpoint_info["parameters"].items(): + if param_info["location"] == "query": + sanitized_name = self._sanitize_parameter_name(param_name) + + if "Optional[bool]" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}'] = str({sanitized_name}).lower()", + ]) + elif "Optional[int]" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}'] = str({sanitized_name})", + ]) + elif "List[" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}[]'] = {sanitized_name}", + ]) + else: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}'] = {sanitized_name}", + ]) + + return lines + + def _build_path_formatting(self, path: str, endpoint_info: Dict) -> str: + """Build URL path with parameter substitution.""" + path_params = [ + name + for name, info in endpoint_info["parameters"].items() + if info["location"] == "path" + ] + + if path_params: + format_dict = ", ".join( + f"{param}={self._sanitize_parameter_name(param)}" + for param in path_params + ) + return f' url = self.base_url + "{path}".format({format_dict})' + else: + return f' url = self.base_url + "{path}"' + + def _build_request_body(self, endpoint_info: Dict) -> List[str]: + """Build request body handling.""" + body_params = { + name: info + for name, info in endpoint_info["parameters"].items() + if info["location"] == "body" + } + + if not body_params: + return [] + + lines = [" body: dict[str, Any] = {}"] + + for param_name, param_info in body_params.items(): + sanitized_name = self._sanitize_parameter_name(param_name) + + if param_name in endpoint_info["required"]: + lines.append(f" body['{param_name}'] = {sanitized_name}") + else: + lines.extend([ + f" if {sanitized_name} is not None:", + f" body['{param_name}'] = {sanitized_name}", + ]) + + return lines + + @staticmethod + def _modernize_type(type_str: str) -> str: + """Convert typing-style annotations to modern Python 3.10+ syntax. + + Optional[str] -> str | None, Dict[str, Any] -> dict[str, Any], + List[str] -> list[str], etc. + """ + if type_str.startswith("Optional[") and type_str.endswith("]"): + inner = type_str[len("Optional["):-1] + inner = CodaDataSourceGenerator._modernize_type(inner) + return f"{inner} | None" + if type_str.startswith("Dict["): + inner = type_str[len("Dict["):-1] + parts = CodaDataSourceGenerator._split_type_args(inner) + modernized = ", ".join( + CodaDataSourceGenerator._modernize_type(p.strip()) for p in parts + ) + return f"dict[{modernized}]" + if type_str == "Dict": + return "dict" + if type_str.startswith("List["): + inner = type_str[len("List["):-1] + parts = CodaDataSourceGenerator._split_type_args(inner) + modernized = ", ".join( + CodaDataSourceGenerator._modernize_type(p.strip()) for p in parts + ) + return f"list[{modernized}]" + if type_str == "List": + return "list" + return type_str + + @staticmethod + def _split_type_args(s: str) -> List[str]: + """Split type arguments respecting nested brackets.""" + parts = [] + depth = 0 + current = "" + for ch in s: + if ch == "[": + depth += 1 + elif ch == "]": + depth -= 1 + if ch == "," and depth == 0: + parts.append(current.strip()) + current = "" + else: + current += ch + if current.strip(): + parts.append(current.strip()) + return parts + + def _generate_method_signature(self, method_name: str, endpoint_info: Dict) -> str: + """Generate method signature with explicit parameters.""" + params = ["self"] + has_any_bool = False + + # Collect required params, split into non-bool and bool groups + required_non_bool: List[str] = [] + required_bool: List[str] = [] + for param_name in endpoint_info["required"]: + if param_name in endpoint_info["parameters"]: + param_info = endpoint_info["parameters"][param_name] + sanitized_name = self._sanitize_parameter_name(param_name) + modern_type = self._modernize_type(param_info["type"]) + param_str = f"{sanitized_name}: {modern_type}" + if "bool" in param_info.get("type", ""): + required_bool.append(param_str) + has_any_bool = True + else: + required_non_bool.append(param_str) + + # Collect optional parameters + optional_params: List[str] = [] + for param_name, param_info in endpoint_info["parameters"].items(): + if param_name not in endpoint_info["required"]: + sanitized_name = self._sanitize_parameter_name(param_name) + modern_type = self._modernize_type(param_info["type"]) + if "| None" not in modern_type: + modern_type = f"{modern_type} | None" + optional_params.append(f"{sanitized_name}: {modern_type} = None") + if "bool" in param_info.get("type", ""): + has_any_bool = True + + # Build signature: non-bool required first, then * if needed, then bool required + optional + params.extend(required_non_bool) + if has_any_bool and (required_bool or optional_params): + params.append("*") + params.extend(required_bool) + params.extend(optional_params) + + signature_params = ",\n ".join(params) + return f" async def {method_name}(\n {signature_params}\n ) -> CodaResponse:" + + def _generate_method_docstring(self, endpoint_info: Dict) -> List[str]: + """Generate method docstring.""" + lines = [f' """{endpoint_info["description"]}', ""] + + if endpoint_info["parameters"]: + lines.append(" Args:") + for param_name, param_info in endpoint_info["parameters"].items(): + sanitized_name = self._sanitize_parameter_name(param_name) + lines.append( + f" {sanitized_name}: {param_info['description']}" + ) + lines.append("") + + lines.extend([ + " Returns:", + " CodaResponse with operation result", + ' """', + ]) + + return lines + + def _generate_method(self, method_name: str, endpoint_info: Dict) -> str: + """Generate a complete method for an API endpoint.""" + lines = [] + + # Method signature + lines.append(self._generate_method_signature(method_name, endpoint_info)) + + # Docstring + lines.extend(self._generate_method_docstring(endpoint_info)) + + # Query parameters + has_query = any( + info["location"] == "query" + for info in endpoint_info["parameters"].values() + ) + if has_query: + query_lines = self._build_query_params(endpoint_info) + lines.extend(query_lines) + lines.append("") + + # URL construction + lines.append(self._build_path_formatting(endpoint_info["path"], endpoint_info)) + + # Request body + body_lines = self._build_request_body(endpoint_info) + if body_lines: + lines.append("") + lines.extend(body_lines) + + # Request construction and execution + lines.append("") + lines.append(" try:") + lines.append(" request = HTTPRequest(") + lines.append(f' method="{endpoint_info["method"]}",') + lines.append(" url=url,") + lines.append(' headers={"Content-Type": "application/json"},') + if has_query: + lines.append(" query=query_params,") + if body_lines: + lines.append(" body=body,") + lines.append(" )") + lines.extend([ + " response = await self.http.execute(request) # type: ignore[reportUnknownMemberType]", + " response_data = response.json() if response.text() else None", + " return CodaResponse(", + " success=response.status < HTTP_ERROR_THRESHOLD,", + " data=response_data,", + f' message="Successfully executed {method_name}" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {{response.status}}"', + " )", + " except Exception as e:", + f' return CodaResponse(success=False, error=str(e), message="Failed to execute {method_name}")', + ]) + + self.generated_methods.append({ + "name": method_name, + "endpoint": endpoint_info["path"], + "method": endpoint_info["method"], + "description": endpoint_info["description"], + }) + + return "\n".join(lines) + + def generate_coda_datasource(self) -> str: + """Generate the complete Coda datasource class.""" + + class_lines = [ + '"""', + "Coda REST API DataSource - Auto-generated API wrapper", + "", + "Generated from Coda REST API v1 documentation.", + "Uses HTTP client for direct REST API interactions.", + "All methods have explicit parameter signatures.", + '"""', + "", + "from __future__ import annotations", + "", + "from typing import Any", + "", + "from app.sources.client.coda.coda import CodaClient, CodaResponse", + "from app.sources.client.http.http_request import HTTPRequest", + "", + "# HTTP status code constant", + "HTTP_ERROR_THRESHOLD = 400", + "", + "", + "class CodaDataSource:", + ' """Coda REST API DataSource', + "", + " Provides async wrapper methods for Coda REST API operations:", + " - User / Account information", + " - Doc CRUD and management", + " - Table and Row operations", + " - Column management", + " - Page operations", + " - Formula and Control access", + " - Permission management", + " - Category listing", + "", + " The base URL is determined by the CodaClient's configured base URL", + " (default: https://coda.io/apis/v1).", + "", + " All methods return CodaResponse objects.", + ' """', + "", + " def __init__(self, client: CodaClient) -> None:", + ' """Initialize with CodaClient.', + "", + " Args:", + " client: CodaClient instance with configured authentication", + ' """', + " self._client = client", + " self.http = client.get_client()", + " try:", + " self.base_url = self.http.get_base_url().rstrip('/')", + " except AttributeError as exc:", + " raise ValueError('HTTP client does not have get_base_url method') from exc", + "", + " def get_data_source(self) -> 'CodaDataSource':", + ' """Return the data source instance."""', + " return self", + "", + " def get_client(self) -> CodaClient:", + ' """Return the underlying CodaClient."""', + " return self._client", + "", + ] + + # Generate all API methods + for method_name, endpoint_info in CODA_API_ENDPOINTS.items(): + class_lines.append(self._generate_method(method_name, endpoint_info)) + class_lines.append("") + + return "\n".join(class_lines) + + def save_to_file(self, filename: Optional[str] = None) -> None: + """Generate and save the Coda datasource to a file.""" + if filename is None: + filename = "coda.py" + + script_dir = Path(__file__).parent if __file__ else Path(".") + coda_dir = script_dir.parent / "app" / "sources" / "external" / "coda" + coda_dir.mkdir(parents=True, exist_ok=True) + + full_path = coda_dir / filename + + class_code = self.generate_coda_datasource() + + full_path.write_text(class_code, encoding="utf-8") + + print(f"Generated Coda data source with {len(self.generated_methods)} methods") + print(f"Saved to: {full_path}") + + # Print summary by category + resource_categories = { + "User/Account": 0, + "Doc": 0, + "Table": 0, + "Row": 0, + "Column": 0, + "Page": 0, + "Formula": 0, + "Control": 0, + "Permission": 0, + "Category": 0, + } + + for method in self.generated_methods: + name = method["name"] + if "whoami" in name: + resource_categories["User/Account"] += 1 + elif "doc" in name: + resource_categories["Doc"] += 1 + elif "table" in name: + resource_categories["Table"] += 1 + elif "row" in name: + resource_categories["Row"] += 1 + elif "column" in name: + resource_categories["Column"] += 1 + elif "page" in name: + resource_categories["Page"] += 1 + elif "formula" in name: + resource_categories["Formula"] += 1 + elif "control" in name: + resource_categories["Control"] += 1 + elif "permission" in name: + resource_categories["Permission"] += 1 + elif "categor" in name: + resource_categories["Category"] += 1 + + print(f"\nMethods by Resource:") + for category, count in resource_categories.items(): + if count > 0: + print(f" - {category}: {count}") + + +def main(): + """Main function for Coda data source generator.""" + import argparse + + parser = argparse.ArgumentParser( + description="Generate Coda REST API data source" + ) + parser.add_argument("--filename", "-f", help="Output filename (optional)") + + args = parser.parse_args() + + try: + generator = CodaDataSourceGenerator() + generator.save_to_file(args.filename) + return 0 + except Exception as e: + print(f"Failed to generate Coda data source: {e}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/python/code-generator/datadog.py b/backend/python/code-generator/datadog.py new file mode 100644 index 000000000..40719b310 --- /dev/null +++ b/backend/python/code-generator/datadog.py @@ -0,0 +1,456 @@ +#!/usr/bin/env python3 +# ruff: noqa +from __future__ import annotations + +""" +Datadog (datadog-api-client) -- Code Generator (strict, typed) + +Emits a `DatadogDataSource` with explicit, typed methods mapped to the +official datadog-api-client Python SDK. + +SDK alignment: +- Dashboards: v1.DashboardsApi (list_dashboards, get_dashboard, create_dashboard) +- Monitors: v1.MonitorsApi (list_monitors, get_monitor, create_monitor, update_monitor, delete_monitor) +- Users: v2.UsersApi (list_users, get_user) +- Hosts: v1.HostsApi (list_hosts) +- Metrics: v1.MetricsApi (query_timeseries) +- Metrics: v2.MetricsApi (list_active_metrics via list_tag_configurations) +- Incidents: v2.IncidentsApi (list_incidents, get_incident) +- Logs: v2.LogsApi (search_logs) +- Synthetics: v1.SyntheticsApi (list_synthetics_tests, get_synthetics_test) +- Downtimes: v1.DowntimesApi (list_downtimes) +- Services: v2.ServiceDefinitionApi (list_service_definitions) + +References: +- https://github.com/DataDog/datadog-api-client-python +- https://datadoghq.dev/datadog-api-client-python/ +""" + +import argparse +import textwrap +from pathlib import Path +from typing import List, Tuple + +# --------------------------------------------------------------------------- +# Configuration knobs (CLI-set) +# --------------------------------------------------------------------------- + +DEFAULT_RESPONSE_IMPORT = "from app.sources.client.datadog.datadog import DatadogResponse" +DEFAULT_CLASS_NAME = "DatadogDataSource" +DEFAULT_OUT = "app/sources/external/datadog/datadog.py" + + +HEADER = '''\ +# ruff: noqa +from __future__ import annotations + +from typing import Any, Dict, Optional, Union + +from datadog_api_client import ApiClient, Configuration +from datadog_api_client.v1.api.dashboards_api import DashboardsApi +from datadog_api_client.v1.api.monitors_api import MonitorsApi +from datadog_api_client.v1.api.hosts_api import HostsApi +from datadog_api_client.v1.api.metrics_api import MetricsApi as MetricsApiV1 +from datadog_api_client.v1.api.synthetics_api import SyntheticsApi +from datadog_api_client.v1.api.downtimes_api import DowntimesApi +from datadog_api_client.v1.model.dashboard import Dashboard +from datadog_api_client.v1.model.monitor import Monitor +from datadog_api_client.v1.model.monitor_update_request import MonitorUpdateRequest +from datadog_api_client.v2.api.users_api import UsersApi +from datadog_api_client.v2.api.incidents_api import IncidentsApi +from datadog_api_client.v2.api.logs_api import LogsApi +from datadog_api_client.v2.api.metrics_api import MetricsApi as MetricsApiV2 +from datadog_api_client.v2.api.service_definition_api import ServiceDefinitionApi +from datadog_api_client.v2.model.logs_list_request import LogsListRequest +from datadog_api_client.v2.model.logs_query_filter import LogsQueryFilter +from datadog_api_client.v2.model.logs_list_request_page import LogsListRequestPage +from datadog_api_client.v2.model.logs_sort import LogsSort + +{response_import} + + +class {class_name}: + """ + Typed wrapper over the official datadog-api-client SDK for common + Datadog business operations. + + Accepts either a ``Configuration`` instance *or* any object with + ``.get_sdk() -> Configuration``. + """ + + def __init__(self, client_or_config: Union[Configuration, object]) -> None: + if hasattr(client_or_config, "get_sdk"): + self._config: Configuration = getattr(client_or_config, "get_sdk")() + else: + self._config = client_or_config # type: ignore[assignment] + + # ---- helpers ---- + + @staticmethod + def _to_dict_safe(obj: Any) -> Any: + """Convert SDK response objects to dicts when possible.""" + if hasattr(obj, "to_dict"): + return obj.to_dict() # type: ignore[reportUnknownMemberType] + if isinstance(obj, list): + out: list[Any] = [] + for item in obj: # type: ignore[reportUnknownVariableType] + out.append(item.to_dict() if hasattr(item, "to_dict") else item) # type: ignore[reportUnknownMemberType] + return out + return obj + + @staticmethod + def _params(**kwargs: Any) -> Dict[str, Any]: + """Filter out None values to avoid overriding SDK defaults.""" + return {k: v for k, v in kwargs.items() if v is not None} +''' + +FOOTER = """ +""" + +# Each tuple: (signature, body, short_doc) +METHODS: List[Tuple[str, str, str]] = [] + +# ---------- Dashboards (v1) ---------- +METHODS += [ + ( + "list_dashboards(self) -> DatadogResponse", + " with ApiClient(self._config) as api_client:\n" + " api = DashboardsApi(api_client)\n" + " result = api.list_dashboards()\n" + " return DatadogResponse(success=True, data=self._to_dict_safe(result))", + "List all dashboards. [dashboards]", + ), + ( + "get_dashboard(self, dashboard_id: str) -> DatadogResponse", + " with ApiClient(self._config) as api_client:\n" + " api = DashboardsApi(api_client)\n" + " result = api.get_dashboard(dashboard_id=dashboard_id)\n" + " return DatadogResponse(success=True, data=self._to_dict_safe(result))", + "Get a single dashboard by ID. [dashboards]", + ), + ( + "create_dashboard(self, body: Dict[str, Any]) -> DatadogResponse", + " with ApiClient(self._config) as api_client:\n" + " api = DashboardsApi(api_client)\n" + " dashboard = Dashboard(**body)\n" + " result = api.create_dashboard(body=dashboard)\n" + " return DatadogResponse(success=True, data=self._to_dict_safe(result))", + "Create a dashboard. Pass the dashboard definition as a dict. [dashboards]", + ), +] + +# ---------- Monitors (v1) ---------- +METHODS += [ + ( + "list_monitors(self, group_states: Optional[str] = None, name: Optional[str] = None, tags: Optional[str] = None, monitor_tags: Optional[str] = None, page: Optional[int] = None, page_size: Optional[int] = None) -> DatadogResponse", + " with ApiClient(self._config) as api_client:\n" + " api = MonitorsApi(api_client)\n" + " kwargs = self._params(group_states=group_states, name=name, tags=tags, monitor_tags=monitor_tags, page=page, page_size=page_size)\n" + " result = api.list_monitors(**kwargs)\n" + " return DatadogResponse(success=True, data=self._to_dict_safe(result))", + "List monitors with optional filters. [monitors]", + ), + ( + "get_monitor(self, monitor_id: int) -> DatadogResponse", + " with ApiClient(self._config) as api_client:\n" + " api = MonitorsApi(api_client)\n" + " result = api.get_monitor(monitor_id=monitor_id)\n" + " return DatadogResponse(success=True, data=self._to_dict_safe(result))", + "Get a single monitor by ID. [monitors]", + ), + ( + "create_monitor(self, body: Dict[str, Any]) -> DatadogResponse", + " with ApiClient(self._config) as api_client:\n" + " api = MonitorsApi(api_client)\n" + " monitor = Monitor(**body)\n" + " result = api.create_monitor(body=monitor)\n" + " return DatadogResponse(success=True, data=self._to_dict_safe(result))", + "Create a monitor. Pass the monitor definition as a dict. [monitors]", + ), + ( + "update_monitor(self, monitor_id: int, body: Dict[str, Any]) -> DatadogResponse", + " with ApiClient(self._config) as api_client:\n" + " api = MonitorsApi(api_client)\n" + " update_req = MonitorUpdateRequest(**body)\n" + " result = api.update_monitor(monitor_id=monitor_id, body=update_req)\n" + " return DatadogResponse(success=True, data=self._to_dict_safe(result))", + "Update an existing monitor. [monitors]", + ), + ( + "delete_monitor(self, monitor_id: int) -> DatadogResponse", + " with ApiClient(self._config) as api_client:\n" + " api = MonitorsApi(api_client)\n" + " result = api.delete_monitor(monitor_id=monitor_id)\n" + " return DatadogResponse(success=True, data=self._to_dict_safe(result))", + "Delete a monitor by ID. [monitors]", + ), +] + +# ---------- Users (v2) ---------- +METHODS += [ + ( + "list_users(self, page_size: Optional[int] = None, page_number: Optional[int] = None, sort: Optional[str] = None, sort_dir: Optional[str] = None, filter_str: Optional[str] = None, filter_status: Optional[str] = None) -> DatadogResponse", + " with ApiClient(self._config) as api_client:\n" + " api = UsersApi(api_client)\n" + " kwargs: Dict[str, Any] = {}\n" + " if page_size is not None:\n" + " kwargs['page_size'] = page_size\n" + " if page_number is not None:\n" + " kwargs['page_number'] = page_number\n" + " if sort is not None:\n" + " kwargs['sort'] = sort\n" + " if sort_dir is not None:\n" + " kwargs['sort_dir'] = sort_dir\n" + " if filter_str is not None:\n" + " kwargs['filter'] = filter_str\n" + " if filter_status is not None:\n" + " kwargs['filter_status'] = filter_status\n" + " result = api.list_users(**kwargs)\n" + " return DatadogResponse(success=True, data=self._to_dict_safe(result))", + "List all users in the organization. [users]", + ), + ( + "get_user(self, user_id: str) -> DatadogResponse", + " with ApiClient(self._config) as api_client:\n" + " api = UsersApi(api_client)\n" + " result = api.get_user(user_id=user_id)\n" + " return DatadogResponse(success=True, data=self._to_dict_safe(result))", + "Get a single user by ID. [users]", + ), +] + +# ---------- Hosts (v1) ---------- +METHODS += [ + ( + "list_hosts(self, filter_str: Optional[str] = None, sort_field: Optional[str] = None, sort_dir: Optional[str] = None, start: Optional[int] = None, count: Optional[int] = None, from_ts: Optional[int] = None) -> DatadogResponse", + " with ApiClient(self._config) as api_client:\n" + " api = HostsApi(api_client)\n" + " kwargs: Dict[str, Any] = {}\n" + " if filter_str is not None:\n" + " kwargs['filter'] = filter_str\n" + " if sort_field is not None:\n" + " kwargs['sort_field'] = sort_field\n" + " if sort_dir is not None:\n" + " kwargs['sort_dir'] = sort_dir\n" + " if start is not None:\n" + " kwargs['start'] = start\n" + " if count is not None:\n" + " kwargs['count'] = count\n" + " if from_ts is not None:\n" + " kwargs['_from'] = from_ts\n" + " result = api.list_hosts(**kwargs)\n" + " return DatadogResponse(success=True, data=self._to_dict_safe(result))", + "List all hosts for the organization. [hosts]", + ), +] + +# ---------- Metrics / Timeseries (v1) ---------- +METHODS += [ + ( + "query_timeseries(self, from_ts: int, to_ts: int, query: str) -> DatadogResponse", + " with ApiClient(self._config) as api_client:\n" + " api = MetricsApiV1(api_client)\n" + " result = api.query_metrics(_from=from_ts, to=to_ts, query=query)\n" + " return DatadogResponse(success=True, data=self._to_dict_safe(result))", + "Query timeseries data using a metrics query string. [metrics]", + ), +] + +# ---------- Metrics (v2) - list_active_metrics via list_tag_configurations ---------- +METHODS += [ + ( + "list_active_metrics(self, filter_configured: Optional[bool] = None, filter_tags_configured: Optional[str] = None, filter_metric_type: Optional[str] = None, filter_include_percentiles: Optional[bool] = None, filter_queried: Optional[bool] = None, filter_tags: Optional[str] = None, window_seconds: Optional[int] = None, page_size: Optional[int] = None, page_cursor: Optional[str] = None) -> DatadogResponse", + " with ApiClient(self._config) as api_client:\n" + " api = MetricsApiV2(api_client)\n" + " kwargs = self._params(\n" + " filter_configured=filter_configured,\n" + " filter_tags_configured=filter_tags_configured,\n" + " filter_metric_type=filter_metric_type,\n" + " filter_include_percentiles=filter_include_percentiles,\n" + " filter_queried=filter_queried,\n" + " filter_tags=filter_tags,\n" + " window_seconds=window_seconds,\n" + " page_size=page_size,\n" + " page_cursor=page_cursor,\n" + " )\n" + " result = api.list_tag_configurations(**kwargs)\n" + " return DatadogResponse(success=True, data=self._to_dict_safe(result))", + "List active metric tag configurations with optional filters. [metrics]", + ), +] + +# ---------- Incidents (v2) ---------- +METHODS += [ + ( + "list_incidents(self, page_size: Optional[int] = None, page_offset: Optional[int] = None) -> DatadogResponse", + " with ApiClient(self._config) as api_client:\n" + " api = IncidentsApi(api_client)\n" + " kwargs = self._params(page_size=page_size, page_offset=page_offset)\n" + " result = api.list_incidents(**kwargs)\n" + " return DatadogResponse(success=True, data=self._to_dict_safe(result))", + "List all incidents. [incidents]", + ), + ( + "get_incident(self, incident_id: str) -> DatadogResponse", + " with ApiClient(self._config) as api_client:\n" + " api = IncidentsApi(api_client)\n" + " result = api.get_incident(incident_id=incident_id)\n" + " return DatadogResponse(success=True, data=self._to_dict_safe(result))", + "Get a single incident by ID. [incidents]", + ), +] + +# ---------- Logs (v2) ---------- +METHODS += [ + ( + "search_logs(self, filter_query: Optional[str] = None, filter_from: Optional[str] = None, filter_to: Optional[str] = None, sort: Optional[str] = None, page_cursor: Optional[str] = None, page_limit: Optional[int] = None) -> DatadogResponse", + " with ApiClient(self._config) as api_client:\n" + " api = LogsApi(api_client)\n" + " filter_obj = LogsQueryFilter()\n" + " if filter_query is not None:\n" + " filter_obj.query = filter_query\n" + " if filter_from is not None:\n" + " filter_obj._from = filter_from\n" + " if filter_to is not None:\n" + " filter_obj.to = filter_to\n" + " body_kwargs: Dict[str, Any] = {'filter': filter_obj}\n" + " if sort is not None:\n" + " body_kwargs['sort'] = LogsSort(sort)\n" + " if page_cursor is not None or page_limit is not None:\n" + " page_obj = LogsListRequestPage()\n" + " if page_cursor is not None:\n" + " page_obj.cursor = page_cursor\n" + " if page_limit is not None:\n" + " page_obj.limit = page_limit\n" + " body_kwargs['page'] = page_obj\n" + " request_body = LogsListRequest(**body_kwargs)\n" + " result = api.list_logs(body=request_body)\n" + " return DatadogResponse(success=True, data=self._to_dict_safe(result))", + "Search and filter logs. [logs]", + ), +] + +# ---------- Synthetics (v1) ---------- +METHODS += [ + ( + "list_synthetics_tests(self, page_size: Optional[int] = None, page_number: Optional[int] = None) -> DatadogResponse", + " with ApiClient(self._config) as api_client:\n" + " api = SyntheticsApi(api_client)\n" + " kwargs = self._params(page_size=page_size, page_number=page_number)\n" + " result = api.list_tests(**kwargs)\n" + " return DatadogResponse(success=True, data=self._to_dict_safe(result))", + "List all Synthetics tests. [synthetics]", + ), + ( + "get_synthetics_test(self, public_id: str) -> DatadogResponse", + " with ApiClient(self._config) as api_client:\n" + " api = SyntheticsApi(api_client)\n" + " result = api.get_test(public_id=public_id)\n" + " return DatadogResponse(success=True, data=self._to_dict_safe(result))", + "Get a single Synthetics test by public ID. [synthetics]", + ), +] + +# ---------- Downtimes (v1) ---------- +METHODS += [ + ( + "list_downtimes(self, current_only: Optional[bool] = None, with_creator: Optional[bool] = None) -> DatadogResponse", + " with ApiClient(self._config) as api_client:\n" + " api = DowntimesApi(api_client)\n" + " kwargs = self._params(current_only=current_only, with_creator=with_creator)\n" + " result = api.list_downtimes(**kwargs)\n" + " return DatadogResponse(success=True, data=self._to_dict_safe(result))", + "List all scheduled downtimes. [downtimes]", + ), +] + +# ---------- Service Definitions (v2) ---------- +METHODS += [ + ( + "list_service_definitions(self, page_size: Optional[int] = None, page_number: Optional[int] = None, schema_version: Optional[str] = None) -> DatadogResponse", + " with ApiClient(self._config) as api_client:\n" + " api = ServiceDefinitionApi(api_client)\n" + " kwargs = self._params(page_size=page_size, page_number=page_number, schema_version=schema_version)\n" + " result = api.list_service_definitions(**kwargs)\n" + " return DatadogResponse(success=True, data=self._to_dict_safe(result))", + "List all service definitions. [service definitions]", + ), +] + + +# ------------------------- +# Code emission utilities +# ------------------------- + + +def _emit_method(sig: str, body: str, doc: str) -> str: + # Dedent body then re-indent to sit inside try: (12 spaces = method + try) + normalized_body = textwrap.indent(textwrap.dedent(body), " ") + return ( + f' def {sig}:\n' + f' """{doc}"""\n' + f' try:\n' + f'{normalized_body}\n' + f' except Exception as e:\n' + f' return DatadogResponse(success=False, error=str(e))\n' + ) + + +def build_class( + response_import: str = DEFAULT_RESPONSE_IMPORT, + class_name: str = DEFAULT_CLASS_NAME, +) -> str: + parts: List[str] = [] + header = HEADER.replace("{response_import}", response_import).replace( + "{class_name}", class_name + ) + parts.append(header) + for sig, body, doc in METHODS: + parts.append(_emit_method(sig, body, doc)) + parts.append(FOOTER) + return "".join(parts) + + +def write_output(path: str, code: str) -> None: + out = Path(path) + out.parent.mkdir(parents=True, exist_ok=True) + with open(out, "w", encoding="utf-8") as f: + f.write(code) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Generate DatadogDataSource (datadog-api-client SDK)." + ) + parser.add_argument( + "--out", + default=DEFAULT_OUT, + help="Output path for the generated data source.", + ) + parser.add_argument( + "--response-import", + default=DEFAULT_RESPONSE_IMPORT, + help="Import line to bring in DatadogResponse.", + ) + parser.add_argument( + "--class-name", + default=DEFAULT_CLASS_NAME, + help="Name of the generated datasource class.", + ) + parser.add_argument( + "--print", + dest="do_print", + action="store_true", + help="Also print generated code to stdout.", + ) + args = parser.parse_args() + + code = build_class(response_import=args.response_import, class_name=args.class_name) + write_output(args.out, code) + print(f"Generated {args.class_name} with {len(METHODS)} methods -> {args.out}") + if args.do_print: + print(code) + + +if __name__ == "__main__": + main() diff --git a/backend/python/code-generator/docusign.py b/backend/python/code-generator/docusign.py new file mode 100644 index 000000000..666782196 --- /dev/null +++ b/backend/python/code-generator/docusign.py @@ -0,0 +1,896 @@ +#!/usr/bin/env python3 +# ruff: noqa +""" +DocuSign Unified Code Generator + +Generates a single ``DocuSignDataSource`` class with ALL methods across: +- eSignature (SDK-based via docusign-esign) +- Admin, Rooms, Click, Monitor, WebForms (HTTP-based via HTTPClient) + +SDK methods are emitted as direct docusign_esign SDK calls (same pattern as +the previous generator). HTTP methods are emitted as HTTPRequest-based calls +following the ClickUp datasource pattern. + +Run: + cd backend/python + python code-generator/docusign.py +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +# --------------------------------------------------------------------------- +# Output configuration +# --------------------------------------------------------------------------- + +DEFAULT_OUT = "app/sources/external/docusign/docusign.py" + +# --------------------------------------------------------------------------- +# SDK-based methods (eSignature) +# --------------------------------------------------------------------------- +# Each tuple: (signature, body, short_doc) + +SDK_METHODS: List[Tuple[str, str, str]] = [] + +# ---------- Envelopes ---------- +SDK_METHODS += [ + ( + "list_envelopes(self, from_date: str, to_date: str | None = None, status: str | None = None, search_text: str | None = None, count: str | None = None, start_position: str | None = None, order: str | None = None, order_by: str | None = None, folder_ids: str | None = None) -> DocuSignResponse", + " params = self._params(from_date=from_date, to_date=to_date, status=status, search_text=search_text, count=count, start_position=start_position, order=order, order_by=order_by, folder_ids=folder_ids)\n" + " result = self.envelopes_api.list_status_changes(account_id=self._account_id, **params)\n" + " return DocuSignResponse(success=True, data=result)", + "List envelopes for the account. from_date is required by the API. [eSign]", + ), + ( + "get_envelope(self, envelope_id: str) -> DocuSignResponse", + " result = self.envelopes_api.get_envelope(account_id=self._account_id, envelope_id=envelope_id)\n" + " return DocuSignResponse(success=True, data=result)", + "Get details for a specific envelope. [eSign]", + ), + ( + "create_envelope(self, envelope_definition: dict[str, object]) -> DocuSignResponse", + " body = docusign_esign.EnvelopeDefinition(**envelope_definition)\n" + " result = self.envelopes_api.create_envelope(account_id=self._account_id, envelope_definition=body)\n" + " return DocuSignResponse(success=True, data=result)", + "Create and optionally send a new envelope from an envelope definition dict. [eSign]", + ), + ( + "update_envelope(self, envelope_id: str, envelope: dict[str, object]) -> DocuSignResponse", + " body = docusign_esign.Envelope(**envelope)\n" + " result = self.envelopes_api.update(account_id=self._account_id, envelope_id=envelope_id, envelope=body)\n" + " return DocuSignResponse(success=True, data=result)", + "Update an existing envelope (e.g. change status to sent or voided). [eSign]", + ), + ( + "list_envelope_documents(self, envelope_id: str) -> DocuSignResponse", + " result = self.envelopes_api.list_documents(account_id=self._account_id, envelope_id=envelope_id)\n" + " return DocuSignResponse(success=True, data=result)", + "List documents in an envelope. [eSign]", + ), + ( + "get_envelope_document(self, envelope_id: str, document_id: str) -> DocuSignResponse", + " result = self.envelopes_api.get_document(account_id=self._account_id, envelope_id=envelope_id, document_id=document_id)\n" + " return DocuSignResponse(success=True, data=result)", + "Download a specific document from an envelope. [eSign]", + ), + ( + "list_envelope_recipients(self, envelope_id: str) -> DocuSignResponse", + " result = self.envelopes_api.list_recipients(account_id=self._account_id, envelope_id=envelope_id)\n" + " return DocuSignResponse(success=True, data=result)", + "List recipients for an envelope. [eSign]", + ), + ( + "get_envelope_audit_events(self, envelope_id: str) -> DocuSignResponse", + " result = self.envelopes_api.list_audit_events(account_id=self._account_id, envelope_id=envelope_id)\n" + " return DocuSignResponse(success=True, data=result)", + "Get audit trail events for an envelope. [eSign]", + ), +] + +# ---------- Templates ---------- +SDK_METHODS += [ + ( + "list_templates(self, count: str | None = None, start_position: str | None = None, search_text: str | None = None, folder: str | None = None, order: str | None = None, order_by: str | None = None) -> DocuSignResponse", + " params = self._params(count=count, start_position=start_position, search_text=search_text, folder=folder, order=order, order_by=order_by)\n" + " result = self.templates_api.list_templates(account_id=self._account_id, **params)\n" + " return DocuSignResponse(success=True, data=result)", + "List templates for the account. [eSign]", + ), + ( + "get_template(self, template_id: str) -> DocuSignResponse", + " result = self.templates_api.get(account_id=self._account_id, template_id=template_id)\n" + " return DocuSignResponse(success=True, data=result)", + "Get details for a specific template. [eSign]", + ), +] + +# ---------- Users ---------- +SDK_METHODS += [ + ( + "list_users(self, count: str | None = None, start_position: str | None = None, status: str | None = None, email: str | None = None) -> DocuSignResponse", + " params = self._params(count=count, start_position=start_position, status=status, email=email)\n" + " result = self.users_api.list(account_id=self._account_id, **params)\n" + " return DocuSignResponse(success=True, data=result)", + "List users in the account. [eSign]", + ), + ( + "get_user(self, user_id: str) -> DocuSignResponse", + " result = self.users_api.get_information(account_id=self._account_id, user_id=user_id)\n" + " return DocuSignResponse(success=True, data=result)", + "Get details for a specific user. [eSign]", + ), +] + +# ---------- Folders ---------- +SDK_METHODS += [ + ( + "list_folders(self) -> DocuSignResponse", + " result = self.folders_api.list(account_id=self._account_id)\n" + " return DocuSignResponse(success=True, data=result)", + "List folders in the account. [eSign]", + ), + ( + "list_folder_items(self, folder_id: str, from_date: str | None = None, to_date: str | None = None, status: str | None = None, search_text: str | None = None, count: str | None = None, start_position: str | None = None) -> DocuSignResponse", + " params = self._params(from_date=from_date, to_date=to_date, status=status, search_text=search_text, count=count, start_position=start_position)\n" + " result = self.folders_api.list_items(account_id=self._account_id, folder_id=folder_id, **params)\n" + " return DocuSignResponse(success=True, data=result)", + "List items (envelopes) in a specific folder. [eSign]", + ), +] + +# ---------- Account (Brands / Custom Fields) ---------- +SDK_METHODS += [ + ( + "list_brands(self) -> DocuSignResponse", + " result = self.accounts_api.list_brands(account_id=self._account_id)\n" + " return DocuSignResponse(success=True, data=result)", + "List brands for the account. [eSign]", + ), + ( + "list_custom_fields(self) -> DocuSignResponse", + " result = self.accounts_api.list_custom_fields(account_id=self._account_id)\n" + " return DocuSignResponse(success=True, data=result)", + "List custom fields for the account. [eSign]", + ), +] + +# --------------------------------------------------------------------------- +# HTTP-based endpoints (Admin, Rooms, Click, Monitor, WebForms) +# --------------------------------------------------------------------------- +# Each endpoint dict follows the ClickUp pattern: +# method, path, description, parameters, required, api +# +# The ``api`` key maps to the lazy HTTP client accessor: +# "admin" -> _get_admin_http() base = ADMIN_BASE_URL +# "rooms" -> _get_rooms_http() base = ROOMS_BASE_URL +# "click" -> _get_click_http() base = CLICK_BASE_URL +# "monitor" -> _get_monitor_http() base = MONITOR_BASE_URL +# "webforms" -> _get_webforms_http() base = WEBFORMS_BASE_URL + +DOCUSIGN_HTTP_ENDPOINTS: Dict[str, dict] = { + # ======================================================================== + # ADMIN + # ======================================================================== + "admin_get_organizations": { + "method": "GET", + "path": "/v2/organizations", + "description": "Get all organizations", + "parameters": {}, + "required": [], + "api": "admin", + }, + "admin_get_users": { + "method": "GET", + "path": "/v2.1/organizations/{org_id}/users", + "description": "Get users for an organization", + "parameters": { + "org_id": {"type": "str", "location": "path", "description": "Organization ID"}, + "account_id": {"type": "str | None", "location": "query", "description": "Filter by account ID"}, + "email": {"type": "str | None", "location": "query", "description": "Filter by email address"}, + "start": {"type": "int | None", "location": "query", "description": "Start index for pagination"}, + "take": {"type": "int | None", "location": "query", "description": "Number of results to return"}, + }, + "required": ["org_id"], + "api": "admin", + }, + "admin_get_user_profile": { + "method": "GET", + "path": "/v2.1/organizations/{org_id}/users/profile", + "description": "Get user profile by email", + "parameters": { + "org_id": {"type": "str", "location": "path", "description": "Organization ID"}, + "email": {"type": "str | None", "location": "query", "description": "Email address to look up"}, + }, + "required": ["org_id"], + "api": "admin", + }, + "admin_get_ds_groups": { + "method": "GET", + "path": "/v2.1/organizations/{org_id}/accounts/{account_id}/dsGroups", + "description": "Get DocuSign groups for an account", + "parameters": { + "org_id": {"type": "str", "location": "path", "description": "Organization ID"}, + "account_id": {"type": "str", "location": "path", "description": "Account ID"}, + }, + "required": ["org_id", "account_id"], + "api": "admin", + }, + "admin_get_permission_profiles": { + "method": "GET", + "path": "/v2.1/organizations/{org_id}/accounts/{account_id}/products/permission_profiles", + "description": "Get permission profiles for an account", + "parameters": { + "org_id": {"type": "str", "location": "path", "description": "Organization ID"}, + "account_id": {"type": "str", "location": "path", "description": "Account ID"}, + }, + "required": ["org_id", "account_id"], + "api": "admin", + }, + + # ======================================================================== + # ROOMS + # ======================================================================== + "rooms_get_rooms": { + "method": "GET", + "path": "/v2/accounts/{account_id}/rooms", + "description": "Get rooms for the account", + "parameters": { + "account_id": {"type": "str", "location": "path", "description": "Account ID"}, + "count": {"type": "int | None", "location": "query", "description": "Number of results to return"}, + "startPosition": {"type": "int | None", "location": "query", "description": "Start position for pagination"}, + "roomStatus": {"type": "str | None", "location": "query", "description": "Filter by room status"}, + }, + "required": ["account_id"], + "api": "rooms", + }, + "rooms_get_room": { + "method": "GET", + "path": "/v2/accounts/{account_id}/rooms/{room_id}", + "description": "Get a specific room", + "parameters": { + "account_id": {"type": "str", "location": "path", "description": "Account ID"}, + "room_id": {"type": "str", "location": "path", "description": "Room ID"}, + }, + "required": ["account_id", "room_id"], + "api": "rooms", + }, + "rooms_create_room": { + "method": "POST", + "path": "/v2/accounts/{account_id}/rooms", + "description": "Create a new room", + "parameters": { + "account_id": {"type": "str", "location": "path", "description": "Account ID"}, + "name": {"type": "str", "location": "body", "description": "Room name"}, + "roleId": {"type": "int", "location": "body", "description": "Role ID for the room creator"}, + "transactionSideId": {"type": "str | None", "location": "body", "description": "Transaction side ID"}, + }, + "required": ["account_id", "name", "roleId"], + "api": "rooms", + }, + "rooms_delete_room": { + "method": "DELETE", + "path": "/v2/accounts/{account_id}/rooms/{room_id}", + "description": "Delete a room", + "parameters": { + "account_id": {"type": "str", "location": "path", "description": "Account ID"}, + "room_id": {"type": "str", "location": "path", "description": "Room ID"}, + }, + "required": ["account_id", "room_id"], + "api": "rooms", + }, + "rooms_get_room_documents": { + "method": "GET", + "path": "/v2/accounts/{account_id}/rooms/{room_id}/documents", + "description": "Get documents in a room", + "parameters": { + "account_id": {"type": "str", "location": "path", "description": "Account ID"}, + "room_id": {"type": "str", "location": "path", "description": "Room ID"}, + }, + "required": ["account_id", "room_id"], + "api": "rooms", + }, + "rooms_get_room_templates": { + "method": "GET", + "path": "/v2/accounts/{account_id}/room_templates", + "description": "Get room templates", + "parameters": { + "account_id": {"type": "str", "location": "path", "description": "Account ID"}, + "count": {"type": "int | None", "location": "query", "description": "Number of results to return"}, + "startPosition": {"type": "int | None", "location": "query", "description": "Start position for pagination"}, + }, + "required": ["account_id"], + "api": "rooms", + }, + "rooms_get_roles": { + "method": "GET", + "path": "/v2/accounts/{account_id}/roles", + "description": "Get roles for the account", + "parameters": { + "account_id": {"type": "str", "location": "path", "description": "Account ID"}, + }, + "required": ["account_id"], + "api": "rooms", + }, + "rooms_get_offices": { + "method": "GET", + "path": "/v2/accounts/{account_id}/offices", + "description": "Get offices for the account", + "parameters": { + "account_id": {"type": "str", "location": "path", "description": "Account ID"}, + }, + "required": ["account_id"], + "api": "rooms", + }, + "rooms_get_regions": { + "method": "GET", + "path": "/v2/accounts/{account_id}/regions", + "description": "Get regions for the account", + "parameters": { + "account_id": {"type": "str", "location": "path", "description": "Account ID"}, + }, + "required": ["account_id"], + "api": "rooms", + }, + "rooms_get_form_libraries": { + "method": "GET", + "path": "/v2/accounts/{account_id}/form_libraries", + "description": "Get form libraries for the account", + "parameters": { + "account_id": {"type": "str", "location": "path", "description": "Account ID"}, + }, + "required": ["account_id"], + "api": "rooms", + }, + + # ======================================================================== + # CLICK + # ======================================================================== + "click_get_clickwraps": { + "method": "GET", + "path": "/v1/accounts/{account_id}/clickwraps", + "description": "Get all clickwraps for the account", + "parameters": { + "account_id": {"type": "str", "location": "path", "description": "Account ID"}, + }, + "required": ["account_id"], + "api": "click", + }, + "click_get_clickwrap": { + "method": "GET", + "path": "/v1/accounts/{account_id}/clickwraps/{clickwrap_id}", + "description": "Get a specific clickwrap", + "parameters": { + "account_id": {"type": "str", "location": "path", "description": "Account ID"}, + "clickwrap_id": {"type": "str", "location": "path", "description": "Clickwrap ID"}, + }, + "required": ["account_id", "clickwrap_id"], + "api": "click", + }, + "click_create_clickwrap": { + "method": "POST", + "path": "/v1/accounts/{account_id}/clickwraps", + "description": "Create a new clickwrap", + "parameters": { + "account_id": {"type": "str", "location": "path", "description": "Account ID"}, + "clickwrapName": {"type": "str", "location": "body", "description": "Clickwrap name"}, + "documents": {"type": "list[dict[str, object]]", "location": "body", "description": "Documents for the clickwrap"}, + "requireReacceptance": {"type": "bool | None", "location": "body", "description": "Whether re-acceptance is required"}, + }, + "required": ["account_id", "clickwrapName", "documents"], + "api": "click", + }, + "click_delete_clickwrap": { + "method": "DELETE", + "path": "/v1/accounts/{account_id}/clickwraps/{clickwrap_id}", + "description": "Delete a clickwrap", + "parameters": { + "account_id": {"type": "str", "location": "path", "description": "Account ID"}, + "clickwrap_id": {"type": "str", "location": "path", "description": "Clickwrap ID"}, + }, + "required": ["account_id", "clickwrap_id"], + "api": "click", + }, + "click_get_clickwrap_agreements": { + "method": "GET", + "path": "/v1/accounts/{account_id}/clickwraps/{clickwrap_id}/users", + "description": "Get clickwrap agreements (user acceptances)", + "parameters": { + "account_id": {"type": "str", "location": "path", "description": "Account ID"}, + "clickwrap_id": {"type": "str", "location": "path", "description": "Clickwrap ID"}, + }, + "required": ["account_id", "clickwrap_id"], + "api": "click", + }, + "click_get_service_info": { + "method": "GET", + "path": "/v1/accounts/{account_id}/service_information", + "description": "Get Click service information for the account", + "parameters": { + "account_id": {"type": "str", "location": "path", "description": "Account ID"}, + }, + "required": ["account_id"], + "api": "click", + }, + + # ======================================================================== + # MONITOR + # ======================================================================== + "monitor_get_stream": { + "method": "GET", + "path": "/api/v2.0/datasets/monitor/stream", + "description": "Get monitor audit stream events", + "parameters": { + "cursor": {"type": "str | None", "location": "query", "description": "Cursor for pagination"}, + "limit": {"type": "int | None", "location": "query", "description": "Number of events to return"}, + }, + "required": [], + "api": "monitor", + }, + + # ======================================================================== + # WEBFORMS + # ======================================================================== + "webforms_list_forms": { + "method": "GET", + "path": "/accounts/{account_id}/forms", + "description": "List web forms for the account", + "parameters": { + "account_id": {"type": "str", "location": "path", "description": "Account ID"}, + "search": {"type": "str | None", "location": "query", "description": "Search filter"}, + "state": {"type": "str | None", "location": "query", "description": "Filter by form state"}, + "status": {"type": "str | None", "location": "query", "description": "Filter by form status"}, + }, + "required": ["account_id"], + "api": "webforms", + }, + "webforms_get_form": { + "method": "GET", + "path": "/accounts/{account_id}/forms/{form_id}", + "description": "Get a specific web form", + "parameters": { + "account_id": {"type": "str", "location": "path", "description": "Account ID"}, + "form_id": {"type": "str", "location": "path", "description": "Form ID"}, + }, + "required": ["account_id", "form_id"], + "api": "webforms", + }, + "webforms_list_instances": { + "method": "GET", + "path": "/accounts/{account_id}/forms/{form_id}/instances", + "description": "List instances of a web form", + "parameters": { + "account_id": {"type": "str", "location": "path", "description": "Account ID"}, + "form_id": {"type": "str", "location": "path", "description": "Form ID"}, + }, + "required": ["account_id", "form_id"], + "api": "webforms", + }, + "webforms_get_instance": { + "method": "GET", + "path": "/accounts/{account_id}/forms/{form_id}/instances/{instance_id}", + "description": "Get a specific web form instance", + "parameters": { + "account_id": {"type": "str", "location": "path", "description": "Account ID"}, + "form_id": {"type": "str", "location": "path", "description": "Form ID"}, + "instance_id": {"type": "str", "location": "path", "description": "Instance ID"}, + }, + "required": ["account_id", "form_id", "instance_id"], + "api": "webforms", + }, + "webforms_create_instance": { + "method": "POST", + "path": "/accounts/{account_id}/forms/{form_id}/instances", + "description": "Create a new web form instance", + "parameters": { + "account_id": {"type": "str", "location": "path", "description": "Account ID"}, + "form_id": {"type": "str", "location": "path", "description": "Form ID"}, + "clientUserId": {"type": "str | None", "location": "body", "description": "Client user ID"}, + "tags": {"type": "list[str] | None", "location": "body", "description": "Tags for the instance"}, + "returnUrl": {"type": "str | None", "location": "body", "description": "Return URL after form completion"}, + }, + "required": ["account_id", "form_id"], + "api": "webforms", + }, +} + +# Map api key -> (base_url_constant_name, lazy_accessor_name) +API_INFO = { + "admin": ("ADMIN_BASE_URL", "_get_admin_http"), + "rooms": ("ROOMS_BASE_URL", "_get_rooms_http"), + "click": ("CLICK_BASE_URL", "_get_click_http"), + "monitor": ("MONITOR_BASE_URL", "_get_monitor_http"), + "webforms": ("WEBFORMS_BASE_URL", "_get_webforms_http"), +} + + +# --------------------------------------------------------------------------- +# Code emission helpers +# --------------------------------------------------------------------------- + + +def _emit_sdk_method(sig: str, body: str, doc: str) -> str: + """Emit a synchronous SDK-based method.""" + lines = [] + lines.append(f" def {sig}:") + lines.append(f' """{doc}"""') + lines.append(" try:") + # Body lines are already indented at 12 spaces + for line in body.split("\n"): + lines.append(line) + lines.append(" except Exception as e:") + lines.append(' return DocuSignResponse(success=False, error=str(e), message="SDK call failed")') + return "\n".join(lines) + + +def _sanitize_param(name: str) -> str: + """Sanitize parameter name for Python (replace reserved words).""" + reserved = {"type", "from", "class", "import", "global", "return", "lambda"} + if name in reserved: + return f"{name}_" + return name + + +def _build_query_params(endpoint: dict) -> List[str]: + """Build query_params dict construction lines.""" + lines = [" query_params: dict[str, object] = {}"] + for param_name, param_info in endpoint["parameters"].items(): + if param_info["location"] != "query": + continue + sanitized = _sanitize_param(param_name) + if param_name in endpoint["required"]: + lines.append(f" query_params['{param_name}'] = {sanitized}") + else: + lines.append(f" if {sanitized} is not None:") + lines.append(f" query_params['{param_name}'] = {sanitized}") + return lines + + +def _build_path_format(path: str, endpoint: dict) -> str: + """Build URL construction line with path parameter formatting.""" + path_params = [ + p for p, info in endpoint["parameters"].items() + if info["location"] == "path" + ] + if path_params: + # Use f-string with direct variable references + return f' url = base_url + f"{path}"' + else: + return f' url = base_url + "{path}"' + + +def _build_body_params(endpoint: dict) -> List[str]: + """Build request body dict construction lines.""" + body_params = [ + (name, info) for name, info in endpoint["parameters"].items() + if info["location"] == "body" + ] + if not body_params: + return [] + + lines = [" body: dict[str, object] = {}"] + for param_name, param_info in body_params: + sanitized = _sanitize_param(param_name) + if param_name in endpoint["required"]: + lines.append(f" body['{param_name}'] = {sanitized}") + else: + lines.append(f" if {sanitized} is not None:") + lines.append(f" body['{param_name}'] = {sanitized}") + return lines + + +def _emit_http_method(method_name: str, endpoint: dict) -> str: + """Emit an async HTTP-based method following the ClickUp pattern.""" + api_key = endpoint["api"] + _, accessor = API_INFO[api_key] + + # Build method signature + params = ["self"] + # Required params (non-body first, then body) + for param_name in endpoint["required"]: + if param_name in endpoint["parameters"]: + pinfo = endpoint["parameters"][param_name] + sanitized = _sanitize_param(param_name) + ptype = pinfo["type"] + params.append(f"{sanitized}: {ptype}") + + # Optional params + for param_name, pinfo in endpoint["parameters"].items(): + if param_name not in endpoint["required"]: + sanitized = _sanitize_param(param_name) + ptype = pinfo["type"] + if "| None" not in ptype: + ptype = f"{ptype} | None" + params.append(f"{sanitized}: {ptype} = None") + + sig_params = ",\n ".join(params) + api_label = endpoint["api"].capitalize() + + lines = [] + lines.append(f" async def {method_name}(") + lines.append(f" {sig_params}") + lines.append(" ) -> DocuSignResponse:") + lines.append(f' """{endpoint["description"]} [{api_label}]') + lines.append("") + + # Args section + if endpoint["parameters"]: + lines.append(" Args:") + for pname, pinfo in endpoint["parameters"].items(): + sanitized = _sanitize_param(pname) + lines.append(f" {sanitized}: {pinfo['description']}") + lines.append("") + lines.append(" Returns:") + lines.append(" DocuSignResponse with operation result") + lines.append(' """') + + # Query params + has_query = any( + info["location"] == "query" + for info in endpoint["parameters"].values() + ) + if has_query: + lines.extend(_build_query_params(endpoint)) + lines.append("") + + # URL construction + base_url_const, _ = API_INFO[api_key] + lines.append(f" base_url = self.{base_url_const}") + lines.append(_build_path_format(endpoint["path"], endpoint)) + + # Body + body_lines = _build_body_params(endpoint) + if body_lines: + lines.append("") + lines.extend(body_lines) + + # Request execution + lines.append("") + lines.append(" try:") + lines.append(" request = HTTPRequest(") + lines.append(f' method="{endpoint["method"]}",') + lines.append(" url=url,") + lines.append(' headers={"Content-Type": "application/json"},') + if has_query: + lines.append(" query=query_params,") + if body_lines: + lines.append(" body=body,") + lines.append(" )") + lines.append(f" response = await self.{accessor}().execute(request) # type: ignore[reportUnknownMemberType]") + lines.append(" response_data = response.json() if response.text() else None") + lines.append(" return DocuSignResponse(") + lines.append(" success=response.status < HTTP_ERROR_THRESHOLD,") + lines.append(" data=response_data,") + lines.append(f' message="Successfully executed {method_name}" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {{response.status}}"') + lines.append(" )") + lines.append(" except Exception as e:") + lines.append(f' return DocuSignResponse(success=False, error=str(e), message="Failed to execute {method_name}")') + + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# Class assembly +# --------------------------------------------------------------------------- + + +def build_class() -> str: + """Build the complete DocuSignDataSource class source code.""" + parts: List[str] = [] + + # File header + parts.append("""\ +# ruff: noqa +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false, reportUnknownParameterType=false, reportArgumentType=false +\"\"\" +DocuSign Unified DataSource - Auto-generated API wrapper + +Covers all DocuSign APIs: +- eSignature (SDK-based via docusign-esign) +- Admin, Rooms, Click, Monitor, WebForms (HTTP-based) + +All eSign methods are synchronous (SDK). All HTTP methods are async. +\"\"\" + +from __future__ import annotations + +from typing import cast + +import docusign_esign # type: ignore[reportMissingImports] +from docusign_esign import ApiClient # type: ignore[reportMissingImports] + +from app.sources.client.docusign.docusign import DocuSignClient, DocuSignResponse +from app.sources.client.http.http_client import HTTPClient +from app.sources.client.http.http_request import HTTPRequest + +# HTTP status code constant +HTTP_ERROR_THRESHOLD = 400 + + +class DocuSignDataSource: + \"\"\"DocuSign Unified DataSource + + Provides wrapper methods for all DocuSign API operations: + - eSignature: Envelopes, Templates, Users, Folders, Brands (SDK-based, sync) + - Admin: Organizations, Users, Groups, Permissions (HTTP-based, async) + - Rooms: Rooms, Documents, Templates, Roles (HTTP-based, async) + - Click: Clickwraps, Agreements, Service Info (HTTP-based, async) + - Monitor: Audit stream events (HTTP-based, async) + - WebForms: Forms, Instances (HTTP-based, async) + + All methods return DocuSignResponse objects. + \"\"\" + + # Base URLs for non-eSign APIs + ADMIN_BASE_URL = "https://api-d.docusign.net/management" + ROOMS_BASE_URL = "https://demo.rooms.docusign.com/restapi" + CLICK_BASE_URL = "https://demo.docusign.net/clickapi" + MONITOR_BASE_URL = "https://lens-d.docusign.net" + WEBFORMS_BASE_URL = "https://apps-d.docusign.com/api/webforms/v1.1" + + def __init__(self, client: DocuSignClient) -> None: + \"\"\"Initialize with DocuSignClient. + + Args: + client: DocuSignClient instance with configured authentication + \"\"\" + self._client = client + # eSign SDK + self._sdk: ApiClient = cast(ApiClient, client.get_client().get_sdk()) + self._account_id: str = client.get_client().get_account_id() + # Lazy HTTP clients for each API + self._admin_http: HTTPClient | None = None + self._rooms_http: HTTPClient | None = None + self._click_http: HTTPClient | None = None + self._monitor_http: HTTPClient | None = None + self._webforms_http: HTTPClient | None = None + + # Lazy SDK API instances + self._envelopes_api: docusign_esign.EnvelopesApi | None = None + self._templates_api: docusign_esign.TemplatesApi | None = None + self._users_api: docusign_esign.UsersApi | None = None + self._folders_api: docusign_esign.FoldersApi | None = None + self._accounts_api: docusign_esign.AccountsApi | None = None + + # ---- lazy HTTP client accessors ---- + + def _get_admin_http(self) -> HTTPClient: + if self._admin_http is None: + self._admin_http = self._client.get_client().get_http_client(self.ADMIN_BASE_URL) + return self._admin_http + + def _get_rooms_http(self) -> HTTPClient: + if self._rooms_http is None: + self._rooms_http = self._client.get_client().get_http_client(self.ROOMS_BASE_URL) + return self._rooms_http + + def _get_click_http(self) -> HTTPClient: + if self._click_http is None: + self._click_http = self._client.get_client().get_http_client(self.CLICK_BASE_URL) + return self._click_http + + def _get_monitor_http(self) -> HTTPClient: + if self._monitor_http is None: + self._monitor_http = self._client.get_client().get_http_client(self.MONITOR_BASE_URL) + return self._monitor_http + + def _get_webforms_http(self) -> HTTPClient: + if self._webforms_http is None: + self._webforms_http = self._client.get_client().get_http_client(self.WEBFORMS_BASE_URL) + return self._webforms_http + + # ---- lazy SDK API accessors ---- + + @property + def envelopes_api(self) -> docusign_esign.EnvelopesApi: + if self._envelopes_api is None: + self._envelopes_api = docusign_esign.EnvelopesApi(self._sdk) + return self._envelopes_api + + @property + def templates_api(self) -> docusign_esign.TemplatesApi: + if self._templates_api is None: + self._templates_api = docusign_esign.TemplatesApi(self._sdk) + return self._templates_api + + @property + def users_api(self) -> docusign_esign.UsersApi: + if self._users_api is None: + self._users_api = docusign_esign.UsersApi(self._sdk) + return self._users_api + + @property + def folders_api(self) -> docusign_esign.FoldersApi: + if self._folders_api is None: + self._folders_api = docusign_esign.FoldersApi(self._sdk) + return self._folders_api + + @property + def accounts_api(self) -> docusign_esign.AccountsApi: + if self._accounts_api is None: + self._accounts_api = docusign_esign.AccountsApi(self._sdk) + return self._accounts_api + + # ---- helpers ---- + + def get_data_source(self) -> 'DocuSignDataSource': + \"\"\"Return the data source instance.\"\"\" + return self + + def get_client(self) -> DocuSignClient: + \"\"\"Return the underlying DocuSignClient.\"\"\" + return self._client + + @staticmethod + def _params(**kwargs: object) -> dict[str, object]: + \"\"\"Filter out Nones to avoid overriding SDK defaults.\"\"\" + out: dict[str, object] = {} + for k, v in kwargs.items(): + if v is None: + continue + if isinstance(v, (list, dict)) and len(v) == 0: + continue + out[k] = v + return out + + # ---- eSign SDK methods (synchronous) ---- +""") + + # Emit SDK methods + for sig, body, doc in SDK_METHODS: + parts.append(_emit_sdk_method(sig, body, doc)) + parts.append("") + + # Section header for HTTP methods + parts.append(" # ---- HTTP-based methods (async) ----\n") + + # Emit HTTP methods + for method_name, endpoint in DOCUSIGN_HTTP_ENDPOINTS.items(): + parts.append(_emit_http_method(method_name, endpoint)) + parts.append("") + + return "\n".join(parts) + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + + +def main() -> None: + """Generate and write DocuSignDataSource.""" + out_path = DEFAULT_OUT + + # Support --out flag + if "--out" in sys.argv: + idx = sys.argv.index("--out") + if idx + 1 < len(sys.argv): + out_path = sys.argv[idx + 1] + + code = build_class() + + p = Path(out_path) + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(code, encoding="utf-8") + + sdk_count = len(SDK_METHODS) + http_count = len(DOCUSIGN_HTTP_ENDPOINTS) + total = sdk_count + http_count + + print(f"Generated DocuSignDataSource -> {out_path} ({total} methods)") + print(f" - eSign SDK methods: {sdk_count}") + print(f" - HTTP methods: {http_count}") + + # Breakdown by API + api_counts: Dict[str, int] = {} + for ep in DOCUSIGN_HTTP_ENDPOINTS.values(): + api = ep["api"] + api_counts[api] = api_counts.get(api, 0) + 1 + for api, count in sorted(api_counts.items()): + print(f" - {api}: {count}") + + +if __name__ == "__main__": + main() diff --git a/backend/python/code-generator/egnyte.py b/backend/python/code-generator/egnyte.py new file mode 100644 index 000000000..1d5e40535 --- /dev/null +++ b/backend/python/code-generator/egnyte.py @@ -0,0 +1,746 @@ +# ruff: noqa +""" +Egnyte REST API Code Generator + +Generates EgnyteDataSource class covering Egnyte Public API v1: +- File system operations (metadata, content, folders) +- Links management +- User and group management +- Audit operations (files, logins, permissions) + +The generated DataSource accepts an EgnyteClient and uses the client's +configured domain to construct the base URL. + +All methods have explicit parameter signatures with no **kwargs usage. + +Usage: + python code-generator/egnyte.py + python code-generator/egnyte.py --filename egnyte.py +""" + +import sys +from pathlib import Path +from typing import Dict, List, Optional + +# ================================================================================ +# Egnyte API Endpoints +# +# Each endpoint defines: +# method: HTTP verb +# path: URL path (appended to base_url which already includes /pubapi/v1) +# description: Human-readable description +# parameters: Dict of param_name -> {type, location (path/query/body), description} +# required: List of required parameter names +# ================================================================================ + +EGNYTE_API_ENDPOINTS = { + # ================================================================================ + # FILE SYSTEM - METADATA + # ================================================================================ + "get_file_or_folder_metadata": { + "method": "GET", + "path": "/fs/{path}", + "description": "Get file or folder metadata at the given path", + "parameters": { + "path": {"type": "str", "location": "path", "description": "File or folder path (e.g. 'Shared/Documents')"}, + "list_content": {"type": "Optional[bool]", "location": "query", "description": "If true and path is a folder, list contents"}, + "allowed_link_types": {"type": "Optional[bool]", "location": "query", "description": "Include allowed link types info"}, + "count": {"type": "Optional[int]", "location": "query", "description": "Number of items to return (for folder listing)"}, + "offset": {"type": "Optional[int]", "location": "query", "description": "Offset for pagination (for folder listing)"}, + "sort_by": {"type": "Optional[str]", "location": "query", "description": "Sort field (name, last_modified, size)"}, + "sort_direction": {"type": "Optional[str]", "location": "query", "description": "Sort direction (asc, desc)"}, + }, + "required": ["path"], + }, + "create_folder": { + "method": "POST", + "path": "/fs/{path}", + "description": "Create a folder at the given path", + "parameters": { + "path": {"type": "str", "location": "path", "description": "Folder path to create"}, + "action": {"type": "str", "location": "body", "description": "Action type (must be 'add_folder')"}, + }, + "required": ["path", "action"], + }, + "delete_file_or_folder": { + "method": "DELETE", + "path": "/fs/{path}", + "description": "Delete a file or folder at the given path", + "parameters": { + "path": {"type": "str", "location": "path", "description": "File or folder path to delete"}, + }, + "required": ["path"], + }, + "move_file_or_folder": { + "method": "POST", + "path": "/fs/{path}", + "description": "Move or copy a file or folder", + "parameters": { + "path": {"type": "str", "location": "path", "description": "Source file or folder path"}, + "action": {"type": "str", "location": "body", "description": "Action type ('move' or 'copy')"}, + "destination": {"type": "str", "location": "body", "description": "Destination path"}, + }, + "required": ["path", "action", "destination"], + }, + + # ================================================================================ + # FILE SYSTEM - CONTENT + # ================================================================================ + "download_file": { + "method": "GET", + "path": "/fs-content/{path}", + "description": "Download file content at the given path", + "parameters": { + "path": {"type": "str", "location": "path", "description": "File path to download"}, + "entry_id": {"type": "Optional[str]", "location": "query", "description": "Specific version entry ID"}, + }, + "required": ["path"], + }, + "upload_file": { + "method": "POST", + "path": "/fs-content/{path}", + "description": "Upload file content to the given path", + "parameters": { + "path": {"type": "str", "location": "path", "description": "File path for upload"}, + }, + "required": ["path"], + }, + + # ================================================================================ + # LINKS + # ================================================================================ + "list_links": { + "method": "GET", + "path": "/links", + "description": "List shared links", + "parameters": { + "path": {"type": "Optional[str]", "location": "query", "description": "Filter by path"}, + "type_": {"type": "Optional[str]", "location": "query", "description": "Link type (file or folder)"}, + "accessibility": {"type": "Optional[str]", "location": "query", "description": "Accessibility (anyone, password, domain, recipients)"}, + "count": {"type": "Optional[int]", "location": "query", "description": "Number of links to return"}, + "offset": {"type": "Optional[int]", "location": "query", "description": "Offset for pagination"}, + }, + "required": [], + }, + "create_link": { + "method": "POST", + "path": "/links", + "description": "Create a shared link", + "parameters": { + "path": {"type": "str", "location": "body", "description": "Path to the file or folder"}, + "type_": {"type": "str", "location": "body", "description": "Link type (file or folder)"}, + "accessibility": {"type": "str", "location": "body", "description": "Accessibility (anyone, password, domain, recipients)"}, + "send_email": {"type": "Optional[bool]", "location": "body", "description": "Send email notification"}, + "recipients": {"type": "Optional[list[str]]", "location": "body", "description": "List of recipient email addresses"}, + "message": {"type": "Optional[str]", "location": "body", "description": "Email message body"}, + "copy_me": {"type": "Optional[bool]", "location": "body", "description": "Send copy to creator"}, + "notify": {"type": "Optional[bool]", "location": "body", "description": "Notify on access"}, + "link_to_current": {"type": "Optional[bool]", "location": "body", "description": "Link to current version only"}, + "expiry_date": {"type": "Optional[str]", "location": "body", "description": "Expiry date (YYYY-MM-DD)"}, + "expiry_clicks": {"type": "Optional[int]", "location": "body", "description": "Number of clicks before expiry"}, + "add_file_name": {"type": "Optional[bool]", "location": "body", "description": "Add file name to link"}, + }, + "required": ["path", "type_", "accessibility"], + }, + "get_link": { + "method": "GET", + "path": "/links/{link_id}", + "description": "Get a specific shared link", + "parameters": { + "link_id": {"type": "str", "location": "path", "description": "The link ID"}, + }, + "required": ["link_id"], + }, + "delete_link": { + "method": "DELETE", + "path": "/links/{link_id}", + "description": "Delete a shared link", + "parameters": { + "link_id": {"type": "str", "location": "path", "description": "The link ID"}, + }, + "required": ["link_id"], + }, + + # ================================================================================ + # USER INFO + # ================================================================================ + "get_current_user": { + "method": "GET", + "path": "/userinfo", + "description": "Get current authenticated user info", + "parameters": {}, + "required": [], + }, + + # ================================================================================ + # USERS + # ================================================================================ + "list_users": { + "method": "GET", + "path": "/users", + "description": "List users in the domain", + "parameters": { + "startIndex": {"type": "Optional[int]", "location": "query", "description": "Start index for pagination (1-based)"}, + "count": {"type": "Optional[int]", "location": "query", "description": "Number of users to return (max 100)"}, + }, + "required": [], + }, + "get_user": { + "method": "GET", + "path": "/users/{user_id}", + "description": "Get a specific user by ID", + "parameters": { + "user_id": {"type": "str", "location": "path", "description": "The user ID"}, + }, + "required": ["user_id"], + }, + "create_user": { + "method": "POST", + "path": "/users", + "description": "Create a new user", + "parameters": { + "userName": {"type": "str", "location": "body", "description": "Username (email)"}, + "externalId": {"type": "str", "location": "body", "description": "External ID"}, + "email": {"type": "str", "location": "body", "description": "User email address"}, + "name": {"type": "dict[str, str]", "location": "body", "description": "User name object with familyName and givenName"}, + "active": {"type": "Optional[bool]", "location": "body", "description": "Whether user is active"}, + "sendInvite": {"type": "Optional[bool]", "location": "body", "description": "Send invite email"}, + "authType": {"type": "Optional[str]", "location": "body", "description": "Authentication type"}, + "userType": {"type": "Optional[str]", "location": "body", "description": "User type (power, standard, etc.)"}, + "role": {"type": "Optional[str]", "location": "body", "description": "User role"}, + }, + "required": ["userName", "externalId", "email", "name"], + }, + "update_user": { + "method": "PATCH", + "path": "/users/{user_id}", + "description": "Update an existing user", + "parameters": { + "user_id": {"type": "str", "location": "path", "description": "The user ID"}, + "userName": {"type": "Optional[str]", "location": "body", "description": "Username (email)"}, + "email": {"type": "Optional[str]", "location": "body", "description": "User email address"}, + "name": {"type": "Optional[dict[str, str]]", "location": "body", "description": "User name object"}, + "active": {"type": "Optional[bool]", "location": "body", "description": "Whether user is active"}, + "userType": {"type": "Optional[str]", "location": "body", "description": "User type"}, + "role": {"type": "Optional[str]", "location": "body", "description": "User role"}, + }, + "required": ["user_id"], + }, + "delete_user": { + "method": "DELETE", + "path": "/users/{user_id}", + "description": "Delete a user", + "parameters": { + "user_id": {"type": "str", "location": "path", "description": "The user ID"}, + }, + "required": ["user_id"], + }, + + # ================================================================================ + # GROUPS + # ================================================================================ + "list_groups": { + "method": "GET", + "path": "/groups", + "description": "List all groups", + "parameters": {}, + "required": [], + }, + "get_group": { + "method": "GET", + "path": "/groups/{group_id}", + "description": "Get a specific group by ID", + "parameters": { + "group_id": {"type": "str", "location": "path", "description": "The group ID"}, + }, + "required": ["group_id"], + }, + "create_group": { + "method": "POST", + "path": "/groups", + "description": "Create a new group", + "parameters": { + "displayName": {"type": "str", "location": "body", "description": "Group display name"}, + "members": {"type": "Optional[list[dict[str, str]]]", "location": "body", "description": "List of member objects with 'value' (user ID)"}, + }, + "required": ["displayName"], + }, + "update_group": { + "method": "PATCH", + "path": "/groups/{group_id}", + "description": "Update a group", + "parameters": { + "group_id": {"type": "str", "location": "path", "description": "The group ID"}, + "displayName": {"type": "Optional[str]", "location": "body", "description": "Group display name"}, + "members": {"type": "Optional[list[dict[str, str]]]", "location": "body", "description": "List of member objects"}, + }, + "required": ["group_id"], + }, + "delete_group": { + "method": "DELETE", + "path": "/groups/{group_id}", + "description": "Delete a group", + "parameters": { + "group_id": {"type": "str", "location": "path", "description": "The group ID"}, + }, + "required": ["group_id"], + }, + + # ================================================================================ + # AUDIT + # ================================================================================ + "audit_files": { + "method": "GET", + "path": "/audit/files", + "description": "Audit file activity (access, uploads, downloads, etc.)", + "parameters": { + "startdate": {"type": "str", "location": "query", "description": "Start date (YYYY-MM-DD)"}, + "enddate": {"type": "str", "location": "query", "description": "End date (YYYY-MM-DD)"}, + "count": {"type": "Optional[int]", "location": "query", "description": "Number of records to return"}, + "offset": {"type": "Optional[int]", "location": "query", "description": "Offset for pagination"}, + "folder": {"type": "Optional[str]", "location": "query", "description": "Filter by folder path"}, + "file": {"type": "Optional[str]", "location": "query", "description": "Filter by file path"}, + "users": {"type": "Optional[str]", "location": "query", "description": "Filter by username"}, + "transaction_type": {"type": "Optional[str]", "location": "query", "description": "Transaction type filter"}, + }, + "required": ["startdate", "enddate"], + }, + "audit_logins": { + "method": "GET", + "path": "/audit/logins", + "description": "Audit login activity", + "parameters": { + "startdate": {"type": "str", "location": "query", "description": "Start date (YYYY-MM-DD)"}, + "enddate": {"type": "str", "location": "query", "description": "End date (YYYY-MM-DD)"}, + "count": {"type": "Optional[int]", "location": "query", "description": "Number of records to return"}, + "offset": {"type": "Optional[int]", "location": "query", "description": "Offset for pagination"}, + "users": {"type": "Optional[str]", "location": "query", "description": "Filter by username"}, + "events": {"type": "Optional[str]", "location": "query", "description": "Filter by event type"}, + "access_points": {"type": "Optional[str]", "location": "query", "description": "Filter by access point"}, + }, + "required": ["startdate", "enddate"], + }, + "audit_permissions": { + "method": "GET", + "path": "/audit/permissions", + "description": "Audit permissions changes", + "parameters": { + "startdate": {"type": "str", "location": "query", "description": "Start date (YYYY-MM-DD)"}, + "enddate": {"type": "str", "location": "query", "description": "End date (YYYY-MM-DD)"}, + "count": {"type": "Optional[int]", "location": "query", "description": "Number of records to return"}, + "offset": {"type": "Optional[int]", "location": "query", "description": "Offset for pagination"}, + "folder": {"type": "Optional[str]", "location": "query", "description": "Filter by folder path"}, + "users": {"type": "Optional[str]", "location": "query", "description": "Filter by username"}, + }, + "required": ["startdate", "enddate"], + }, + + # ================================================================================ + # SEARCH + # ================================================================================ + "search": { + "method": "GET", + "path": "/search", + "description": "Search for files and folders", + "parameters": { + "query": {"type": "str", "location": "query", "description": "Search query string"}, + "offset": {"type": "Optional[int]", "location": "query", "description": "Offset for pagination"}, + "count": {"type": "Optional[int]", "location": "query", "description": "Number of results to return"}, + "folder": {"type": "Optional[str]", "location": "query", "description": "Restrict search to folder path"}, + "modified_before": {"type": "Optional[str]", "location": "query", "description": "Filter modified before (ISO 8601)"}, + "modified_after": {"type": "Optional[str]", "location": "query", "description": "Filter modified after (ISO 8601)"}, + "type_": {"type": "Optional[str]", "location": "query", "description": "Filter by type (file, folder)"}, + }, + "required": ["query"], + }, + + # ================================================================================ + # PERMISSIONS + # ================================================================================ + "get_folder_permissions": { + "method": "GET", + "path": "/perms/{path}", + "description": "Get permissions for a folder", + "parameters": { + "path": {"type": "str", "location": "path", "description": "Folder path"}, + }, + "required": ["path"], + }, + "set_folder_permissions": { + "method": "POST", + "path": "/perms/{path}", + "description": "Set permissions for a folder", + "parameters": { + "path": {"type": "str", "location": "path", "description": "Folder path"}, + "userPerms": {"type": "Optional[dict[str, str]]", "location": "body", "description": "User permissions mapping"}, + "groupPerms": {"type": "Optional[dict[str, str]]", "location": "body", "description": "Group permissions mapping"}, + "inheritsPermissions": {"type": "Optional[bool]", "location": "body", "description": "Whether folder inherits parent permissions"}, + }, + "required": ["path"], + }, +} + + +class EgnyteDataSourceGenerator: + """Generator for comprehensive Egnyte REST API datasource class.""" + + def __init__(self): + self.generated_methods: List[Dict[str, str]] = [] + + def _sanitize_parameter_name(self, name: str) -> str: + """Sanitize parameter names to be valid Python identifiers.""" + sanitized = name.replace("-", "_").replace(".", "_").replace("/", "_") + if sanitized and not (sanitized[0].isalpha() or sanitized[0] == "_"): + sanitized = f"param_{sanitized}" + # Handle trailing underscore for reserved words + if sanitized == "type_": + return "type_" + return sanitized + + def _build_query_params(self, endpoint_info: Dict) -> List[str]: + """Build query parameter handling code.""" + lines = [" query_params: dict[str, Any] = {}"] + + # Map for parameters that use different API names + api_name_map = {"type_": "type"} + + for param_name, param_info in endpoint_info["parameters"].items(): + if param_info["location"] == "query": + sanitized_name = self._sanitize_parameter_name(param_name) + api_name = api_name_map.get(param_name, param_name) + + if "Optional[bool]" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{api_name}'] = str({sanitized_name}).lower()", + ]) + elif "Optional[int]" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{api_name}'] = str({sanitized_name})", + ]) + elif param_name in endpoint_info["required"]: + lines.append(f" query_params['{api_name}'] = {sanitized_name}") + else: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{api_name}'] = {sanitized_name}", + ]) + + return lines + + def _build_path_formatting(self, path: str, endpoint_info: Dict) -> str: + """Build URL path with parameter substitution.""" + path_params = [ + name + for name, info in endpoint_info["parameters"].items() + if info["location"] == "path" + ] + + if path_params: + format_dict = ", ".join( + f"{param}={self._sanitize_parameter_name(param)}" + for param in path_params + ) + return f' url = self.base_url + "{path}".format({format_dict})' + else: + return f' url = self.base_url + "{path}"' + + def _build_request_body(self, endpoint_info: Dict) -> List[str]: + """Build request body handling.""" + body_params = { + name: info + for name, info in endpoint_info["parameters"].items() + if info["location"] == "body" + } + + if not body_params: + return [] + + # Map for parameters that use different API names + api_name_map = {"type_": "type"} + + lines = [" body: dict[str, Any] = {}"] + + for param_name, param_info in body_params.items(): + sanitized_name = self._sanitize_parameter_name(param_name) + api_name = api_name_map.get(param_name, param_name) + + if param_name in endpoint_info["required"]: + lines.append(f" body['{api_name}'] = {sanitized_name}") + else: + lines.extend([ + f" if {sanitized_name} is not None:", + f" body['{api_name}'] = {sanitized_name}", + ]) + + return lines + + @staticmethod + def _modernize_type(type_str: str) -> str: + """Convert typing-style annotations to modern Python 3.10+ syntax.""" + if type_str.startswith("Optional[") and type_str.endswith("]"): + inner = type_str[len("Optional["):-1] + inner = EgnyteDataSourceGenerator._modernize_type(inner) + return f"{inner} | None" + return type_str + + def _generate_method_signature(self, method_name: str, endpoint_info: Dict) -> str: + """Generate method signature with explicit parameters.""" + params = ["self"] + + # Collect required params + required_non_bool: List[str] = [] + for param_name in endpoint_info["required"]: + if param_name in endpoint_info["parameters"]: + param_info = endpoint_info["parameters"][param_name] + sanitized_name = self._sanitize_parameter_name(param_name) + modern_type = self._modernize_type(param_info["type"]) + required_non_bool.append(f"{sanitized_name}: {modern_type}") + + # Collect optional parameters + optional_params: List[str] = [] + for param_name, param_info in endpoint_info["parameters"].items(): + if param_name not in endpoint_info["required"]: + sanitized_name = self._sanitize_parameter_name(param_name) + modern_type = self._modernize_type(param_info["type"]) + if "| None" not in modern_type: + modern_type = f"{modern_type} | None" + optional_params.append(f"{sanitized_name}: {modern_type} = None") + + params.extend(required_non_bool) + if optional_params: + params.append("*") + params.extend(optional_params) + + signature_params = ",\n ".join(params) + return f" async def {method_name}(\n {signature_params}\n ) -> EgnyteResponse:" + + def _generate_method_docstring(self, endpoint_info: Dict) -> List[str]: + """Generate method docstring.""" + lines = [f' """{endpoint_info["description"]}', ""] + + if endpoint_info["parameters"]: + lines.append(" Args:") + for param_name, param_info in endpoint_info["parameters"].items(): + sanitized_name = self._sanitize_parameter_name(param_name) + lines.append( + f" {sanitized_name}: {param_info['description']}" + ) + lines.append("") + + lines.extend([ + " Returns:", + " EgnyteResponse with operation result", + ' """', + ]) + + return lines + + def _generate_method(self, method_name: str, endpoint_info: Dict) -> str: + """Generate a complete method for an API endpoint.""" + lines = [] + + # Method signature + lines.append(self._generate_method_signature(method_name, endpoint_info)) + + # Docstring + lines.extend(self._generate_method_docstring(endpoint_info)) + + # Query parameters + has_query = any( + info["location"] == "query" + for info in endpoint_info["parameters"].values() + ) + if has_query: + query_lines = self._build_query_params(endpoint_info) + lines.extend(query_lines) + lines.append("") + + # URL construction + lines.append(self._build_path_formatting(endpoint_info["path"], endpoint_info)) + + # Request body + body_lines = self._build_request_body(endpoint_info) + if body_lines: + lines.append("") + lines.extend(body_lines) + + # Request construction and execution + lines.append("") + lines.append(" try:") + lines.append(" request = HTTPRequest(") + lines.append(f' method="{endpoint_info["method"]}",') + lines.append(" url=url,") + lines.append(' headers={"Content-Type": "application/json"},') + if has_query: + lines.append(" query=query_params,") + if body_lines: + lines.append(" body=body,") + lines.append(" )") + lines.extend([ + " response = await self.http.execute(request) # type: ignore[reportUnknownMemberType]", + " response_data = response.json() if response.text() else None", + " return EgnyteResponse(", + " success=response.status < HTTP_ERROR_THRESHOLD,", + " data=response_data,", + f' message="Successfully executed {method_name}" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {{response.status}}"', + " )", + " except Exception as e:", + f' return EgnyteResponse(success=False, error=str(e), message="Failed to execute {method_name}")', + ]) + + self.generated_methods.append({ + "name": method_name, + "endpoint": endpoint_info["path"], + "method": endpoint_info["method"], + "description": endpoint_info["description"], + }) + + return "\n".join(lines) + + def generate_egnyte_datasource(self) -> str: + """Generate the complete Egnyte datasource class.""" + + class_lines = [ + '"""', + "Egnyte REST API DataSource - Auto-generated API wrapper", + "", + "Generated from Egnyte Public API v1 documentation.", + "Uses HTTP client for direct REST API interactions.", + "All methods have explicit parameter signatures.", + '"""', + "", + "from __future__ import annotations", + "", + "from typing import Any", + "", + "from app.sources.client.egnyte.egnyte import EgnyteClient, EgnyteResponse", + "from app.sources.client.http.http_request import HTTPRequest", + "", + "# HTTP status code constant", + "HTTP_ERROR_THRESHOLD = 400", + "", + "", + "class EgnyteDataSource:", + ' """Egnyte REST API DataSource', + "", + " Provides async wrapper methods for Egnyte Public API v1 operations:", + " - File system operations (metadata, content, folders)", + " - Links management", + " - User and group management", + " - Audit operations (files, logins, permissions)", + " - Search", + " - Permissions management", + "", + " The base URL is determined by the EgnyteClient's configured domain.", + "", + " All methods return EgnyteResponse objects.", + ' """', + "", + " def __init__(self, client: EgnyteClient) -> None:", + ' """Initialize with EgnyteClient.', + "", + " Args:", + " client: EgnyteClient instance with configured authentication and domain", + ' """', + " self._client = client", + " self.http = client.get_client()", + " try:", + " self.base_url = self.http.get_base_url().rstrip('/')", + " except AttributeError as exc:", + " raise ValueError('HTTP client does not have get_base_url method') from exc", + "", + " def get_data_source(self) -> 'EgnyteDataSource':", + ' """Return the data source instance."""', + " return self", + "", + " def get_client(self) -> EgnyteClient:", + ' """Return the underlying EgnyteClient."""', + " return self._client", + "", + ] + + # Generate all API methods + for method_name, endpoint_info in EGNYTE_API_ENDPOINTS.items(): + class_lines.append(self._generate_method(method_name, endpoint_info)) + class_lines.append("") + + return "\n".join(class_lines) + + def save_to_file(self, filename: Optional[str] = None) -> None: + """Generate and save the Egnyte datasource to a file.""" + if filename is None: + filename = "egnyte.py" + + script_dir = Path(__file__).parent if __file__ else Path(".") + egnyte_dir = script_dir.parent / "app" / "sources" / "external" / "egnyte" + egnyte_dir.mkdir(parents=True, exist_ok=True) + + full_path = egnyte_dir / filename + + class_code = self.generate_egnyte_datasource() + full_path.write_text(class_code, encoding="utf-8") + + print(f"Generated Egnyte data source with {len(self.generated_methods)} methods") + print(f"Saved to: {full_path}") + + # Print summary by resource + resource_categories = { + "File System": 0, + "Links": 0, + "Users": 0, + "Groups": 0, + "Audit": 0, + "Search": 0, + "Permissions": 0, + "User Info": 0, + } + + for method in self.generated_methods: + name = method["name"] + if "file" in name or "folder" in name or "download" in name or "upload" in name: + resource_categories["File System"] += 1 + elif "link" in name: + resource_categories["Links"] += 1 + elif "user" in name and "current" not in name: + resource_categories["Users"] += 1 + elif "group" in name: + resource_categories["Groups"] += 1 + elif "audit" in name: + resource_categories["Audit"] += 1 + elif "search" in name: + resource_categories["Search"] += 1 + elif "perm" in name: + resource_categories["Permissions"] += 1 + elif "current" in name: + resource_categories["User Info"] += 1 + + print(f"\nMethods by Resource:") + for category, count in resource_categories.items(): + if count > 0: + print(f" - {category}: {count}") + + +def main(): + """Main function for Egnyte data source generator.""" + import argparse + + parser = argparse.ArgumentParser( + description="Generate Egnyte REST API data source" + ) + parser.add_argument("--filename", "-f", help="Output filename (optional)") + + args = parser.parse_args() + + try: + generator = EgnyteDataSourceGenerator() + generator.save_to_file(args.filename) + return 0 + except Exception as e: + print(f"Failed to generate Egnyte data source: {e}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/python/code-generator/elasticsearch_db.py b/backend/python/code-generator/elasticsearch_db.py new file mode 100644 index 000000000..d0e927878 --- /dev/null +++ b/backend/python/code-generator/elasticsearch_db.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +# ruff: noqa +from __future__ import annotations + +""" +Elasticsearch (elasticsearch-py) -- Code Generator (strict, no `Any`, no `None` passthrough) + +Emits an `ElasticsearchDataSource` with explicit, typed methods mapped to *real* elasticsearch-py APIs. +- No `Any` in signatures or implementation. +- Never forwards None to the SDK (filters optionals). +- Accepts either a raw `Elasticsearch` instance or any client exposing `.get_sdk() -> Elasticsearch`. + +SDK references: +- Info: client.info() +- Search: client.search(index=..., body=...) +- Index: client.index(index=..., id=..., body=...) +- Get: client.get(index=..., id=...) +- Delete: client.delete(index=..., id=...) +- Bulk: client.bulk(body=..., index=...) +- Count: client.count(index=..., body=...) +- Mapping: client.indices.get_mapping(index=...) +- Indices: client.indices.get_alias(index="*") +- Create index: client.indices.create(index=..., body=...) +- Delete index: client.indices.delete(index=...) +- Cluster health: client.cluster.health() +- Cluster stats: client.cluster.stats() +- Scroll: client.scroll(scroll_id=..., scroll=...) +- Clear scroll: client.clear_scroll(scroll_id=...) +""" + +import argparse +import textwrap +from typing import Dict, List, Optional, Tuple + +# ----------------------------- +# Configuration knobs (CLI-set) +# ----------------------------- + +DEFAULT_RESPONSE_IMPORT = "from app.sources.client.elasticsearch_db.elasticsearch_db import ElasticsearchResponse" +DEFAULT_CLASS_NAME = "ElasticsearchDataSource" +DEFAULT_OUT = "elasticsearch_data_source.py" + + +HEADER = '''\ +# ruff: noqa +from __future__ import annotations + +from elasticsearch import Elasticsearch # type: ignore[import-untyped] +from typing import Dict, List, Optional, Union, cast + +{response_import} + +class {class_name}: + """ + Strict, typed wrapper over elasticsearch-py for common Elasticsearch operations. + + Accepts either an elasticsearch-py `Elasticsearch` instance *or* any object with `.get_sdk() -> Elasticsearch`. + """ + + def __init__(self, client_or_sdk: Union[Elasticsearch, object]) -> None: + super().__init__() + # Support a raw SDK or a wrapper that exposes `.get_sdk()` + if hasattr(client_or_sdk, "get_sdk"): + sdk_obj = getattr(client_or_sdk, "get_sdk")() + self._sdk: Elasticsearch = cast(Elasticsearch, sdk_obj) + else: + self._sdk = cast(Elasticsearch, client_or_sdk) + + # ---- helpers ---- + @staticmethod + def _params(**kwargs: object) -> Dict[str, object]: + # Filter out Nones to avoid overriding SDK defaults + out: Dict[str, object] = {} + for k, v in kwargs.items(): + if v is None: + continue + if isinstance(v, (list, dict)) and len(v) == 0: + continue + out[k] = v + return out +''' + +FOOTER = """ +""" + +# Each tuple: (signature, body, short_doc) +METHODS: List[Tuple[str, str, str]] = [] + +# ---------- Cluster / Info ---------- +METHODS += [ + ( + "info(self) -> ElasticsearchResponse", + " result = self._sdk.info()\n" + " return ElasticsearchResponse(success=True, data=result)", + "Get cluster info.", + ), + ( + "get_cluster_health(self) -> ElasticsearchResponse", + " result = self._sdk.cluster.health()\n" + " return ElasticsearchResponse(success=True, data=result)", + "Get cluster health status.", + ), + ( + "get_cluster_stats(self) -> ElasticsearchResponse", + " result = self._sdk.cluster.stats()\n" + " return ElasticsearchResponse(success=True, data=result)", + "Get cluster statistics.", + ), +] + +# ---------- Index management ---------- +METHODS += [ + ( + "list_indices(self) -> ElasticsearchResponse", + " result = self._sdk.indices.get_alias(index='*')\n" + " return ElasticsearchResponse(success=True, data=result)", + "List all indices and their aliases.", + ), + ( + "create_index(self, index: str, body: Optional[Dict[str, object]] = None) -> ElasticsearchResponse", + " params = self._params(index=index, body=body)\n" + " result = self._sdk.indices.create(**params)\n" + " return ElasticsearchResponse(success=True, data=result)", + "Create an index with optional settings/mappings.", + ), + ( + "delete_index(self, index: str) -> ElasticsearchResponse", + " result = self._sdk.indices.delete(index=index)\n" + " return ElasticsearchResponse(success=True, data=result)", + "Delete an index.", + ), + ( + "get_mapping(self, index: str) -> ElasticsearchResponse", + " result = self._sdk.indices.get_mapping(index=index)\n" + " return ElasticsearchResponse(success=True, data=result)", + "Get mapping for an index.", + ), +] + +# ---------- Document operations ---------- +METHODS += [ + ( + "search(self, index: str, body: Optional[Dict[str, object]] = None, size: Optional[int] = None, from_: Optional[int] = None, sort: Optional[str] = None) -> ElasticsearchResponse", + " params = self._params(index=index, body=body, size=size, sort=sort)\n" + " if from_ is not None:\n" + " params['from_'] = from_\n" + " result = self._sdk.search(**params)\n" + " return ElasticsearchResponse(success=True, data=result)", + "Search documents in an index.", + ), + ( + "index_document(self, index: str, body: Dict[str, object], doc_id: Optional[str] = None) -> ElasticsearchResponse", + " params = self._params(index=index, body=body, id=doc_id)\n" + " result = self._sdk.index(**params)\n" + " return ElasticsearchResponse(success=True, data=result)", + "Index (create/update) a document.", + ), + ( + "get_document(self, index: str, doc_id: str) -> ElasticsearchResponse", + " result = self._sdk.get(index=index, id=doc_id)\n" + " return ElasticsearchResponse(success=True, data=result)", + "Get a document by ID.", + ), + ( + "delete_document(self, index: str, doc_id: str) -> ElasticsearchResponse", + " result = self._sdk.delete(index=index, id=doc_id)\n" + " return ElasticsearchResponse(success=True, data=result)", + "Delete a document by ID.", + ), + ( + "count(self, index: str, body: Optional[Dict[str, object]] = None) -> ElasticsearchResponse", + " params = self._params(index=index, body=body)\n" + " result = self._sdk.count(**params)\n" + " return ElasticsearchResponse(success=True, data=result)", + "Count documents in an index.", + ), + ( + "bulk(self, body: List[Dict[str, object]], index: Optional[str] = None) -> ElasticsearchResponse", + " params = self._params(body=body, index=index)\n" + " result = self._sdk.bulk(**params)\n" + " return ElasticsearchResponse(success=True, data=result)", + "Perform bulk operations.", + ), +] + +# ---------- Scroll ---------- +METHODS += [ + ( + "scroll(self, scroll_id: str, scroll: str = '5m') -> ElasticsearchResponse", + " result = self._sdk.scroll(scroll_id=scroll_id, scroll=scroll)\n" + " return ElasticsearchResponse(success=True, data=result)", + "Continue a scroll search.", + ), + ( + "clear_scroll(self, scroll_id: str) -> ElasticsearchResponse", + " result = self._sdk.clear_scroll(scroll_id=scroll_id)\n" + " return ElasticsearchResponse(success=True, data=result)", + "Clear a scroll context.", + ), +] + +# ------------------------- +# Code emission utilities +# ------------------------- + + +def _emit_method(sig: str, body: str, doc: str) -> str: + normalized_body = textwrap.indent(textwrap.dedent(body), " ") + return f' def {sig}:\n """{doc}"""\n{normalized_body}\n' + + +def build_class( + response_import: str = DEFAULT_RESPONSE_IMPORT, class_name: str = DEFAULT_CLASS_NAME +) -> str: + parts: List[str] = [] + header = HEADER.replace("{response_import}", response_import).replace( + "{class_name}", class_name + ) + parts.append(header) + for sig, body, doc in METHODS: + parts.append(_emit_method(sig, body, doc)) + parts.append(FOOTER) + return "".join(parts) + + +def write_output(path: str, code: str) -> None: + with open(path, "w", encoding="utf-8") as f: + f.write(code) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Generate ElasticsearchDataSource (elasticsearch-py)." + ) + parser.add_argument( + "--out", default=DEFAULT_OUT, help="Output path for the generated data source." + ) + parser.add_argument( + "--response-import", + default=DEFAULT_RESPONSE_IMPORT, + help="Import line to bring in ElasticsearchResponse.", + ) + parser.add_argument( + "--class-name", + default=DEFAULT_CLASS_NAME, + help="Name of the generated datasource class.", + ) + parser.add_argument( + "--print", + dest="do_print", + action="store_true", + help="Also print generated code to stdout.", + ) + args = parser.parse_args() + + code = build_class(response_import=args.response_import, class_name=args.class_name) + write_output(args.out, code) + if args.do_print: + print(code) + + +if __name__ == "__main__": + main() diff --git a/backend/python/code-generator/figma.py b/backend/python/code-generator/figma.py new file mode 100644 index 000000000..8c05fb8f2 --- /dev/null +++ b/backend/python/code-generator/figma.py @@ -0,0 +1,779 @@ +# ruff: noqa +""" +Figma REST API Code Generator + +Generates FigmaDataSource class covering Figma API v1: +- User / Authentication +- Files and File Nodes +- Images +- Comments +- File Versions +- Team Projects and Project Files +- Components and Component Sets +- Styles +- Variables (Local and Published) +- Webhooks +- Activity Logs + +The generated DataSource accepts a FigmaClient and uses the client's +base URL (https://api.figma.com/v1). + +All methods have explicit parameter signatures with no **kwargs usage. +""" + +import sys +from pathlib import Path +from typing import Dict, List, Optional + +# ================================================================================ +# Figma API Endpoints - organized by resource +# +# Each endpoint defines: +# method: HTTP verb +# path: URL path (appended to base_url which is https://api.figma.com/v1) +# description: Human-readable description +# parameters: Dict of param_name -> {type, location (path/query/body), description} +# required: List of required parameter names +# ================================================================================ + +FIGMA_API_ENDPOINTS = { + # ================================================================================ + # USER / AUTHENTICATION + # ================================================================================ + "get_current_user": { + "method": "GET", + "path": "/me", + "description": "Get the current authenticated user", + "parameters": {}, + "required": [], + }, + + # ================================================================================ + # FILES + # ================================================================================ + "get_file": { + "method": "GET", + "path": "/files/{file_key}", + "description": "Get a Figma file by key", + "parameters": { + "file_key": {"type": "str", "location": "path", "description": "The file key (from the Figma file URL)"}, + "version": {"type": "Optional[str]", "location": "query", "description": "A specific version ID to get"}, + "ids": {"type": "Optional[str]", "location": "query", "description": "Comma-separated list of node IDs to retrieve"}, + "depth": {"type": "Optional[int]", "location": "query", "description": "Positive integer representing how deep into the document tree to traverse"}, + "geometry": {"type": "Optional[str]", "location": "query", "description": "Set to 'paths' to export vector data"}, + "plugin_data": {"type": "Optional[str]", "location": "query", "description": "Comma-separated list of plugin IDs or 'shared' for shared plugin data"}, + "branch_data": {"type": "Optional[bool]", "location": "query", "description": "Returns branch metadata for the requested file"}, + }, + "required": ["file_key"], + }, + "get_file_nodes": { + "method": "GET", + "path": "/files/{file_key}/nodes", + "description": "Get specific nodes from a Figma file", + "parameters": { + "file_key": {"type": "str", "location": "path", "description": "The file key"}, + "ids": {"type": "str", "location": "query", "description": "Comma-separated list of node IDs to retrieve"}, + "version": {"type": "Optional[str]", "location": "query", "description": "A specific version ID to get"}, + "depth": {"type": "Optional[int]", "location": "query", "description": "Positive integer for document tree depth"}, + "geometry": {"type": "Optional[str]", "location": "query", "description": "Set to 'paths' to export vector data"}, + "plugin_data": {"type": "Optional[str]", "location": "query", "description": "Comma-separated list of plugin IDs or 'shared'"}, + }, + "required": ["file_key", "ids"], + }, + + # ================================================================================ + # IMAGES + # ================================================================================ + "get_file_images": { + "method": "GET", + "path": "/images/{file_key}", + "description": "Render images from a Figma file", + "parameters": { + "file_key": {"type": "str", "location": "path", "description": "The file key"}, + "ids": {"type": "str", "location": "query", "description": "Comma-separated list of node IDs to render"}, + "scale": {"type": "Optional[float]", "location": "query", "description": "Image scale factor (0.01 to 4)"}, + "format": {"type": "Optional[str]", "location": "query", "description": "Image format: jpg, png, svg, or pdf"}, + "svg_include_id": {"type": "Optional[bool]", "location": "query", "description": "Include id attribute for all SVG elements"}, + "svg_simplify_stroke": {"type": "Optional[bool]", "location": "query", "description": "Simplify inside/outside strokes and use stroke attribute"}, + "use_absolute_bounds": {"type": "Optional[bool]", "location": "query", "description": "Use full dimensions of the node regardless of cropping"}, + "version": {"type": "Optional[str]", "location": "query", "description": "A specific version ID to get"}, + }, + "required": ["file_key", "ids"], + }, + + # ================================================================================ + # COMMENTS + # ================================================================================ + "list_comments": { + "method": "GET", + "path": "/files/{file_key}/comments", + "description": "List comments on a file", + "parameters": { + "file_key": {"type": "str", "location": "path", "description": "The file key"}, + }, + "required": ["file_key"], + }, + "post_comment": { + "method": "POST", + "path": "/files/{file_key}/comments", + "description": "Post a comment on a file", + "parameters": { + "file_key": {"type": "str", "location": "path", "description": "The file key"}, + "message": {"type": "str", "location": "body", "description": "The comment text"}, + "comment_id": {"type": "Optional[str]", "location": "body", "description": "The ID of the comment to reply to"}, + "client_meta": {"type": "Optional[Dict[str, Any]]", "location": "body", "description": "Position of the comment (x, y, node_id, node_offset)"}, + }, + "required": ["file_key", "message"], + }, + + # ================================================================================ + # FILE VERSIONS + # ================================================================================ + "list_file_versions": { + "method": "GET", + "path": "/files/{file_key}/versions", + "description": "List version history of a file", + "parameters": { + "file_key": {"type": "str", "location": "path", "description": "The file key"}, + }, + "required": ["file_key"], + }, + + # ================================================================================ + # TEAM PROJECTS + # ================================================================================ + "list_team_projects": { + "method": "GET", + "path": "/teams/{team_id}/projects", + "description": "List projects in a team", + "parameters": { + "team_id": {"type": "str", "location": "path", "description": "The team ID"}, + }, + "required": ["team_id"], + }, + + # ================================================================================ + # PROJECT FILES + # ================================================================================ + "list_project_files": { + "method": "GET", + "path": "/projects/{project_id}/files", + "description": "List files in a project", + "parameters": { + "project_id": {"type": "str", "location": "path", "description": "The project ID"}, + "branch_data": {"type": "Optional[bool]", "location": "query", "description": "Returns branch metadata for the requested files"}, + }, + "required": ["project_id"], + }, + + # ================================================================================ + # COMPONENTS + # ================================================================================ + "list_team_components": { + "method": "GET", + "path": "/teams/{team_id}/components", + "description": "List components published in a team library", + "parameters": { + "team_id": {"type": "str", "location": "path", "description": "The team ID"}, + "page_size": {"type": "Optional[int]", "location": "query", "description": "Number of items per page (max 30)"}, + "after": {"type": "Optional[str]", "location": "query", "description": "Cursor for pagination (next page)"}, + "before": {"type": "Optional[str]", "location": "query", "description": "Cursor for pagination (previous page)"}, + }, + "required": ["team_id"], + }, + "list_file_components": { + "method": "GET", + "path": "/files/{file_key}/components", + "description": "List components in a file", + "parameters": { + "file_key": {"type": "str", "location": "path", "description": "The file key"}, + }, + "required": ["file_key"], + }, + + # ================================================================================ + # COMPONENT SETS + # ================================================================================ + "list_team_component_sets": { + "method": "GET", + "path": "/teams/{team_id}/component_sets", + "description": "List component sets published in a team library", + "parameters": { + "team_id": {"type": "str", "location": "path", "description": "The team ID"}, + "page_size": {"type": "Optional[int]", "location": "query", "description": "Number of items per page (max 30)"}, + "after": {"type": "Optional[str]", "location": "query", "description": "Cursor for pagination (next page)"}, + "before": {"type": "Optional[str]", "location": "query", "description": "Cursor for pagination (previous page)"}, + }, + "required": ["team_id"], + }, + + # ================================================================================ + # STYLES + # ================================================================================ + "list_team_styles": { + "method": "GET", + "path": "/teams/{team_id}/styles", + "description": "List styles published in a team library", + "parameters": { + "team_id": {"type": "str", "location": "path", "description": "The team ID"}, + "page_size": {"type": "Optional[int]", "location": "query", "description": "Number of items per page (max 30)"}, + "after": {"type": "Optional[str]", "location": "query", "description": "Cursor for pagination (next page)"}, + "before": {"type": "Optional[str]", "location": "query", "description": "Cursor for pagination (previous page)"}, + }, + "required": ["team_id"], + }, + "list_file_styles": { + "method": "GET", + "path": "/files/{file_key}/styles", + "description": "List styles in a file", + "parameters": { + "file_key": {"type": "str", "location": "path", "description": "The file key"}, + }, + "required": ["file_key"], + }, + + # ================================================================================ + # VARIABLES + # ================================================================================ + "get_local_variables": { + "method": "GET", + "path": "/files/{file_key}/variables/local", + "description": "Get local variables in a file", + "parameters": { + "file_key": {"type": "str", "location": "path", "description": "The file key"}, + }, + "required": ["file_key"], + }, + "get_published_variables": { + "method": "GET", + "path": "/files/{file_key}/variables/published", + "description": "Get published variables in a file", + "parameters": { + "file_key": {"type": "str", "location": "path", "description": "The file key"}, + }, + "required": ["file_key"], + }, + + # ================================================================================ + # WEBHOOKS + # ================================================================================ + "get_webhook": { + "method": "GET", + "path": "/webhooks/{webhook_id}", + "description": "Get a webhook by ID", + "parameters": { + "webhook_id": {"type": "str", "location": "path", "description": "The webhook ID"}, + }, + "required": ["webhook_id"], + }, + "create_webhook": { + "method": "POST", + "path": "/webhooks", + "description": "Create a new webhook", + "parameters": { + "event_type": {"type": "str", "location": "body", "description": "The event type to subscribe to"}, + "team_id": {"type": "str", "location": "body", "description": "The team ID to receive events from"}, + "endpoint": {"type": "str", "location": "body", "description": "The endpoint URL to receive webhook events"}, + "passcode": {"type": "Optional[str]", "location": "body", "description": "A passcode for webhook verification"}, + "description": {"type": "Optional[str]", "location": "body", "description": "A description for the webhook"}, + }, + "required": ["event_type", "team_id", "endpoint"], + }, + "update_webhook": { + "method": "PUT", + "path": "/webhooks/{webhook_id}", + "description": "Update an existing webhook", + "parameters": { + "webhook_id": {"type": "str", "location": "path", "description": "The webhook ID"}, + "event_type": {"type": "Optional[str]", "location": "body", "description": "The event type to subscribe to"}, + "endpoint": {"type": "Optional[str]", "location": "body", "description": "The endpoint URL to receive webhook events"}, + "passcode": {"type": "Optional[str]", "location": "body", "description": "A passcode for webhook verification"}, + "description": {"type": "Optional[str]", "location": "body", "description": "A description for the webhook"}, + }, + "required": ["webhook_id"], + }, + "delete_webhook": { + "method": "DELETE", + "path": "/webhooks/{webhook_id}", + "description": "Delete a webhook", + "parameters": { + "webhook_id": {"type": "str", "location": "path", "description": "The webhook ID"}, + }, + "required": ["webhook_id"], + }, + "list_team_webhooks": { + "method": "GET", + "path": "/teams/{team_id}/webhooks", + "description": "List webhooks for a team", + "parameters": { + "team_id": {"type": "str", "location": "path", "description": "The team ID"}, + }, + "required": ["team_id"], + }, + + # ================================================================================ + # ACTIVITY LOGS + # ================================================================================ + "list_activity_logs": { + "method": "GET", + "path": "/activity_logs", + "description": "List activity log events", + "parameters": { + "events": {"type": "Optional[str]", "location": "query", "description": "Comma-separated list of event types to filter"}, + "limit": {"type": "Optional[int]", "location": "query", "description": "Maximum number of events to return"}, + "order": {"type": "Optional[str]", "location": "query", "description": "Sort order: 'asc' or 'desc'"}, + }, + "required": [], + }, +} + + +class FigmaDataSourceGenerator: + """Generator for comprehensive Figma REST API datasource class. + + Generates methods for Figma API v1 endpoints. + The generated DataSource class accepts a FigmaClient whose + base URL is https://api.figma.com/v1. + + All methods have explicit parameter signatures. + """ + + def __init__(self): + self.generated_methods: List[Dict[str, str]] = [] + + def _sanitize_parameter_name(self, name: str) -> str: + """Sanitize parameter names to be valid Python identifiers.""" + sanitized = name.replace("-", "_").replace(".", "_").replace("/", "_") + if sanitized and not (sanitized[0].isalpha() or sanitized[0] == "_"): + sanitized = f"param_{sanitized}" + # Avoid shadowing Python builtins + if sanitized == "format": + sanitized = "image_format" + return sanitized + + def _build_query_params(self, endpoint_info: Dict) -> List[str]: + """Build query parameter handling code.""" + lines = [" query_params: dict[str, Any] = {}"] + required = endpoint_info.get("required", []) + + for param_name, param_info in endpoint_info["parameters"].items(): + if param_info["location"] == "query": + sanitized_name = self._sanitize_parameter_name(param_name) + is_required = param_name in required + + if is_required: + # Required query params: add unconditionally (no None check) + if "bool" in param_info["type"]: + lines.append( + f" query_params['{param_name}'] = str({sanitized_name}).lower()" + ) + elif "int" in param_info["type"] or "float" in param_info["type"]: + lines.append( + f" query_params['{param_name}'] = str({sanitized_name})" + ) + else: + lines.append( + f" query_params['{param_name}'] = {sanitized_name}" + ) + elif "Optional[bool]" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}'] = str({sanitized_name}).lower()", + ]) + elif "Optional[int]" in param_info["type"] or "Optional[float]" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}'] = str({sanitized_name})", + ]) + elif "List[" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}[]'] = {sanitized_name}", + ]) + else: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}'] = {sanitized_name}", + ]) + + return lines + + def _build_path_formatting(self, path: str, endpoint_info: Dict) -> str: + """Build URL path with parameter substitution.""" + path_params = [ + name + for name, info in endpoint_info["parameters"].items() + if info["location"] == "path" + ] + + if path_params: + format_dict = ", ".join( + f"{param}={self._sanitize_parameter_name(param)}" + for param in path_params + ) + return f' url = self.base_url + "{path}".format({format_dict})' + else: + return f' url = self.base_url + "{path}"' + + def _build_request_body(self, endpoint_info: Dict) -> List[str]: + """Build request body handling.""" + body_params = { + name: info + for name, info in endpoint_info["parameters"].items() + if info["location"] == "body" + } + + if not body_params: + return [] + + lines = [" body: dict[str, Any] = {}"] + + for param_name, param_info in body_params.items(): + sanitized_name = self._sanitize_parameter_name(param_name) + + if param_name in endpoint_info["required"]: + lines.append(f" body['{param_name}'] = {sanitized_name}") + else: + lines.extend([ + f" if {sanitized_name} is not None:", + f" body['{param_name}'] = {sanitized_name}", + ]) + + return lines + + @staticmethod + def _modernize_type(type_str: str) -> str: + """Convert typing-style annotations to modern Python 3.10+ syntax. + + Optional[str] -> str | None, Dict[str, Any] -> dict[str, Any], + List[str] -> list[str], etc. + """ + if type_str.startswith("Optional[") and type_str.endswith("]"): + inner = type_str[len("Optional["):-1] + inner = FigmaDataSourceGenerator._modernize_type(inner) + return f"{inner} | None" + if type_str.startswith("Dict["): + inner = type_str[len("Dict["):-1] + parts = FigmaDataSourceGenerator._split_type_args(inner) + modernized = ", ".join( + FigmaDataSourceGenerator._modernize_type(p.strip()) for p in parts + ) + return f"dict[{modernized}]" + if type_str == "Dict": + return "dict" + if type_str.startswith("List["): + inner = type_str[len("List["):-1] + parts = FigmaDataSourceGenerator._split_type_args(inner) + modernized = ", ".join( + FigmaDataSourceGenerator._modernize_type(p.strip()) for p in parts + ) + return f"list[{modernized}]" + if type_str == "List": + return "list" + return type_str + + @staticmethod + def _split_type_args(s: str) -> List[str]: + """Split type arguments respecting nested brackets.""" + parts = [] + depth = 0 + current = "" + for ch in s: + if ch == "[": + depth += 1 + elif ch == "]": + depth -= 1 + if ch == "," and depth == 0: + parts.append(current.strip()) + current = "" + else: + current += ch + if current.strip(): + parts.append(current.strip()) + return parts + + def _generate_method_signature(self, method_name: str, endpoint_info: Dict) -> str: + """Generate method signature with explicit parameters.""" + params = ["self"] + has_any_bool = False + + # Collect required params, split into non-bool and bool groups + required_non_bool: List[str] = [] + required_bool: List[str] = [] + for param_name in endpoint_info["required"]: + if param_name in endpoint_info["parameters"]: + param_info = endpoint_info["parameters"][param_name] + sanitized_name = self._sanitize_parameter_name(param_name) + modern_type = self._modernize_type(param_info["type"]) + param_str = f"{sanitized_name}: {modern_type}" + if "bool" in param_info.get("type", ""): + required_bool.append(param_str) + has_any_bool = True + else: + required_non_bool.append(param_str) + + # Collect optional parameters + optional_params: List[str] = [] + for param_name, param_info in endpoint_info["parameters"].items(): + if param_name not in endpoint_info["required"]: + sanitized_name = self._sanitize_parameter_name(param_name) + modern_type = self._modernize_type(param_info["type"]) + if "| None" not in modern_type: + modern_type = f"{modern_type} | None" + optional_params.append(f"{sanitized_name}: {modern_type} = None") + if "bool" in param_info.get("type", ""): + has_any_bool = True + + # Build signature: non-bool required first, then * if needed, then bool required + optional + params.extend(required_non_bool) + if has_any_bool and (required_bool or optional_params): + params.append("*") + params.extend(required_bool) + params.extend(optional_params) + + signature_params = ",\n ".join(params) + return f" async def {method_name}(\n {signature_params}\n ) -> FigmaResponse:" + + def _generate_method_docstring(self, endpoint_info: Dict) -> List[str]: + """Generate method docstring.""" + lines = [f' """{endpoint_info["description"]}', ""] + + if endpoint_info["parameters"]: + lines.append(" Args:") + for param_name, param_info in endpoint_info["parameters"].items(): + sanitized_name = self._sanitize_parameter_name(param_name) + lines.append( + f" {sanitized_name}: {param_info['description']}" + ) + lines.append("") + + lines.extend([ + " Returns:", + " FigmaResponse with operation result", + ' """', + ]) + + return lines + + def _generate_method(self, method_name: str, endpoint_info: Dict) -> str: + """Generate a complete method for an API endpoint.""" + lines = [] + + # Method signature + lines.append(self._generate_method_signature(method_name, endpoint_info)) + + # Docstring + lines.extend(self._generate_method_docstring(endpoint_info)) + + # Query parameters + has_query = any( + info["location"] == "query" + for info in endpoint_info["parameters"].values() + ) + if has_query: + query_lines = self._build_query_params(endpoint_info) + lines.extend(query_lines) + lines.append("") + + # URL construction + lines.append(self._build_path_formatting(endpoint_info["path"], endpoint_info)) + + # Request body + body_lines = self._build_request_body(endpoint_info) + if body_lines: + lines.append("") + lines.extend(body_lines) + + # Request construction and execution + lines.append("") + lines.append(" try:") + lines.append(" request = HTTPRequest(") + lines.append(f' method="{endpoint_info["method"]}",') + lines.append(" url=url,") + lines.append(' headers={"Content-Type": "application/json"},') + if has_query: + lines.append(" query=query_params,") + if body_lines: + lines.append(" body=body,") + lines.append(" )") + lines.extend([ + " response = await self.http.execute(request) # type: ignore[reportUnknownMemberType]", + " response_data = response.json() if response.text() else None", + " return FigmaResponse(", + " success=response.status < HTTP_ERROR_THRESHOLD,", + " data=response_data,", + f' message="Successfully executed {method_name}" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {{response.status}}"', + " )", + " except Exception as e:", + f' return FigmaResponse(success=False, error=str(e), message="Failed to execute {method_name}")', + ]) + + self.generated_methods.append({ + "name": method_name, + "endpoint": endpoint_info["path"], + "method": endpoint_info["method"], + "description": endpoint_info["description"], + }) + + return "\n".join(lines) + + def generate_figma_datasource(self) -> str: + """Generate the complete Figma datasource class.""" + + class_lines = [ + '"""', + "Figma REST API DataSource - Auto-generated API wrapper", + "", + "Generated from Figma REST API v1 documentation.", + "Uses HTTP client for direct REST API interactions.", + "All methods have explicit parameter signatures.", + '"""', + "", + "from __future__ import annotations", + "", + "from typing import Any", + "", + "from app.sources.client.figma.figma import FigmaClient, FigmaResponse", + "from app.sources.client.http.http_request import HTTPRequest", + "", + "# HTTP status code constant", + "HTTP_ERROR_THRESHOLD = 400", + "", + "", + "class FigmaDataSource:", + ' """Figma REST API DataSource', + "", + " Provides async wrapper methods for Figma REST API operations:", + " - User / Authentication", + " - Files and File Nodes", + " - Images", + " - Comments", + " - File Versions", + " - Team Projects and Project Files", + " - Components and Component Sets", + " - Styles", + " - Variables (Local and Published)", + " - Webhooks", + " - Activity Logs", + "", + " The base URL is https://api.figma.com/v1.", + "", + " All methods return FigmaResponse objects.", + ' """', + "", + " def __init__(self, client: FigmaClient) -> None:", + ' """Initialize with FigmaClient.', + "", + " Args:", + " client: FigmaClient instance with configured authentication", + ' """', + " self._client = client", + " self.http = client.get_client()", + " try:", + " self.base_url = self.http.get_base_url().rstrip('/')", + " except AttributeError as exc:", + " raise ValueError('HTTP client does not have get_base_url method') from exc", + "", + " def get_data_source(self) -> 'FigmaDataSource':", + ' """Return the data source instance."""', + " return self", + "", + " def get_client(self) -> FigmaClient:", + ' """Return the underlying FigmaClient."""', + " return self._client", + "", + ] + + # Generate all API methods + for method_name, endpoint_info in FIGMA_API_ENDPOINTS.items(): + class_lines.append(self._generate_method(method_name, endpoint_info)) + class_lines.append("") + + return "\n".join(class_lines) + + def save_to_file(self, filename: Optional[str] = None) -> None: + """Generate and save the Figma datasource to a file.""" + if filename is None: + filename = "figma.py" + + script_dir = Path(__file__).parent if __file__ else Path(".") + figma_dir = script_dir.parent / "app" / "sources" / "external" / "figma" + figma_dir.mkdir(parents=True, exist_ok=True) + + full_path = figma_dir / filename + + class_code = self.generate_figma_datasource() + + full_path.write_text(class_code, encoding="utf-8") + + print(f"Generated Figma data source with {len(self.generated_methods)} methods") + print(f"Saved to: {full_path}") + + # Print summary by category + resource_categories = { + "User/Auth": 0, + "File": 0, + "Image": 0, + "Comment": 0, + "Version": 0, + "Project": 0, + "Component": 0, + "Style": 0, + "Variable": 0, + "Webhook": 0, + "Activity Log": 0, + } + + for method in self.generated_methods: + name = method["name"] + if "current_user" in name: + resource_categories["User/Auth"] += 1 + elif "image" in name: + resource_categories["Image"] += 1 + elif "comment" in name: + resource_categories["Comment"] += 1 + elif "version" in name: + resource_categories["Version"] += 1 + elif "project" in name: + resource_categories["Project"] += 1 + elif "component" in name: + resource_categories["Component"] += 1 + elif "style" in name: + resource_categories["Style"] += 1 + elif "variable" in name: + resource_categories["Variable"] += 1 + elif "webhook" in name: + resource_categories["Webhook"] += 1 + elif "activity" in name: + resource_categories["Activity Log"] += 1 + elif "file" in name or "node" in name: + resource_categories["File"] += 1 + + print(f"\nMethods by Resource:") + for category, count in resource_categories.items(): + if count > 0: + print(f" - {category}: {count}") + + +def main(): + """Main function for Figma data source generator.""" + import argparse + + parser = argparse.ArgumentParser( + description="Generate Figma REST API data source" + ) + parser.add_argument("--filename", "-f", help="Output filename (optional)") + + args = parser.parse_args() + + try: + generator = FigmaDataSourceGenerator() + generator.save_to_file(args.filename) + return 0 + except Exception as e: + print(f"Failed to generate Figma data source: {e}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/python/code-generator/freshservice.py b/backend/python/code-generator/freshservice.py new file mode 100644 index 000000000..df5b19b6c --- /dev/null +++ b/backend/python/code-generator/freshservice.py @@ -0,0 +1,669 @@ +# ruff: noqa +""" +Freshservice REST API Code Generator + +Generates FreshserviceDataSource class covering Freshservice API v2: +- Ticket operations +- Ticket conversations +- Requesters and agents +- Assets +- Problems, changes, releases +- Departments, groups +- Service catalog items + +The generated DataSource accepts a FreshserviceClient and uses the client's +base URL to construct API requests. + +All methods have explicit parameter signatures with no **kwargs usage. +""" + +import sys +from pathlib import Path +from typing import Dict, List, Optional + +# ================================================================================ +# Freshservice API Endpoints +# +# Each endpoint defines: +# method: HTTP verb +# path: URL path (appended to base_url which already includes /api/v2) +# description: Human-readable description +# parameters: Dict of param_name -> {type, location (path/query/body), description} +# required: List of required parameter names +# ================================================================================ + +FRESHSERVICE_API_ENDPOINTS = { + # ================================================================================ + # TICKETS + # ================================================================================ + "list_tickets": { + "method": "GET", + "path": "/tickets", + "description": "List all tickets with optional filters", + "parameters": { + "page": {"type": "Optional[int]", "location": "query", "description": "Page number for pagination"}, + "per_page": {"type": "Optional[int]", "location": "query", "description": "Number of tickets per page (max 100)"}, + "filter": {"type": "Optional[str]", "location": "query", "description": "Predefined filter name"}, + "order_by": {"type": "Optional[str]", "location": "query", "description": "Field to order by (e.g., created_at, updated_at)"}, + "order_type": {"type": "Optional[str]", "location": "query", "description": "Order direction: asc or desc"}, + "updated_since": {"type": "Optional[str]", "location": "query", "description": "Filter tickets updated since this timestamp (ISO format)"}, + "requester_id": {"type": "Optional[int]", "location": "query", "description": "Filter by requester ID"}, + }, + "required": [], + }, + "get_ticket": { + "method": "GET", + "path": "/tickets/{id}", + "description": "Get a specific ticket by ID", + "parameters": { + "id": {"type": "int", "location": "path", "description": "Ticket ID"}, + }, + "required": ["id"], + }, + "create_ticket": { + "method": "POST", + "path": "/tickets", + "description": "Create a new ticket", + "parameters": { + "subject": {"type": "str", "location": "body", "description": "Subject of the ticket"}, + "description": {"type": "Optional[str]", "location": "body", "description": "HTML content of the ticket"}, + "email": {"type": "Optional[str]", "location": "body", "description": "Email of the requester"}, + "requester_id": {"type": "Optional[int]", "location": "body", "description": "User ID of the requester"}, + "phone": {"type": "Optional[str]", "location": "body", "description": "Phone number of the requester"}, + "priority": {"type": "Optional[int]", "location": "body", "description": "Priority: 1=Low, 2=Medium, 3=High, 4=Urgent"}, + "status": {"type": "Optional[int]", "location": "body", "description": "Status: 2=Open, 3=Pending, 4=Resolved, 5=Closed"}, + "source": {"type": "Optional[int]", "location": "body", "description": "Source of the ticket"}, + "type": {"type": "Optional[str]", "location": "body", "description": "Type of the ticket"}, + "tags": {"type": "Optional[List[str]]", "location": "body", "description": "Tags for the ticket"}, + "cc_emails": {"type": "Optional[List[str]]", "location": "body", "description": "CC email addresses"}, + "custom_fields": {"type": "Optional[Dict[str, Any]]", "location": "body", "description": "Custom field values"}, + "department_id": {"type": "Optional[int]", "location": "body", "description": "Department ID"}, + "group_id": {"type": "Optional[int]", "location": "body", "description": "Group ID"}, + "category": {"type": "Optional[str]", "location": "body", "description": "Category of the ticket"}, + "sub_category": {"type": "Optional[str]", "location": "body", "description": "Sub-category of the ticket"}, + "item_category": {"type": "Optional[str]", "location": "body", "description": "Item category"}, + "responder_id": {"type": "Optional[int]", "location": "body", "description": "Agent ID to assign"}, + "due_by": {"type": "Optional[str]", "location": "body", "description": "Due date (ISO format)"}, + "fr_due_by": {"type": "Optional[str]", "location": "body", "description": "First response due date (ISO format)"}, + "urgency": {"type": "Optional[int]", "location": "body", "description": "Urgency of the ticket"}, + "impact": {"type": "Optional[int]", "location": "body", "description": "Impact of the ticket"}, + }, + "required": ["subject"], + }, + "update_ticket": { + "method": "PUT", + "path": "/tickets/{id}", + "description": "Update an existing ticket", + "parameters": { + "id": {"type": "int", "location": "path", "description": "Ticket ID"}, + "subject": {"type": "Optional[str]", "location": "body", "description": "Subject of the ticket"}, + "description": {"type": "Optional[str]", "location": "body", "description": "HTML content of the ticket"}, + "priority": {"type": "Optional[int]", "location": "body", "description": "Priority: 1=Low, 2=Medium, 3=High, 4=Urgent"}, + "status": {"type": "Optional[int]", "location": "body", "description": "Status: 2=Open, 3=Pending, 4=Resolved, 5=Closed"}, + "type": {"type": "Optional[str]", "location": "body", "description": "Type of the ticket"}, + "tags": {"type": "Optional[List[str]]", "location": "body", "description": "Tags for the ticket"}, + "custom_fields": {"type": "Optional[Dict[str, Any]]", "location": "body", "description": "Custom field values"}, + "department_id": {"type": "Optional[int]", "location": "body", "description": "Department ID"}, + "group_id": {"type": "Optional[int]", "location": "body", "description": "Group ID"}, + "category": {"type": "Optional[str]", "location": "body", "description": "Category"}, + "sub_category": {"type": "Optional[str]", "location": "body", "description": "Sub-category"}, + "item_category": {"type": "Optional[str]", "location": "body", "description": "Item category"}, + "responder_id": {"type": "Optional[int]", "location": "body", "description": "Agent ID to assign"}, + "urgency": {"type": "Optional[int]", "location": "body", "description": "Urgency"}, + "impact": {"type": "Optional[int]", "location": "body", "description": "Impact"}, + }, + "required": ["id"], + }, + "delete_ticket": { + "method": "DELETE", + "path": "/tickets/{id}", + "description": "Delete a ticket", + "parameters": { + "id": {"type": "int", "location": "path", "description": "Ticket ID"}, + }, + "required": ["id"], + }, + + # ================================================================================ + # TICKET CONVERSATIONS + # ================================================================================ + "list_ticket_conversations": { + "method": "GET", + "path": "/tickets/{id}/conversations", + "description": "List all conversations of a ticket", + "parameters": { + "id": {"type": "int", "location": "path", "description": "Ticket ID"}, + "page": {"type": "Optional[int]", "location": "query", "description": "Page number"}, + "per_page": {"type": "Optional[int]", "location": "query", "description": "Items per page"}, + }, + "required": ["id"], + }, + + # ================================================================================ + # REQUESTERS + # ================================================================================ + "list_requesters": { + "method": "GET", + "path": "/requesters", + "description": "List all requesters", + "parameters": { + "page": {"type": "Optional[int]", "location": "query", "description": "Page number"}, + "per_page": {"type": "Optional[int]", "location": "query", "description": "Items per page"}, + "email": {"type": "Optional[str]", "location": "query", "description": "Filter by email"}, + "query": {"type": "Optional[str]", "location": "query", "description": "Search query string"}, + }, + "required": [], + }, + "get_requester": { + "method": "GET", + "path": "/requesters/{id}", + "description": "Get a specific requester by ID", + "parameters": { + "id": {"type": "int", "location": "path", "description": "Requester ID"}, + }, + "required": ["id"], + }, + + # ================================================================================ + # AGENTS + # ================================================================================ + "list_agents": { + "method": "GET", + "path": "/agents", + "description": "List all agents", + "parameters": { + "page": {"type": "Optional[int]", "location": "query", "description": "Page number"}, + "per_page": {"type": "Optional[int]", "location": "query", "description": "Items per page"}, + "email": {"type": "Optional[str]", "location": "query", "description": "Filter by email"}, + "state": {"type": "Optional[str]", "location": "query", "description": "Filter by agent state (fulltime, occasional)"}, + }, + "required": [], + }, + "get_agent": { + "method": "GET", + "path": "/agents/{id}", + "description": "Get a specific agent by ID", + "parameters": { + "id": {"type": "int", "location": "path", "description": "Agent ID"}, + }, + "required": ["id"], + }, + + # ================================================================================ + # ASSETS + # ================================================================================ + "list_assets": { + "method": "GET", + "path": "/assets", + "description": "List all assets", + "parameters": { + "page": {"type": "Optional[int]", "location": "query", "description": "Page number"}, + "per_page": {"type": "Optional[int]", "location": "query", "description": "Items per page"}, + "filter": {"type": "Optional[str]", "location": "query", "description": "Filter name"}, + }, + "required": [], + }, + "get_asset": { + "method": "GET", + "path": "/assets/{display_id}", + "description": "Get a specific asset by display ID", + "parameters": { + "display_id": {"type": "int", "location": "path", "description": "Asset display ID"}, + }, + "required": ["display_id"], + }, + + # ================================================================================ + # PROBLEMS + # ================================================================================ + "list_problems": { + "method": "GET", + "path": "/problems", + "description": "List all problems", + "parameters": { + "page": {"type": "Optional[int]", "location": "query", "description": "Page number"}, + "per_page": {"type": "Optional[int]", "location": "query", "description": "Items per page"}, + }, + "required": [], + }, + + # ================================================================================ + # CHANGES + # ================================================================================ + "list_changes": { + "method": "GET", + "path": "/changes", + "description": "List all changes", + "parameters": { + "page": {"type": "Optional[int]", "location": "query", "description": "Page number"}, + "per_page": {"type": "Optional[int]", "location": "query", "description": "Items per page"}, + }, + "required": [], + }, + + # ================================================================================ + # RELEASES + # ================================================================================ + "list_releases": { + "method": "GET", + "path": "/releases", + "description": "List all releases", + "parameters": { + "page": {"type": "Optional[int]", "location": "query", "description": "Page number"}, + "per_page": {"type": "Optional[int]", "location": "query", "description": "Items per page"}, + }, + "required": [], + }, + + # ================================================================================ + # DEPARTMENTS + # ================================================================================ + "list_departments": { + "method": "GET", + "path": "/departments", + "description": "List all departments", + "parameters": { + "page": {"type": "Optional[int]", "location": "query", "description": "Page number"}, + "per_page": {"type": "Optional[int]", "location": "query", "description": "Items per page"}, + }, + "required": [], + }, + + # ================================================================================ + # GROUPS + # ================================================================================ + "list_groups": { + "method": "GET", + "path": "/groups", + "description": "List all groups", + "parameters": { + "page": {"type": "Optional[int]", "location": "query", "description": "Page number"}, + "per_page": {"type": "Optional[int]", "location": "query", "description": "Items per page"}, + }, + "required": [], + }, + + # ================================================================================ + # SERVICE CATALOG + # ================================================================================ + "list_service_catalog_items": { + "method": "GET", + "path": "/service_catalog/items", + "description": "List all service catalog items", + "parameters": { + "page": {"type": "Optional[int]", "location": "query", "description": "Page number"}, + "per_page": {"type": "Optional[int]", "location": "query", "description": "Items per page"}, + }, + "required": [], + }, +} + + +class FreshserviceDataSourceGenerator: + """Generator for Freshservice REST API datasource class.""" + + def __init__(self): + self.generated_methods: List[Dict[str, str]] = [] + + def _sanitize_parameter_name(self, name: str) -> str: + """Sanitize parameter names to be valid Python identifiers.""" + sanitized = name.replace("-", "_").replace(".", "_").replace("/", "_") + if sanitized and not (sanitized[0].isalpha() or sanitized[0] == "_"): + sanitized = f"param_{sanitized}" + # Handle Python keywords + if sanitized == "type": + sanitized = "type_" + elif sanitized == "filter": + sanitized = "filter_" + elif sanitized == "query": + sanitized = "query_" + return sanitized + + def _build_query_params(self, endpoint_info: Dict) -> List[str]: + """Build query parameter handling code.""" + lines = [" query_params: dict[str, Any] = {}"] + + for param_name, param_info in endpoint_info["parameters"].items(): + if param_info["location"] == "query": + sanitized_name = self._sanitize_parameter_name(param_name) + + if "Optional[bool]" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}'] = str({sanitized_name}).lower()", + ]) + elif "Optional[int]" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}'] = str({sanitized_name})", + ]) + elif "List[" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}[]'] = {sanitized_name}", + ]) + else: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}'] = {sanitized_name}", + ]) + + return lines + + def _build_path_formatting(self, path: str, endpoint_info: Dict) -> str: + """Build URL path with parameter substitution.""" + path_params = [ + name + for name, info in endpoint_info["parameters"].items() + if info["location"] == "path" + ] + + if path_params: + format_dict = ", ".join( + f"{param}={self._sanitize_parameter_name(param)}" + for param in path_params + ) + return f' url = self.base_url + "{path}".format({format_dict})' + else: + return f' url = self.base_url + "{path}"' + + def _build_request_body(self, endpoint_info: Dict) -> List[str]: + """Build request body handling.""" + body_params = { + name: info + for name, info in endpoint_info["parameters"].items() + if info["location"] == "body" + } + + if not body_params: + return [] + + lines = [" request_body: dict[str, Any] = {}"] + + for param_name, param_info in body_params.items(): + sanitized_name = self._sanitize_parameter_name(param_name) + + if param_name in endpoint_info["required"]: + lines.append(f" request_body['{param_name}'] = {sanitized_name}") + else: + lines.extend([ + f" if {sanitized_name} is not None:", + f" request_body['{param_name}'] = {sanitized_name}", + ]) + + return lines + + @staticmethod + def _modernize_type(type_str: str) -> str: + """Convert typing-style annotations to modern Python 3.10+ syntax.""" + if type_str.startswith("Optional[") and type_str.endswith("]"): + inner = type_str[len("Optional["):-1] + inner = FreshserviceDataSourceGenerator._modernize_type(inner) + return f"{inner} | None" + if type_str.startswith("Dict["): + inner = type_str[len("Dict["):-1] + parts = FreshserviceDataSourceGenerator._split_type_args(inner) + modernized = ", ".join( + FreshserviceDataSourceGenerator._modernize_type(p.strip()) for p in parts + ) + return f"dict[{modernized}]" + if type_str == "Dict": + return "dict" + if type_str.startswith("List["): + inner = type_str[len("List["):-1] + parts = FreshserviceDataSourceGenerator._split_type_args(inner) + modernized = ", ".join( + FreshserviceDataSourceGenerator._modernize_type(p.strip()) for p in parts + ) + return f"list[{modernized}]" + if type_str == "List": + return "list" + return type_str + + @staticmethod + def _split_type_args(s: str) -> List[str]: + """Split type arguments respecting nested brackets.""" + parts = [] + depth = 0 + current = "" + for ch in s: + if ch == "[": + depth += 1 + elif ch == "]": + depth -= 1 + if ch == "," and depth == 0: + parts.append(current.strip()) + current = "" + else: + current += ch + if current.strip(): + parts.append(current.strip()) + return parts + + def _generate_method_signature(self, method_name: str, endpoint_info: Dict) -> str: + """Generate method signature with explicit parameters.""" + params = ["self"] + + # Required params + for param_name in endpoint_info["required"]: + if param_name in endpoint_info["parameters"]: + param_info = endpoint_info["parameters"][param_name] + sanitized_name = self._sanitize_parameter_name(param_name) + modern_type = self._modernize_type(param_info["type"]) + params.append(f"{sanitized_name}: {modern_type}") + + # Optional parameters + for param_name, param_info in endpoint_info["parameters"].items(): + if param_name not in endpoint_info["required"]: + sanitized_name = self._sanitize_parameter_name(param_name) + modern_type = self._modernize_type(param_info["type"]) + if "| None" not in modern_type: + modern_type = f"{modern_type} | None" + params.append(f"{sanitized_name}: {modern_type} = None") + + signature_params = ",\n ".join(params) + return f" async def {method_name}(\n {signature_params}\n ) -> FreshserviceResponse:" + + def _generate_method_docstring(self, endpoint_info: Dict) -> List[str]: + """Generate method docstring.""" + lines = [f' """{endpoint_info["description"]}', ""] + + if endpoint_info["parameters"]: + lines.append(" Args:") + for param_name, param_info in endpoint_info["parameters"].items(): + sanitized_name = self._sanitize_parameter_name(param_name) + lines.append( + f" {sanitized_name}: {param_info['description']}" + ) + lines.append("") + + lines.extend([ + " Returns:", + " FreshserviceResponse with operation result", + ' """', + ]) + + return lines + + def _generate_method(self, method_name: str, endpoint_info: Dict) -> str: + """Generate a complete method for an API endpoint.""" + lines = [] + + # Method signature + lines.append(self._generate_method_signature(method_name, endpoint_info)) + + # Docstring + lines.extend(self._generate_method_docstring(endpoint_info)) + + # Query parameters + has_query = any( + info["location"] == "query" + for info in endpoint_info["parameters"].values() + ) + if has_query: + query_lines = self._build_query_params(endpoint_info) + lines.extend(query_lines) + lines.append("") + + # URL construction + lines.append(self._build_path_formatting(endpoint_info["path"], endpoint_info)) + + # Request body + body_lines = self._build_request_body(endpoint_info) + if body_lines: + lines.append("") + lines.extend(body_lines) + + # Request construction and execution + lines.append("") + lines.append(" try:") + lines.append(" request = HTTPRequest(") + lines.append(f' method="{endpoint_info["method"]}",') + lines.append(" url=url,") + lines.append(' headers={"Content-Type": "application/json"},') + if has_query: + lines.append(" query=query_params,") + if body_lines: + lines.append(" body=request_body,") + lines.append(" )") + lines.extend([ + " response = await self.http.execute(request) # type: ignore[reportUnknownMemberType]", + " response_data = response.json() if response.text() else None", + " return FreshserviceResponse(", + " success=response.status < HTTP_ERROR_THRESHOLD,", + " data=response_data,", + f' message="Successfully executed {method_name}" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {{response.status}}"', + " )", + " except Exception as e:", + f' return FreshserviceResponse(success=False, error=str(e), message="Failed to execute {method_name}")', + ]) + + self.generated_methods.append({ + "name": method_name, + "endpoint": endpoint_info["path"], + "method": endpoint_info["method"], + "description": endpoint_info["description"], + }) + + return "\n".join(lines) + + def generate_freshservice_datasource(self) -> str: + """Generate the complete Freshservice datasource class.""" + + class_lines = [ + "# ruff: noqa: A002, FBT001", + '"""', + "Freshservice REST API DataSource - Auto-generated API wrapper", + "", + "Generated from Freshservice REST API v2 documentation.", + "Uses HTTP client for direct REST API interactions.", + "All methods have explicit parameter signatures.", + '"""', + "", + "from __future__ import annotations", + "", + "from typing import Any", + "", + "from app.sources.client.freshservice.freshservice import FreshserviceClient, FreshserviceResponse", + "from app.sources.client.http.http_request import HTTPRequest", + "", + "# HTTP status code constant", + "HTTP_ERROR_THRESHOLD = 400", + "", + "", + "class FreshserviceDataSource:", + ' """Freshservice REST API DataSource', + "", + " Provides async wrapper methods for Freshservice REST API operations:", + " - Ticket CRUD and management", + " - Ticket conversations", + " - Requesters and agents", + " - Assets", + " - Problems, changes, releases", + " - Departments, groups", + " - Service catalog items", + "", + " All methods return FreshserviceResponse objects.", + ' """', + "", + " def __init__(self, client: FreshserviceClient) -> None:", + ' """Initialize with FreshserviceClient.', + "", + " Args:", + " client: FreshserviceClient instance with configured authentication", + ' """', + " self._client = client", + " self.http = client.get_client()", + " try:", + " self.base_url = self.http.get_base_url().rstrip('/')", + " except AttributeError as exc:", + " raise ValueError('HTTP client does not have get_base_url method') from exc", + "", + " def get_data_source(self) -> 'FreshserviceDataSource':", + ' """Return the data source instance."""', + " return self", + "", + " def get_client(self) -> FreshserviceClient:", + ' """Return the underlying FreshserviceClient."""', + " return self._client", + "", + ] + + # Generate all API methods + for method_name, endpoint_info in FRESHSERVICE_API_ENDPOINTS.items(): + class_lines.append(self._generate_method(method_name, endpoint_info)) + class_lines.append("") + + return "\n".join(class_lines) + + def save_to_file(self, filename: Optional[str] = None) -> None: + """Generate and save the Freshservice datasource to a file.""" + if filename is None: + filename = "freshservice.py" + + script_dir = Path(__file__).parent if __file__ else Path(".") + target_dir = script_dir.parent / "app" / "sources" / "external" / "freshservice" + target_dir.mkdir(parents=True, exist_ok=True) + + full_path = target_dir / filename + + class_code = self.generate_freshservice_datasource() + + full_path.write_text(class_code, encoding="utf-8") + + print(f"Generated Freshservice data source with {len(self.generated_methods)} methods") + print(f"Saved to: {full_path}") + + # Print summary + method_counts: Dict[str, int] = {} + for method in self.generated_methods: + http_method = method["method"] + method_counts[http_method] = method_counts.get(http_method, 0) + 1 + + print(f"\nMethods by HTTP verb:") + for verb, count in sorted(method_counts.items()): + print(f" - {verb}: {count}") + + +def main(): + """Main function for Freshservice data source generator.""" + import argparse + + parser = argparse.ArgumentParser( + description="Generate Freshservice REST API data source" + ) + parser.add_argument("--filename", "-f", help="Output filename (optional)") + + args = parser.parse_args() + + try: + generator = FreshserviceDataSourceGenerator() + generator.save_to_file(args.filename) + return 0 + except Exception as e: + print(f"Failed to generate Freshservice data source: {e}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/python/code-generator/greenhouse.py b/backend/python/code-generator/greenhouse.py new file mode 100644 index 000000000..80098878a --- /dev/null +++ b/backend/python/code-generator/greenhouse.py @@ -0,0 +1,763 @@ +# ruff: noqa +""" +Greenhouse Harvest REST API Code Generator + +Generates GreenhouseDataSource class covering Greenhouse Harvest API v1: +- Candidates and Applications +- Jobs and Job Stages +- Offers +- Departments and Offices +- Users +- Scorecards, Scheduled Interviews +- Sources, Rejection Reasons, Custom Fields +- Activity Feed + +The generated DataSource accepts a GreenhouseClient and uses the client's +base URL (https://harvest.greenhouse.io/v1). Methods are generated for +all Harvest API endpoints. + +All methods have explicit parameter signatures with no **kwargs usage. +""" + +import sys +from pathlib import Path +from typing import Dict, List, Optional + +# ================================================================================ +# Greenhouse Harvest API Endpoints +# +# Each endpoint defines: +# method: HTTP verb +# path: URL path (appended to base_url which already includes /v1) +# description: Human-readable description +# parameters: Dict of param_name -> {type, location (path/query/body), description} +# required: List of required parameter names +# ================================================================================ + +GREENHOUSE_API_ENDPOINTS = { + # ================================================================================ + # CANDIDATES + # ================================================================================ + "list_candidates": { + "method": "GET", + "path": "/candidates", + "description": "List all candidates", + "parameters": { + "per_page": {"type": "Optional[int]", "location": "query", "description": "Number of results per page (max 500)"}, + "page": {"type": "Optional[int]", "location": "query", "description": "Page number to retrieve"}, + "created_after": {"type": "Optional[str]", "location": "query", "description": "Return candidates created after this date (ISO 8601)"}, + "created_before": {"type": "Optional[str]", "location": "query", "description": "Return candidates created before this date (ISO 8601)"}, + "updated_after": {"type": "Optional[str]", "location": "query", "description": "Return candidates updated after this date (ISO 8601)"}, + "updated_before": {"type": "Optional[str]", "location": "query", "description": "Return candidates updated before this date (ISO 8601)"}, + "job_id": {"type": "Optional[str]", "location": "query", "description": "Filter candidates by job ID"}, + }, + "required": [], + }, + "get_candidate": { + "method": "GET", + "path": "/candidates/{candidate_id}", + "description": "Get a single candidate by ID", + "parameters": { + "candidate_id": {"type": "str", "location": "path", "description": "The candidate ID"}, + }, + "required": ["candidate_id"], + }, + + # ================================================================================ + # APPLICATIONS + # ================================================================================ + "list_applications": { + "method": "GET", + "path": "/applications", + "description": "List all applications", + "parameters": { + "per_page": {"type": "Optional[int]", "location": "query", "description": "Number of results per page (max 500)"}, + "page": {"type": "Optional[int]", "location": "query", "description": "Page number to retrieve"}, + "created_after": {"type": "Optional[str]", "location": "query", "description": "Return applications created after this date (ISO 8601)"}, + "created_before": {"type": "Optional[str]", "location": "query", "description": "Return applications created before this date (ISO 8601)"}, + "last_activity_after": {"type": "Optional[str]", "location": "query", "description": "Return applications with activity after this date (ISO 8601)"}, + "job_id": {"type": "Optional[str]", "location": "query", "description": "Filter applications by job ID"}, + "status": {"type": "Optional[str]", "location": "query", "description": "Filter by application status (active, converted, hired, rejected)"}, + }, + "required": [], + }, + "get_application": { + "method": "GET", + "path": "/applications/{application_id}", + "description": "Get a single application by ID", + "parameters": { + "application_id": {"type": "str", "location": "path", "description": "The application ID"}, + }, + "required": ["application_id"], + }, + + # ================================================================================ + # JOBS + # ================================================================================ + "list_jobs": { + "method": "GET", + "path": "/jobs", + "description": "List all jobs", + "parameters": { + "per_page": {"type": "Optional[int]", "location": "query", "description": "Number of results per page (max 500)"}, + "page": {"type": "Optional[int]", "location": "query", "description": "Page number to retrieve"}, + "status": {"type": "Optional[str]", "location": "query", "description": "Filter by job status (open, closed, draft)"}, + "department_id": {"type": "Optional[str]", "location": "query", "description": "Filter jobs by department ID"}, + "office_id": {"type": "Optional[str]", "location": "query", "description": "Filter jobs by office ID"}, + "created_after": {"type": "Optional[str]", "location": "query", "description": "Return jobs created after this date (ISO 8601)"}, + "created_before": {"type": "Optional[str]", "location": "query", "description": "Return jobs created before this date (ISO 8601)"}, + "updated_after": {"type": "Optional[str]", "location": "query", "description": "Return jobs updated after this date (ISO 8601)"}, + "updated_before": {"type": "Optional[str]", "location": "query", "description": "Return jobs updated before this date (ISO 8601)"}, + }, + "required": [], + }, + "get_job": { + "method": "GET", + "path": "/jobs/{job_id}", + "description": "Get a single job by ID", + "parameters": { + "job_id": {"type": "str", "location": "path", "description": "The job ID"}, + }, + "required": ["job_id"], + }, + + # ================================================================================ + # JOB STAGES + # ================================================================================ + "list_job_stages": { + "method": "GET", + "path": "/job_stages", + "description": "List all job stages", + "parameters": { + "per_page": {"type": "Optional[int]", "location": "query", "description": "Number of results per page (max 500)"}, + "page": {"type": "Optional[int]", "location": "query", "description": "Page number to retrieve"}, + "created_after": {"type": "Optional[str]", "location": "query", "description": "Return job stages created after this date (ISO 8601)"}, + "updated_after": {"type": "Optional[str]", "location": "query", "description": "Return job stages updated after this date (ISO 8601)"}, + }, + "required": [], + }, + "get_job_stage": { + "method": "GET", + "path": "/job_stages/{job_stage_id}", + "description": "Get a single job stage by ID", + "parameters": { + "job_stage_id": {"type": "str", "location": "path", "description": "The job stage ID"}, + }, + "required": ["job_stage_id"], + }, + + # ================================================================================ + # OFFERS + # ================================================================================ + "list_offers": { + "method": "GET", + "path": "/offers", + "description": "List all offers", + "parameters": { + "per_page": {"type": "Optional[int]", "location": "query", "description": "Number of results per page (max 500)"}, + "page": {"type": "Optional[int]", "location": "query", "description": "Page number to retrieve"}, + "created_after": {"type": "Optional[str]", "location": "query", "description": "Return offers created after this date (ISO 8601)"}, + "created_before": {"type": "Optional[str]", "location": "query", "description": "Return offers created before this date (ISO 8601)"}, + "updated_after": {"type": "Optional[str]", "location": "query", "description": "Return offers updated after this date (ISO 8601)"}, + "updated_before": {"type": "Optional[str]", "location": "query", "description": "Return offers updated before this date (ISO 8601)"}, + "status": {"type": "Optional[str]", "location": "query", "description": "Filter by offer status (unresolved, accepted, rejected, deprecated)"}, + }, + "required": [], + }, + "get_offer": { + "method": "GET", + "path": "/offers/{offer_id}", + "description": "Get a single offer by ID", + "parameters": { + "offer_id": {"type": "str", "location": "path", "description": "The offer ID"}, + }, + "required": ["offer_id"], + }, + + # ================================================================================ + # DEPARTMENTS + # ================================================================================ + "list_departments": { + "method": "GET", + "path": "/departments", + "description": "List all departments", + "parameters": { + "per_page": {"type": "Optional[int]", "location": "query", "description": "Number of results per page (max 500)"}, + "page": {"type": "Optional[int]", "location": "query", "description": "Page number to retrieve"}, + }, + "required": [], + }, + "get_department": { + "method": "GET", + "path": "/departments/{department_id}", + "description": "Get a single department by ID", + "parameters": { + "department_id": {"type": "str", "location": "path", "description": "The department ID"}, + }, + "required": ["department_id"], + }, + + # ================================================================================ + # OFFICES + # ================================================================================ + "list_offices": { + "method": "GET", + "path": "/offices", + "description": "List all offices", + "parameters": { + "per_page": {"type": "Optional[int]", "location": "query", "description": "Number of results per page (max 500)"}, + "page": {"type": "Optional[int]", "location": "query", "description": "Page number to retrieve"}, + }, + "required": [], + }, + "get_office": { + "method": "GET", + "path": "/offices/{office_id}", + "description": "Get a single office by ID", + "parameters": { + "office_id": {"type": "str", "location": "path", "description": "The office ID"}, + }, + "required": ["office_id"], + }, + + # ================================================================================ + # USERS + # ================================================================================ + "list_users": { + "method": "GET", + "path": "/users", + "description": "List all users", + "parameters": { + "per_page": {"type": "Optional[int]", "location": "query", "description": "Number of results per page (max 500)"}, + "page": {"type": "Optional[int]", "location": "query", "description": "Page number to retrieve"}, + "created_after": {"type": "Optional[str]", "location": "query", "description": "Return users created after this date (ISO 8601)"}, + "updated_after": {"type": "Optional[str]", "location": "query", "description": "Return users updated after this date (ISO 8601)"}, + "email": {"type": "Optional[str]", "location": "query", "description": "Filter users by email address"}, + }, + "required": [], + }, + "get_user": { + "method": "GET", + "path": "/users/{user_id}", + "description": "Get a single user by ID", + "parameters": { + "user_id": {"type": "str", "location": "path", "description": "The user ID"}, + }, + "required": ["user_id"], + }, + + # ================================================================================ + # SCORECARDS + # ================================================================================ + "list_scorecards": { + "method": "GET", + "path": "/scorecards", + "description": "List all scorecards", + "parameters": { + "per_page": {"type": "Optional[int]", "location": "query", "description": "Number of results per page (max 500)"}, + "page": {"type": "Optional[int]", "location": "query", "description": "Page number to retrieve"}, + "created_after": {"type": "Optional[str]", "location": "query", "description": "Return scorecards created after this date (ISO 8601)"}, + "updated_after": {"type": "Optional[str]", "location": "query", "description": "Return scorecards updated after this date (ISO 8601)"}, + "application_id": {"type": "Optional[str]", "location": "query", "description": "Filter scorecards by application ID"}, + }, + "required": [], + }, + + # ================================================================================ + # SCHEDULED INTERVIEWS + # ================================================================================ + "list_scheduled_interviews": { + "method": "GET", + "path": "/scheduled_interviews", + "description": "List all scheduled interviews", + "parameters": { + "per_page": {"type": "Optional[int]", "location": "query", "description": "Number of results per page (max 500)"}, + "page": {"type": "Optional[int]", "location": "query", "description": "Page number to retrieve"}, + "created_after": {"type": "Optional[str]", "location": "query", "description": "Return interviews created after this date (ISO 8601)"}, + "updated_after": {"type": "Optional[str]", "location": "query", "description": "Return interviews updated after this date (ISO 8601)"}, + "starts_after": {"type": "Optional[str]", "location": "query", "description": "Return interviews starting after this date (ISO 8601)"}, + "starts_before": {"type": "Optional[str]", "location": "query", "description": "Return interviews starting before this date (ISO 8601)"}, + }, + "required": [], + }, + + # ================================================================================ + # SOURCES + # ================================================================================ + "list_sources": { + "method": "GET", + "path": "/sources", + "description": "List all sources", + "parameters": { + "per_page": {"type": "Optional[int]", "location": "query", "description": "Number of results per page (max 500)"}, + "page": {"type": "Optional[int]", "location": "query", "description": "Page number to retrieve"}, + }, + "required": [], + }, + + # ================================================================================ + # REJECTION REASONS + # ================================================================================ + "list_rejection_reasons": { + "method": "GET", + "path": "/rejection_reasons", + "description": "List all rejection reasons", + "parameters": { + "per_page": {"type": "Optional[int]", "location": "query", "description": "Number of results per page (max 500)"}, + "page": {"type": "Optional[int]", "location": "query", "description": "Page number to retrieve"}, + }, + "required": [], + }, + + # ================================================================================ + # CUSTOM FIELDS + # ================================================================================ + "list_custom_fields": { + "method": "GET", + "path": "/custom_fields", + "description": "List all custom fields", + "parameters": { + "field_type": {"type": "Optional[str]", "location": "query", "description": "Filter by field type (candidate, application, offer, job, etc.)"}, + }, + "required": [], + }, + + # ================================================================================ + # ACTIVITY FEED + # ================================================================================ + "get_activity_feed": { + "method": "GET", + "path": "/candidates/{candidate_id}/activity_feed", + "description": "Get the activity feed for a candidate", + "parameters": { + "candidate_id": {"type": "str", "location": "path", "description": "The candidate ID"}, + }, + "required": ["candidate_id"], + }, +} + + +class GreenhouseDataSourceGenerator: + """Generator for comprehensive Greenhouse Harvest API datasource class. + + Generates methods for Greenhouse Harvest API v1 endpoints. + The generated DataSource class accepts a GreenhouseClient whose + base URL is https://harvest.greenhouse.io/v1. + """ + + def __init__(self): + self.generated_methods: List[Dict[str, str]] = [] + + def _sanitize_parameter_name(self, name: str) -> str: + """Sanitize parameter names to be valid Python identifiers.""" + sanitized = name.replace("-", "_").replace(".", "_").replace("/", "_") + if sanitized and not (sanitized[0].isalpha() or sanitized[0] == "_"): + sanitized = f"param_{sanitized}" + return sanitized + + def _build_query_params(self, endpoint_info: Dict) -> List[str]: + """Build query parameter handling code.""" + lines = [" query_params: dict[str, Any] = {}"] + + for param_name, param_info in endpoint_info["parameters"].items(): + if param_info["location"] == "query": + sanitized_name = self._sanitize_parameter_name(param_name) + + if "Optional[bool]" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}'] = str({sanitized_name}).lower()", + ]) + elif "Optional[int]" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}'] = str({sanitized_name})", + ]) + elif "List[" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}[]'] = {sanitized_name}", + ]) + else: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}'] = {sanitized_name}", + ]) + + return lines + + def _build_path_formatting(self, path: str, endpoint_info: Dict) -> str: + """Build URL path with parameter substitution.""" + path_params = [ + name + for name, info in endpoint_info["parameters"].items() + if info["location"] == "path" + ] + + if path_params: + format_dict = ", ".join( + f"{param}={self._sanitize_parameter_name(param)}" + for param in path_params + ) + return f' url = self.base_url + "{path}".format({format_dict})' + else: + return f' url = self.base_url + "{path}"' + + def _build_request_body(self, endpoint_info: Dict) -> List[str]: + """Build request body handling.""" + body_params = { + name: info + for name, info in endpoint_info["parameters"].items() + if info["location"] == "body" + } + + if not body_params: + return [] + + lines = [" body: dict[str, Any] = {}"] + + for param_name, param_info in body_params.items(): + sanitized_name = self._sanitize_parameter_name(param_name) + + if param_name in endpoint_info["required"]: + lines.append(f" body['{param_name}'] = {sanitized_name}") + else: + lines.extend([ + f" if {sanitized_name} is not None:", + f" body['{param_name}'] = {sanitized_name}", + ]) + + return lines + + @staticmethod + def _modernize_type(type_str: str) -> str: + """Convert typing-style annotations to modern Python 3.10+ syntax. + + Optional[str] -> str | None, Dict[str, Any] -> dict[str, Any], + List[str] -> list[str], etc. + """ + if type_str.startswith("Optional[") and type_str.endswith("]"): + inner = type_str[len("Optional["):-1] + inner = GreenhouseDataSourceGenerator._modernize_type(inner) + return f"{inner} | None" + if type_str.startswith("Dict["): + inner = type_str[len("Dict["):-1] + parts = GreenhouseDataSourceGenerator._split_type_args(inner) + modernized = ", ".join( + GreenhouseDataSourceGenerator._modernize_type(p.strip()) for p in parts + ) + return f"dict[{modernized}]" + if type_str == "Dict": + return "dict" + if type_str.startswith("List["): + inner = type_str[len("List["):-1] + parts = GreenhouseDataSourceGenerator._split_type_args(inner) + modernized = ", ".join( + GreenhouseDataSourceGenerator._modernize_type(p.strip()) for p in parts + ) + return f"list[{modernized}]" + if type_str == "List": + return "list" + return type_str + + @staticmethod + def _split_type_args(s: str) -> List[str]: + """Split type arguments respecting nested brackets.""" + parts = [] + depth = 0 + current = "" + for ch in s: + if ch == "[": + depth += 1 + elif ch == "]": + depth -= 1 + if ch == "," and depth == 0: + parts.append(current.strip()) + current = "" + else: + current += ch + if current.strip(): + parts.append(current.strip()) + return parts + + def _generate_method_signature(self, method_name: str, endpoint_info: Dict) -> str: + """Generate method signature with explicit parameters.""" + params = ["self"] + + # Collect required params + required_non_bool: List[str] = [] + for param_name in endpoint_info["required"]: + if param_name in endpoint_info["parameters"]: + param_info = endpoint_info["parameters"][param_name] + sanitized_name = self._sanitize_parameter_name(param_name) + modern_type = self._modernize_type(param_info["type"]) + required_non_bool.append(f"{sanitized_name}: {modern_type}") + + # Collect optional parameters + optional_params: List[str] = [] + for param_name, param_info in endpoint_info["parameters"].items(): + if param_name not in endpoint_info["required"]: + sanitized_name = self._sanitize_parameter_name(param_name) + modern_type = self._modernize_type(param_info["type"]) + if "| None" not in modern_type: + modern_type = f"{modern_type} | None" + optional_params.append(f"{sanitized_name}: {modern_type} = None") + + # Build signature: required first, then * separator, then optional + params.extend(required_non_bool) + if optional_params: + params.append("*") + params.extend(optional_params) + + signature_params = ",\n ".join(params) + return f" async def {method_name}(\n {signature_params}\n ) -> GreenhouseResponse:" + + def _generate_method_docstring(self, endpoint_info: Dict) -> List[str]: + """Generate method docstring.""" + lines = [f' """{endpoint_info["description"]}', ""] + + if endpoint_info["parameters"]: + lines.append(" Args:") + for param_name, param_info in endpoint_info["parameters"].items(): + sanitized_name = self._sanitize_parameter_name(param_name) + lines.append( + f" {sanitized_name}: {param_info['description']}" + ) + lines.append("") + + lines.extend([ + " Returns:", + " GreenhouseResponse with operation result", + ' """', + ]) + + return lines + + def _generate_method(self, method_name: str, endpoint_info: Dict) -> str: + """Generate a complete method for an API endpoint.""" + lines = [] + + # Method signature + lines.append(self._generate_method_signature(method_name, endpoint_info)) + + # Docstring + lines.extend(self._generate_method_docstring(endpoint_info)) + + # Query parameters + has_query = any( + info["location"] == "query" + for info in endpoint_info["parameters"].values() + ) + if has_query: + query_lines = self._build_query_params(endpoint_info) + lines.extend(query_lines) + lines.append("") + + # URL construction + lines.append(self._build_path_formatting(endpoint_info["path"], endpoint_info)) + + # Request body + body_lines = self._build_request_body(endpoint_info) + if body_lines: + lines.append("") + lines.extend(body_lines) + + # Request construction and execution + lines.append("") + lines.append(" try:") + lines.append(" request = HTTPRequest(") + lines.append(f' method="{endpoint_info["method"]}",') + lines.append(" url=url,") + lines.append(' headers={"Content-Type": "application/json"},') + if has_query: + lines.append(" query=query_params,") + if body_lines: + lines.append(" body=body,") + lines.append(" )") + lines.extend([ + " response = await self.http.execute(request) # type: ignore[reportUnknownMemberType]", + " response_data = response.json() if response.text() else None", + " return GreenhouseResponse(", + " success=response.status < HTTP_ERROR_THRESHOLD,", + " data=response_data,", + f' message="Successfully executed {method_name}" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {{response.status}}"', + " )", + " except Exception as e:", + f' return GreenhouseResponse(success=False, error=str(e), message="Failed to execute {method_name}")', + ]) + + self.generated_methods.append({ + "name": method_name, + "endpoint": endpoint_info["path"], + "method": endpoint_info["method"], + "description": endpoint_info["description"], + }) + + return "\n".join(lines) + + def generate_greenhouse_datasource(self) -> str: + """Generate the complete Greenhouse datasource class.""" + + class_lines = [ + '"""', + "Greenhouse Harvest REST API DataSource - Auto-generated API wrapper", + "", + "Generated from Greenhouse Harvest API v1 documentation.", + "Uses HTTP client for direct REST API interactions.", + "All methods have explicit parameter signatures.", + '"""', + "", + "from __future__ import annotations", + "", + "from typing import Any", + "", + "from app.sources.client.greenhouse.greenhouse import GreenhouseClient, GreenhouseResponse", + "from app.sources.client.http.http_request import HTTPRequest", + "", + "# HTTP status code constant", + "HTTP_ERROR_THRESHOLD = 400", + "", + "", + "class GreenhouseDataSource:", + ' """Greenhouse Harvest REST API DataSource', + "", + " Provides async wrapper methods for Greenhouse Harvest API operations:", + " - Candidates and Applications", + " - Jobs and Job Stages", + " - Offers", + " - Departments and Offices", + " - Users", + " - Scorecards, Scheduled Interviews", + " - Sources, Rejection Reasons, Custom Fields", + " - Activity Feed", + "", + " The base URL is https://harvest.greenhouse.io/v1.", + "", + " All methods return GreenhouseResponse objects.", + ' """', + "", + " def __init__(self, client: GreenhouseClient) -> None:", + ' """Initialize with GreenhouseClient.', + "", + " Args:", + " client: GreenhouseClient instance with configured authentication", + ' """', + " self._client = client", + " self.http = client.get_client()", + " try:", + " self.base_url = self.http.get_base_url().rstrip('/')", + " except AttributeError as exc:", + " raise ValueError('HTTP client does not have get_base_url method') from exc", + "", + " def get_data_source(self) -> 'GreenhouseDataSource':", + ' """Return the data source instance."""', + " return self", + "", + " def get_client(self) -> GreenhouseClient:", + ' """Return the underlying GreenhouseClient."""', + " return self._client", + "", + ] + + # Generate all API methods + for method_name, endpoint_info in GREENHOUSE_API_ENDPOINTS.items(): + class_lines.append(self._generate_method(method_name, endpoint_info)) + class_lines.append("") + + return "\n".join(class_lines) + + def save_to_file(self, filename: Optional[str] = None) -> None: + """Generate and save the Greenhouse datasource to a file.""" + if filename is None: + filename = "greenhouse.py" + + script_dir = Path(__file__).parent if __file__ else Path(".") + greenhouse_dir = script_dir.parent / "app" / "sources" / "external" / "greenhouse" + greenhouse_dir.mkdir(parents=True, exist_ok=True) + + full_path = greenhouse_dir / filename + + class_code = self.generate_greenhouse_datasource() + + full_path.write_text(class_code, encoding="utf-8") + + print(f"Generated Greenhouse data source with {len(self.generated_methods)} methods") + print(f"Saved to: {full_path}") + + # Print summary by resource + resource_categories = { + "Candidates": 0, + "Applications": 0, + "Jobs": 0, + "Job Stages": 0, + "Offers": 0, + "Departments": 0, + "Offices": 0, + "Users": 0, + "Scorecards": 0, + "Scheduled Interviews": 0, + "Sources": 0, + "Rejection Reasons": 0, + "Custom Fields": 0, + "Activity Feed": 0, + } + + for method in self.generated_methods: + name = method["name"] + if "candidate" in name and "activity" not in name: + resource_categories["Candidates"] += 1 + elif "application" in name: + resource_categories["Applications"] += 1 + elif "job_stage" in name: + resource_categories["Job Stages"] += 1 + elif "job" in name: + resource_categories["Jobs"] += 1 + elif "offer" in name: + resource_categories["Offers"] += 1 + elif "department" in name: + resource_categories["Departments"] += 1 + elif "office" in name: + resource_categories["Offices"] += 1 + elif "user" in name: + resource_categories["Users"] += 1 + elif "scorecard" in name: + resource_categories["Scorecards"] += 1 + elif "interview" in name: + resource_categories["Scheduled Interviews"] += 1 + elif "source" in name: + resource_categories["Sources"] += 1 + elif "rejection" in name: + resource_categories["Rejection Reasons"] += 1 + elif "custom_field" in name: + resource_categories["Custom Fields"] += 1 + elif "activity" in name: + resource_categories["Activity Feed"] += 1 + + print(f"\nMethods by Resource:") + for category, count in resource_categories.items(): + if count > 0: + print(f" - {category}: {count}") + + +def main(): + """Main function for Greenhouse data source generator.""" + import argparse + + parser = argparse.ArgumentParser( + description="Generate Greenhouse Harvest REST API data source" + ) + parser.add_argument("--filename", "-f", help="Output filename (optional)") + + args = parser.parse_args() + + try: + generator = GreenhouseDataSourceGenerator() + generator.save_to_file(args.filename) + return 0 + except Exception as e: + print(f"Failed to generate Greenhouse data source: {e}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/python/code-generator/harvest.py b/backend/python/code-generator/harvest.py new file mode 100644 index 000000000..86c3cb560 --- /dev/null +++ b/backend/python/code-generator/harvest.py @@ -0,0 +1,745 @@ +# ruff: noqa +""" +Harvest REST API Code Generator + +Generates HarvestDataSource class covering Harvest API v2: +- Users and user management +- Time entries CRUD +- Projects and clients +- Tasks, invoices, expenses +- Company info, roles +- Project assignments + +The generated DataSource accepts a HarvestClient and uses its +configured base URL. Methods are generated for all API endpoints. + +All methods have explicit parameter signatures with no **kwargs usage. +""" + +import sys +from pathlib import Path +from typing import Dict, List, Optional + +# ================================================================================ +# Harvest API Endpoints +# +# Each endpoint defines: +# method: HTTP verb +# path: URL path (appended to base_url which is https://api.harvestapp.com/v2) +# description: Human-readable description +# parameters: Dict of param_name -> {type, location (path/query/body), description} +# required: List of required parameter names +# ================================================================================ + +HARVEST_API_ENDPOINTS = { + # ================================================================================ + # USERS + # ================================================================================ + "get_current_user": { + "method": "GET", + "path": "/users/me", + "description": "Get the currently authenticated user", + "parameters": {}, + "required": [], + }, + "list_users": { + "method": "GET", + "path": "/users", + "description": "List all users", + "parameters": { + "is_active": {"type": "Optional[bool]", "location": "query", "description": "Filter by active status"}, + "page": {"type": "Optional[int]", "location": "query", "description": "Page number for pagination"}, + "per_page": {"type": "Optional[int]", "location": "query", "description": "Number of records per page"}, + "updated_since": {"type": "Optional[str]", "location": "query", "description": "Only return users updated since this datetime (ISO 8601)"}, + }, + "required": [], + }, + "get_user": { + "method": "GET", + "path": "/users/{user_id}", + "description": "Get a specific user by ID", + "parameters": { + "user_id": {"type": "str", "location": "path", "description": "The user ID"}, + }, + "required": ["user_id"], + }, + + # ================================================================================ + # TIME ENTRIES + # ================================================================================ + "list_time_entries": { + "method": "GET", + "path": "/time_entries", + "description": "List all time entries", + "parameters": { + "user_id": {"type": "Optional[str]", "location": "query", "description": "Filter by user ID"}, + "client_id_": {"type": "Optional[str]", "location": "query", "description": "Filter by client ID"}, + "project_id": {"type": "Optional[str]", "location": "query", "description": "Filter by project ID"}, + "is_billed": {"type": "Optional[bool]", "location": "query", "description": "Filter by billed status"}, + "is_running": {"type": "Optional[bool]", "location": "query", "description": "Filter by running status"}, + "updated_since": {"type": "Optional[str]", "location": "query", "description": "Only return time entries updated since this datetime (ISO 8601)"}, + "from_": {"type": "Optional[str]", "location": "query", "description": "Start date for filtering (YYYY-MM-DD)"}, + "to_": {"type": "Optional[str]", "location": "query", "description": "End date for filtering (YYYY-MM-DD)"}, + "page": {"type": "Optional[int]", "location": "query", "description": "Page number for pagination"}, + "per_page": {"type": "Optional[int]", "location": "query", "description": "Number of records per page"}, + }, + "required": [], + }, + "get_time_entry": { + "method": "GET", + "path": "/time_entries/{time_entry_id}", + "description": "Get a specific time entry by ID", + "parameters": { + "time_entry_id": {"type": "str", "location": "path", "description": "The time entry ID"}, + }, + "required": ["time_entry_id"], + }, + "create_time_entry": { + "method": "POST", + "path": "/time_entries", + "description": "Create a new time entry", + "parameters": { + "body": {"type": "dict[str, Any]", "location": "body", "description": "Time entry data (project_id, task_id, spent_date, etc.)"}, + }, + "required": ["body"], + }, + "update_time_entry": { + "method": "PATCH", + "path": "/time_entries/{time_entry_id}", + "description": "Update an existing time entry", + "parameters": { + "time_entry_id": {"type": "str", "location": "path", "description": "The time entry ID"}, + "body": {"type": "dict[str, Any]", "location": "body", "description": "Time entry fields to update"}, + }, + "required": ["time_entry_id", "body"], + }, + "delete_time_entry": { + "method": "DELETE", + "path": "/time_entries/{time_entry_id}", + "description": "Delete a time entry", + "parameters": { + "time_entry_id": {"type": "str", "location": "path", "description": "The time entry ID"}, + }, + "required": ["time_entry_id"], + }, + + # ================================================================================ + # PROJECTS + # ================================================================================ + "list_projects": { + "method": "GET", + "path": "/projects", + "description": "List all projects", + "parameters": { + "is_active": {"type": "Optional[bool]", "location": "query", "description": "Filter by active status"}, + "client_id_": {"type": "Optional[str]", "location": "query", "description": "Filter by client ID"}, + "updated_since": {"type": "Optional[str]", "location": "query", "description": "Only return projects updated since this datetime (ISO 8601)"}, + "page": {"type": "Optional[int]", "location": "query", "description": "Page number for pagination"}, + "per_page": {"type": "Optional[int]", "location": "query", "description": "Number of records per page"}, + }, + "required": [], + }, + "get_project": { + "method": "GET", + "path": "/projects/{project_id}", + "description": "Get a specific project by ID", + "parameters": { + "project_id": {"type": "str", "location": "path", "description": "The project ID"}, + }, + "required": ["project_id"], + }, + + # ================================================================================ + # CLIENTS + # ================================================================================ + "list_clients": { + "method": "GET", + "path": "/clients", + "description": "List all clients", + "parameters": { + "is_active": {"type": "Optional[bool]", "location": "query", "description": "Filter by active status"}, + "updated_since": {"type": "Optional[str]", "location": "query", "description": "Only return clients updated since this datetime (ISO 8601)"}, + "page": {"type": "Optional[int]", "location": "query", "description": "Page number for pagination"}, + "per_page": {"type": "Optional[int]", "location": "query", "description": "Number of records per page"}, + }, + "required": [], + }, + "get_client_by_id": { + "method": "GET", + "path": "/clients/{client_id_param}", + "description": "Get a specific client by ID", + "parameters": { + "client_id_param": {"type": "str", "location": "path", "description": "The client ID"}, + }, + "required": ["client_id_param"], + }, + + # ================================================================================ + # TASKS + # ================================================================================ + "list_tasks": { + "method": "GET", + "path": "/tasks", + "description": "List all tasks", + "parameters": { + "is_active": {"type": "Optional[bool]", "location": "query", "description": "Filter by active status"}, + "updated_since": {"type": "Optional[str]", "location": "query", "description": "Only return tasks updated since this datetime (ISO 8601)"}, + "page": {"type": "Optional[int]", "location": "query", "description": "Page number for pagination"}, + "per_page": {"type": "Optional[int]", "location": "query", "description": "Number of records per page"}, + }, + "required": [], + }, + "get_task": { + "method": "GET", + "path": "/tasks/{task_id}", + "description": "Get a specific task by ID", + "parameters": { + "task_id": {"type": "str", "location": "path", "description": "The task ID"}, + }, + "required": ["task_id"], + }, + + # ================================================================================ + # INVOICES + # ================================================================================ + "list_invoices": { + "method": "GET", + "path": "/invoices", + "description": "List all invoices", + "parameters": { + "client_id_": {"type": "Optional[str]", "location": "query", "description": "Filter by client ID"}, + "project_id": {"type": "Optional[str]", "location": "query", "description": "Filter by project ID"}, + "updated_since": {"type": "Optional[str]", "location": "query", "description": "Only return invoices updated since this datetime (ISO 8601)"}, + "from_": {"type": "Optional[str]", "location": "query", "description": "Start date for filtering (YYYY-MM-DD)"}, + "to_": {"type": "Optional[str]", "location": "query", "description": "End date for filtering (YYYY-MM-DD)"}, + "state": {"type": "Optional[str]", "location": "query", "description": "Filter by invoice state (draft, open, paid, closed)"}, + "page": {"type": "Optional[int]", "location": "query", "description": "Page number for pagination"}, + "per_page": {"type": "Optional[int]", "location": "query", "description": "Number of records per page"}, + }, + "required": [], + }, + "get_invoice": { + "method": "GET", + "path": "/invoices/{invoice_id}", + "description": "Get a specific invoice by ID", + "parameters": { + "invoice_id": {"type": "str", "location": "path", "description": "The invoice ID"}, + }, + "required": ["invoice_id"], + }, + + # ================================================================================ + # EXPENSES + # ================================================================================ + "list_expenses": { + "method": "GET", + "path": "/expenses", + "description": "List all expenses", + "parameters": { + "user_id": {"type": "Optional[str]", "location": "query", "description": "Filter by user ID"}, + "client_id_": {"type": "Optional[str]", "location": "query", "description": "Filter by client ID"}, + "project_id": {"type": "Optional[str]", "location": "query", "description": "Filter by project ID"}, + "is_billed": {"type": "Optional[bool]", "location": "query", "description": "Filter by billed status"}, + "updated_since": {"type": "Optional[str]", "location": "query", "description": "Only return expenses updated since this datetime (ISO 8601)"}, + "from_": {"type": "Optional[str]", "location": "query", "description": "Start date for filtering (YYYY-MM-DD)"}, + "to_": {"type": "Optional[str]", "location": "query", "description": "End date for filtering (YYYY-MM-DD)"}, + "page": {"type": "Optional[int]", "location": "query", "description": "Page number for pagination"}, + "per_page": {"type": "Optional[int]", "location": "query", "description": "Number of records per page"}, + }, + "required": [], + }, + "get_expense": { + "method": "GET", + "path": "/expenses/{expense_id}", + "description": "Get a specific expense by ID", + "parameters": { + "expense_id": {"type": "str", "location": "path", "description": "The expense ID"}, + }, + "required": ["expense_id"], + }, + + # ================================================================================ + # COMPANY + # ================================================================================ + "get_company": { + "method": "GET", + "path": "/company", + "description": "Get the company information for the authenticated user's account", + "parameters": {}, + "required": [], + }, + + # ================================================================================ + # ROLES + # ================================================================================ + "list_roles": { + "method": "GET", + "path": "/roles", + "description": "List all roles", + "parameters": { + "page": {"type": "Optional[int]", "location": "query", "description": "Page number for pagination"}, + "per_page": {"type": "Optional[int]", "location": "query", "description": "Number of records per page"}, + }, + "required": [], + }, + + # ================================================================================ + # PROJECT ASSIGNMENTS + # ================================================================================ + "list_project_assignments": { + "method": "GET", + "path": "/project_assignments", + "description": "List project assignments for the currently authenticated user", + "parameters": { + "page": {"type": "Optional[int]", "location": "query", "description": "Page number for pagination"}, + "per_page": {"type": "Optional[int]", "location": "query", "description": "Number of records per page"}, + "updated_since": {"type": "Optional[str]", "location": "query", "description": "Only return assignments updated since this datetime (ISO 8601)"}, + }, + "required": [], + }, + "list_user_project_assignments": { + "method": "GET", + "path": "/users/{user_id}/project_assignments", + "description": "List project assignments for a specific user", + "parameters": { + "user_id": {"type": "str", "location": "path", "description": "The user ID"}, + "page": {"type": "Optional[int]", "location": "query", "description": "Page number for pagination"}, + "per_page": {"type": "Optional[int]", "location": "query", "description": "Number of records per page"}, + "updated_since": {"type": "Optional[str]", "location": "query", "description": "Only return assignments updated since this datetime (ISO 8601)"}, + }, + "required": ["user_id"], + }, +} + + +class HarvestDataSourceGenerator: + """Generator for comprehensive Harvest REST API datasource class. + + Generates methods for Harvest API v2 endpoints. + The generated DataSource class accepts a HarvestClient whose + base URL is configured at initialization. + """ + + def __init__(self): + self.generated_methods: List[Dict[str, str]] = [] + + def _sanitize_parameter_name(self, name: str) -> str: + """Sanitize parameter names to be valid Python identifiers.""" + sanitized = name.replace("-", "_").replace(".", "_").replace("/", "_") + if sanitized and not (sanitized[0].isalpha() or sanitized[0] == "_"): + sanitized = f"param_{sanitized}" + return sanitized + + def _build_query_params(self, endpoint_info: Dict) -> List[str]: + """Build query parameter handling code.""" + lines = [" query_params: dict[str, Any] = {}"] + + for param_name, param_info in endpoint_info["parameters"].items(): + if param_info["location"] == "query": + sanitized_name = self._sanitize_parameter_name(param_name) + # Map Python parameter names with trailing underscores to API names + api_name = param_name + if api_name == "client_id_": + api_name = "client_id" + elif api_name == "from_": + api_name = "from" + elif api_name == "to_": + api_name = "to" + + if "Optional[bool]" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{api_name}'] = str({sanitized_name}).lower()", + ]) + elif "Optional[int]" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{api_name}'] = str({sanitized_name})", + ]) + else: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{api_name}'] = {sanitized_name}", + ]) + + return lines + + def _build_path_formatting(self, path: str, endpoint_info: Dict) -> str: + """Build URL path with parameter substitution.""" + path_params = [ + name + for name, info in endpoint_info["parameters"].items() + if info["location"] == "path" + ] + + if path_params: + format_dict = ", ".join( + f"{param}={self._sanitize_parameter_name(param)}" + for param in path_params + ) + return f' url = self.base_url + "{path}".format({format_dict})' + else: + return f' url = self.base_url + "{path}"' + + def _build_request_body(self, endpoint_info: Dict) -> List[str]: + """Build request body handling.""" + body_params = { + name: info + for name, info in endpoint_info["parameters"].items() + if info["location"] == "body" + } + + if not body_params: + return [] + + # For Harvest, we pass the body dict directly + if "body" in body_params: + return [] # body is passed directly in the request + + lines = [" body: dict[str, Any] = {}"] + + for param_name, param_info in body_params.items(): + sanitized_name = self._sanitize_parameter_name(param_name) + + if param_name in endpoint_info["required"]: + lines.append(f" body['{param_name}'] = {sanitized_name}") + else: + lines.extend([ + f" if {sanitized_name} is not None:", + f" body['{param_name}'] = {sanitized_name}", + ]) + + return lines + + @staticmethod + def _modernize_type(type_str: str) -> str: + """Convert typing-style annotations to modern Python 3.10+ syntax. + + Optional[str] -> str | None, Dict[str, Any] -> dict[str, Any], + List[str] -> list[str], etc. + """ + if type_str.startswith("Optional[") and type_str.endswith("]"): + inner = type_str[len("Optional["):-1] + inner = HarvestDataSourceGenerator._modernize_type(inner) + return f"{inner} | None" + if type_str.startswith("Dict["): + inner = type_str[len("Dict["):-1] + parts = HarvestDataSourceGenerator._split_type_args(inner) + modernized = ", ".join( + HarvestDataSourceGenerator._modernize_type(p.strip()) for p in parts + ) + return f"dict[{modernized}]" + if type_str == "Dict": + return "dict" + if type_str.startswith("List["): + inner = type_str[len("List["):-1] + parts = HarvestDataSourceGenerator._split_type_args(inner) + modernized = ", ".join( + HarvestDataSourceGenerator._modernize_type(p.strip()) for p in parts + ) + return f"list[{modernized}]" + if type_str == "List": + return "list" + return type_str + + @staticmethod + def _split_type_args(s: str) -> List[str]: + """Split type arguments respecting nested brackets.""" + parts = [] + depth = 0 + current = "" + for ch in s: + if ch == "[": + depth += 1 + elif ch == "]": + depth -= 1 + if ch == "," and depth == 0: + parts.append(current.strip()) + current = "" + else: + current += ch + if current.strip(): + parts.append(current.strip()) + return parts + + def _generate_method_signature(self, method_name: str, endpoint_info: Dict) -> str: + """Generate method signature with explicit parameters.""" + params = ["self"] + has_any_bool = False + + # Collect required params, split into non-bool and bool groups + required_non_bool: List[str] = [] + required_bool: List[str] = [] + for param_name in endpoint_info["required"]: + if param_name in endpoint_info["parameters"]: + param_info = endpoint_info["parameters"][param_name] + sanitized_name = self._sanitize_parameter_name(param_name) + modern_type = self._modernize_type(param_info["type"]) + param_str = f"{sanitized_name}: {modern_type}" + if "bool" in param_info.get("type", ""): + required_bool.append(param_str) + has_any_bool = True + else: + required_non_bool.append(param_str) + + # Collect optional parameters + optional_params: List[str] = [] + for param_name, param_info in endpoint_info["parameters"].items(): + if param_name not in endpoint_info["required"]: + sanitized_name = self._sanitize_parameter_name(param_name) + modern_type = self._modernize_type(param_info["type"]) + if "| None" not in modern_type: + modern_type = f"{modern_type} | None" + optional_params.append(f"{sanitized_name}: {modern_type} = None") + if "bool" in param_info.get("type", ""): + has_any_bool = True + + # Build signature: non-bool required first, then * if needed, then bool required + optional + params.extend(required_non_bool) + if has_any_bool and (required_bool or optional_params): + params.append("*") + params.extend(required_bool) + params.extend(optional_params) + + signature_params = ",\n ".join(params) + return f" async def {method_name}(\n {signature_params}\n ) -> HarvestResponse:" + + def _generate_method_docstring(self, endpoint_info: Dict) -> List[str]: + """Generate method docstring.""" + lines = [f' """{endpoint_info["description"]}', ""] + + if endpoint_info["parameters"]: + lines.append(" Args:") + for param_name, param_info in endpoint_info["parameters"].items(): + sanitized_name = self._sanitize_parameter_name(param_name) + lines.append( + f" {sanitized_name}: {param_info['description']}" + ) + lines.append("") + + lines.extend([ + " Returns:", + " HarvestResponse with operation result", + ' """', + ]) + + return lines + + def _generate_method(self, method_name: str, endpoint_info: Dict) -> str: + """Generate a complete method for an API endpoint.""" + lines = [] + + # Method signature + lines.append(self._generate_method_signature(method_name, endpoint_info)) + + # Docstring + lines.extend(self._generate_method_docstring(endpoint_info)) + + # Query parameters + has_query = any( + info["location"] == "query" + for info in endpoint_info["parameters"].values() + ) + if has_query: + query_lines = self._build_query_params(endpoint_info) + lines.extend(query_lines) + lines.append("") + + # URL construction + lines.append(self._build_path_formatting(endpoint_info["path"], endpoint_info)) + + # Request body + body_lines = self._build_request_body(endpoint_info) + has_body_param = "body" in endpoint_info["parameters"] and endpoint_info["parameters"]["body"]["location"] == "body" + if body_lines: + lines.append("") + lines.extend(body_lines) + + # Request construction and execution + lines.append("") + lines.append(" try:") + lines.append(" request = HTTPRequest(") + lines.append(f' method="{endpoint_info["method"]}",') + lines.append(" url=url,") + lines.append(' headers={"Content-Type": "application/json"},') + if has_query: + lines.append(" query=query_params,") + if has_body_param: + lines.append(" body=body,") + elif body_lines: + lines.append(" body=body,") + lines.append(" )") + lines.extend([ + " response = await self.http.execute(request) # type: ignore[reportUnknownMemberType]", + " response_data = response.json() if response.text() else None", + " return HarvestResponse(", + " success=response.status < HTTP_ERROR_THRESHOLD,", + " data=response_data,", + f' message="Successfully executed {method_name}" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {{response.status}}"', + " )", + " except Exception as e:", + f' return HarvestResponse(success=False, error=str(e), message="Failed to execute {method_name}")', + ]) + + self.generated_methods.append({ + "name": method_name, + "endpoint": endpoint_info["path"], + "method": endpoint_info["method"], + "description": endpoint_info["description"], + }) + + return "\n".join(lines) + + def generate_harvest_datasource(self) -> str: + """Generate the complete Harvest datasource class.""" + + class_lines = [ + '"""', + "Harvest REST API DataSource - Auto-generated API wrapper", + "", + "Generated from Harvest REST API v2 documentation.", + "Uses HTTP client for direct REST API interactions.", + "All methods have explicit parameter signatures.", + '"""', + "", + "from __future__ import annotations", + "", + "from typing import Any", + "", + "from app.sources.client.harvest.harvest import HarvestClient, HarvestResponse", + "from app.sources.client.http.http_request import HTTPRequest", + "", + "# HTTP status code constant", + "HTTP_ERROR_THRESHOLD = 400", + "", + "", + "class HarvestDataSource:", + ' """Harvest REST API DataSource', + "", + " Provides async wrapper methods for Harvest REST API operations:", + " - Users and user management", + " - Time entries CRUD", + " - Projects and clients", + " - Tasks, invoices, expenses", + " - Company info, roles", + " - Project assignments", + "", + " All requests require a Harvest-Account-Id header, which is set", + " by the HarvestClient during initialization.", + "", + " All methods return HarvestResponse objects.", + ' """', + "", + " def __init__(self, client: HarvestClient) -> None:", + ' """Initialize with HarvestClient.', + "", + " Args:", + " client: HarvestClient instance with configured authentication", + ' """', + " self._client = client", + " self.http = client.get_client()", + " try:", + " self.base_url = self.http.get_base_url().rstrip('/')", + " except AttributeError as exc:", + " raise ValueError('HTTP client does not have get_base_url method') from exc", + "", + " def get_data_source(self) -> 'HarvestDataSource':", + ' """Return the data source instance."""', + " return self", + "", + " def get_client(self) -> HarvestClient:", + ' """Return the underlying HarvestClient."""', + " return self._client", + "", + ] + + # Generate all API methods + for method_name, endpoint_info in HARVEST_API_ENDPOINTS.items(): + class_lines.append(self._generate_method(method_name, endpoint_info)) + class_lines.append("") + + return "\n".join(class_lines) + + def save_to_file(self, filename: Optional[str] = None) -> None: + """Generate and save the Harvest datasource to a file.""" + if filename is None: + filename = "harvest.py" + + script_dir = Path(__file__).parent if __file__ else Path(".") + harvest_dir = script_dir.parent / "app" / "sources" / "external" / "harvest" + harvest_dir.mkdir(parents=True, exist_ok=True) + + full_path = harvest_dir / filename + + class_code = self.generate_harvest_datasource() + + full_path.write_text(class_code, encoding="utf-8") + + print(f"Generated Harvest data source with {len(self.generated_methods)} methods") + print(f"Saved to: {full_path}") + + # Print summary by resource category + resource_categories = { + "Users": 0, + "Time Entries": 0, + "Projects": 0, + "Clients": 0, + "Tasks": 0, + "Invoices": 0, + "Expenses": 0, + "Company": 0, + "Roles": 0, + "Project Assignments": 0, + } + + for method in self.generated_methods: + name = method["name"] + if "user" in name and "assignment" not in name: + resource_categories["Users"] += 1 + elif "time_entr" in name: + resource_categories["Time Entries"] += 1 + elif "project" in name and "assignment" not in name: + resource_categories["Projects"] += 1 + elif "client" in name: + resource_categories["Clients"] += 1 + elif "task" in name: + resource_categories["Tasks"] += 1 + elif "invoice" in name: + resource_categories["Invoices"] += 1 + elif "expense" in name: + resource_categories["Expenses"] += 1 + elif "company" in name: + resource_categories["Company"] += 1 + elif "role" in name: + resource_categories["Roles"] += 1 + elif "assignment" in name: + resource_categories["Project Assignments"] += 1 + + print(f"\nMethods by Resource:") + for category, count in resource_categories.items(): + if count > 0: + print(f" - {category}: {count}") + + +def main(): + """Main function for Harvest data source generator.""" + import argparse + + parser = argparse.ArgumentParser( + description="Generate Harvest REST API data source" + ) + parser.add_argument("--filename", "-f", help="Output filename (optional)") + + args = parser.parse_args() + + try: + generator = HarvestDataSourceGenerator() + generator.save_to_file(args.filename) + return 0 + except Exception as e: + print(f"Failed to generate Harvest data source: {e}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/python/code-generator/hubspot.py b/backend/python/code-generator/hubspot.py new file mode 100644 index 000000000..41d155ccf --- /dev/null +++ b/backend/python/code-generator/hubspot.py @@ -0,0 +1,501 @@ +# ruff: noqa +""" +HubSpot CRM SDK Code Generator + +Generates ``HubSpotDataSource`` class that wraps the official ``hubspot-api-client`` +Python SDK. Each method calls the SDK directly and wraps the result in a +``HubSpotResponse``. + +Pattern follows the GitLab code-generator approach: a list of (signature, body, +docstring) tuples that are emitted into a single class. + +SDK reference (hubspot-api-client): +- Contacts: client.crm.contacts.basic_api.get_page / get_by_id / create / update / archive +- Companies: client.crm.companies.basic_api.* +- Deals: client.crm.deals.basic_api.* +- Tickets: client.crm.tickets.basic_api.* +- Notes: client.crm.objects.basic_api.* (object_type="notes") +- Pipelines: client.crm.pipelines.pipelines_api.get_all / get_by_id +- Properties: client.crm.properties.core_api.get_all +- Owners: client.crm.owners.owners_api.get_page / get_by_id +- Search: client.crm..search_api.do_search + +Usage: + cd backend/python + python code-generator/hubspot.py + +Output: + app/sources/external/hubspot/hubspot.py +""" +from __future__ import annotations + +import argparse +import textwrap +from pathlib import Path +from typing import List, Tuple + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +DEFAULT_RESPONSE_IMPORT = ( + "from app.sources.client.hubspot.hubspot import HubSpotResponse" +) +DEFAULT_CLASS_NAME = "HubSpotDataSource" +DEFAULT_OUT = str( + Path(__file__).resolve().parent.parent + / "app" + / "sources" + / "external" + / "hubspot" + / "hubspot.py" +) + +# --------------------------------------------------------------------------- +# Header / Footer templates +# --------------------------------------------------------------------------- + +HEADER = '''\ +# ruff: noqa +""" +HubSpot CRM SDK DataSource - Auto-generated wrapper + +Generated from hubspot-api-client Python SDK. +All methods call the SDK directly and wrap results in HubSpotResponse. +""" +from __future__ import annotations + +from typing import Any, Optional + +from hubspot.crm.contacts import SimplePublicObjectInputForCreate as ContactCreateInput # type: ignore[import-untyped] +from hubspot.crm.contacts import SimplePublicObjectInput as ContactUpdateInput # type: ignore[import-untyped] +from hubspot.crm.companies import SimplePublicObjectInputForCreate as CompanyCreateInput # type: ignore[import-untyped] +from hubspot.crm.companies import SimplePublicObjectInput as CompanyUpdateInput # type: ignore[import-untyped] +from hubspot.crm.deals import SimplePublicObjectInputForCreate as DealCreateInput # type: ignore[import-untyped] +from hubspot.crm.deals import SimplePublicObjectInput as DealUpdateInput # type: ignore[import-untyped] +from hubspot.crm.tickets import SimplePublicObjectInputForCreate as TicketCreateInput # type: ignore[import-untyped] +from hubspot.crm.objects.notes import SimplePublicObjectInputForCreate as NoteCreateInput # type: ignore[import-untyped] +from hubspot.crm.contacts import PublicObjectSearchRequest as ContactSearchRequest # type: ignore[import-untyped] +from hubspot.crm.companies import PublicObjectSearchRequest as CompanySearchRequest # type: ignore[import-untyped] +from hubspot.crm.deals import PublicObjectSearchRequest as DealSearchRequest # type: ignore[import-untyped] + +{response_import} + + +def _to_dict(obj: object) -> Any: + """Convert an SDK response object to a plain dict/list.""" + if hasattr(obj, "to_dict"): + return obj.to_dict() # type: ignore[reportUnknownMemberType] + return obj + + +class {class_name}: + """HubSpot CRM SDK DataSource + + Typed wrapper around the official ``hubspot-api-client`` SDK for common + CRM operations: + - Contacts CRUD and search + - Companies CRUD and search + - Deals CRUD and search + - Tickets CRUD + - Notes/Engagements CRUD + - Pipelines and pipeline stages + - Properties management + - Owners management + + Accepts a ``HubSpotClient`` (which exposes ``.get_sdk() -> HubSpot``) or + a raw ``HubSpot`` SDK instance. + + All methods return ``HubSpotResponse`` objects. + """ + + def __init__(self, client_or_sdk: object) -> None: + """Initialize with a HubSpotClient or raw HubSpot SDK instance. + + Args: + client_or_sdk: A ``HubSpotClient`` with ``.get_sdk()`` or a + ``HubSpot`` instance directly. + """ + if hasattr(client_or_sdk, "get_sdk"): + self._sdk: Any = client_or_sdk.get_sdk() # type: ignore[reportUnknownMemberType] + else: + self._sdk = client_or_sdk + + def get_data_source(self) -> "{class_name}": + """Return the data source instance.""" + return self + +''' + +FOOTER = "" + +# --------------------------------------------------------------------------- +# Method definitions: (signature, body, docstring) +# --------------------------------------------------------------------------- + +METHODS: List[Tuple[str, str, str]] = [] + +# ========== CONTACTS ========== +METHODS += [ + ( + "list_contacts(self, limit: int = 10, after: Optional[str] = None, properties: Optional[list[str]] = None, archived: bool = False) -> HubSpotResponse", + " kwargs: dict[str, Any] = {'limit': limit, 'archived': archived}\n" + " if after is not None:\n" + " kwargs['after'] = after\n" + " if properties is not None:\n" + " kwargs['properties'] = properties\n" + " result = self._sdk.crm.contacts.basic_api.get_page(**kwargs)\n" + " return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully listed contacts')", + "List contacts with pagination and optional property selection.", + ), + ( + "get_contact(self, contact_id: str, properties: Optional[list[str]] = None, archived: bool = False) -> HubSpotResponse", + " kwargs: dict[str, Any] = {'contact_id': contact_id, 'archived': archived}\n" + " if properties is not None:\n" + " kwargs['properties'] = properties\n" + " result = self._sdk.crm.contacts.basic_api.get_by_id(**kwargs)\n" + " return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully retrieved contact')", + "Get a single contact by ID.", + ), + ( + "create_contact(self, properties: dict[str, str], associations: Optional[list[dict[str, Any]]] = None) -> HubSpotResponse", + " body = ContactCreateInput(properties=properties, associations=associations or []) # type: ignore[reportUnknownVariableType]\n" + " result = self._sdk.crm.contacts.basic_api.create(simple_public_object_input_for_create=body)\n" + " return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully created contact')", + "Create a new contact with the given properties.", + ), + ( + "update_contact(self, contact_id: str, properties: dict[str, str]) -> HubSpotResponse", + " body = ContactUpdateInput(properties=properties) # type: ignore[reportUnknownVariableType]\n" + " result = self._sdk.crm.contacts.basic_api.update(contact_id=contact_id, simple_public_object_input=body)\n" + " return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully updated contact')", + "Update an existing contact's properties.", + ), + ( + "delete_contact(self, contact_id: str) -> HubSpotResponse", + " self._sdk.crm.contacts.basic_api.archive(contact_id=contact_id)\n" + " return HubSpotResponse(success=True, data={'archived': True}, message='Successfully archived contact')", + "Archive (soft-delete) a contact by ID.", + ), + ( + "search_contacts(self, filter_groups: Optional[list[dict[str, Any]]] = None, query: Optional[str] = None, properties: Optional[list[str]] = None, sorts: Optional[list[dict[str, Any]]] = None, limit: int = 10, after: int = 0) -> HubSpotResponse", + " request_body: dict[str, Any] = {'limit': limit, 'after': after}\n" + " if filter_groups is not None:\n" + " request_body['filter_groups'] = filter_groups\n" + " if query is not None:\n" + " request_body['query'] = query\n" + " if properties is not None:\n" + " request_body['properties'] = properties\n" + " if sorts is not None:\n" + " request_body['sorts'] = sorts\n" + " search_request = ContactSearchRequest(**request_body) # type: ignore[reportUnknownVariableType]\n" + " result = self._sdk.crm.contacts.search_api.do_search(public_object_search_request=search_request)\n" + " return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully searched contacts')", + "Search contacts using filter groups, query string, and sorting.", + ), +] + +# ========== COMPANIES ========== +METHODS += [ + ( + "list_companies(self, limit: int = 10, after: Optional[str] = None, properties: Optional[list[str]] = None, archived: bool = False) -> HubSpotResponse", + " kwargs: dict[str, Any] = {'limit': limit, 'archived': archived}\n" + " if after is not None:\n" + " kwargs['after'] = after\n" + " if properties is not None:\n" + " kwargs['properties'] = properties\n" + " result = self._sdk.crm.companies.basic_api.get_page(**kwargs)\n" + " return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully listed companies')", + "List companies with pagination and optional property selection.", + ), + ( + "get_company(self, company_id: str, properties: Optional[list[str]] = None, archived: bool = False) -> HubSpotResponse", + " kwargs: dict[str, Any] = {'company_id': company_id, 'archived': archived}\n" + " if properties is not None:\n" + " kwargs['properties'] = properties\n" + " result = self._sdk.crm.companies.basic_api.get_by_id(**kwargs)\n" + " return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully retrieved company')", + "Get a single company by ID.", + ), + ( + "create_company(self, properties: dict[str, str], associations: Optional[list[dict[str, Any]]] = None) -> HubSpotResponse", + " body = CompanyCreateInput(properties=properties, associations=associations or []) # type: ignore[reportUnknownVariableType]\n" + " result = self._sdk.crm.companies.basic_api.create(simple_public_object_input_for_create=body)\n" + " return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully created company')", + "Create a new company with the given properties.", + ), + ( + "update_company(self, company_id: str, properties: dict[str, str]) -> HubSpotResponse", + " body = CompanyUpdateInput(properties=properties) # type: ignore[reportUnknownVariableType]\n" + " result = self._sdk.crm.companies.basic_api.update(company_id=company_id, simple_public_object_input=body)\n" + " return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully updated company')", + "Update an existing company's properties.", + ), + ( + "search_companies(self, filter_groups: Optional[list[dict[str, Any]]] = None, query: Optional[str] = None, properties: Optional[list[str]] = None, sorts: Optional[list[dict[str, Any]]] = None, limit: int = 10, after: int = 0) -> HubSpotResponse", + " request_body: dict[str, Any] = {'limit': limit, 'after': after}\n" + " if filter_groups is not None:\n" + " request_body['filter_groups'] = filter_groups\n" + " if query is not None:\n" + " request_body['query'] = query\n" + " if properties is not None:\n" + " request_body['properties'] = properties\n" + " if sorts is not None:\n" + " request_body['sorts'] = sorts\n" + " search_request = CompanySearchRequest(**request_body) # type: ignore[reportUnknownVariableType]\n" + " result = self._sdk.crm.companies.search_api.do_search(public_object_search_request=search_request)\n" + " return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully searched companies')", + "Search companies using filter groups, query string, and sorting.", + ), +] + +# ========== DEALS ========== +METHODS += [ + ( + "list_deals(self, limit: int = 10, after: Optional[str] = None, properties: Optional[list[str]] = None, archived: bool = False) -> HubSpotResponse", + " kwargs: dict[str, Any] = {'limit': limit, 'archived': archived}\n" + " if after is not None:\n" + " kwargs['after'] = after\n" + " if properties is not None:\n" + " kwargs['properties'] = properties\n" + " result = self._sdk.crm.deals.basic_api.get_page(**kwargs)\n" + " return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully listed deals')", + "List deals with pagination and optional property selection.", + ), + ( + "get_deal(self, deal_id: str, properties: Optional[list[str]] = None, archived: bool = False) -> HubSpotResponse", + " kwargs: dict[str, Any] = {'deal_id': deal_id, 'archived': archived}\n" + " if properties is not None:\n" + " kwargs['properties'] = properties\n" + " result = self._sdk.crm.deals.basic_api.get_by_id(**kwargs)\n" + " return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully retrieved deal')", + "Get a single deal by ID.", + ), + ( + "create_deal(self, properties: dict[str, str], associations: Optional[list[dict[str, Any]]] = None) -> HubSpotResponse", + " body = DealCreateInput(properties=properties, associations=associations or []) # type: ignore[reportUnknownVariableType]\n" + " result = self._sdk.crm.deals.basic_api.create(simple_public_object_input_for_create=body)\n" + " return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully created deal')", + "Create a new deal with the given properties.", + ), + ( + "update_deal(self, deal_id: str, properties: dict[str, str]) -> HubSpotResponse", + " body = DealUpdateInput(properties=properties) # type: ignore[reportUnknownVariableType]\n" + " result = self._sdk.crm.deals.basic_api.update(deal_id=deal_id, simple_public_object_input=body)\n" + " return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully updated deal')", + "Update an existing deal's properties.", + ), + ( + "search_deals(self, filter_groups: Optional[list[dict[str, Any]]] = None, query: Optional[str] = None, properties: Optional[list[str]] = None, sorts: Optional[list[dict[str, Any]]] = None, limit: int = 10, after: int = 0) -> HubSpotResponse", + " request_body: dict[str, Any] = {'limit': limit, 'after': after}\n" + " if filter_groups is not None:\n" + " request_body['filter_groups'] = filter_groups\n" + " if query is not None:\n" + " request_body['query'] = query\n" + " if properties is not None:\n" + " request_body['properties'] = properties\n" + " if sorts is not None:\n" + " request_body['sorts'] = sorts\n" + " search_request = DealSearchRequest(**request_body) # type: ignore[reportUnknownVariableType]\n" + " result = self._sdk.crm.deals.search_api.do_search(public_object_search_request=search_request)\n" + " return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully searched deals')", + "Search deals using filter groups, query string, and sorting.", + ), +] + +# ========== TICKETS ========== +METHODS += [ + ( + "list_tickets(self, limit: int = 10, after: Optional[str] = None, properties: Optional[list[str]] = None, archived: bool = False) -> HubSpotResponse", + " kwargs: dict[str, Any] = {'limit': limit, 'archived': archived}\n" + " if after is not None:\n" + " kwargs['after'] = after\n" + " if properties is not None:\n" + " kwargs['properties'] = properties\n" + " result = self._sdk.crm.tickets.basic_api.get_page(**kwargs)\n" + " return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully listed tickets')", + "List tickets with pagination and optional property selection.", + ), + ( + "get_ticket(self, ticket_id: str, properties: Optional[list[str]] = None, archived: bool = False) -> HubSpotResponse", + " kwargs: dict[str, Any] = {'ticket_id': ticket_id, 'archived': archived}\n" + " if properties is not None:\n" + " kwargs['properties'] = properties\n" + " result = self._sdk.crm.tickets.basic_api.get_by_id(**kwargs)\n" + " return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully retrieved ticket')", + "Get a single ticket by ID.", + ), + ( + "create_ticket(self, properties: dict[str, str], associations: Optional[list[dict[str, Any]]] = None) -> HubSpotResponse", + " body = TicketCreateInput(properties=properties, associations=associations or []) # type: ignore[reportUnknownVariableType]\n" + " result = self._sdk.crm.tickets.basic_api.create(simple_public_object_input_for_create=body)\n" + " return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully created ticket')", + "Create a new ticket with the given properties.", + ), +] + +# ========== NOTES / ENGAGEMENTS ========== +METHODS += [ + ( + "list_notes(self, limit: int = 10, after: Optional[str] = None, properties: Optional[list[str]] = None, archived: bool = False) -> HubSpotResponse", + " kwargs: dict[str, Any] = {'limit': limit, 'archived': archived}\n" + " if after is not None:\n" + " kwargs['after'] = after\n" + " if properties is not None:\n" + " kwargs['properties'] = properties\n" + " result = self._sdk.crm.objects.notes.basic_api.get_page(**kwargs)\n" + " return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully listed notes')", + "List notes/engagements with pagination.", + ), + ( + "get_note(self, note_id: str, properties: Optional[list[str]] = None, archived: bool = False) -> HubSpotResponse", + " kwargs: dict[str, Any] = {'note_id': note_id, 'archived': archived}\n" + " if properties is not None:\n" + " kwargs['properties'] = properties\n" + " result = self._sdk.crm.objects.notes.basic_api.get_by_id(**kwargs)\n" + " return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully retrieved note')", + "Get a single note/engagement by ID.", + ), + ( + "create_note(self, properties: dict[str, str], associations: Optional[list[dict[str, Any]]] = None) -> HubSpotResponse", + " body = NoteCreateInput(properties=properties, associations=associations or []) # type: ignore[reportUnknownVariableType]\n" + " result = self._sdk.crm.objects.notes.basic_api.create(simple_public_object_input_for_create=body)\n" + " return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully created note')", + "Create a new note/engagement with the given properties.", + ), +] + +# ========== PIPELINES ========== +METHODS += [ + ( + "list_pipelines(self, object_type: str) -> HubSpotResponse", + " result = self._sdk.crm.pipelines.pipelines_api.get_all(object_type=object_type)\n" + " return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully listed pipelines')", + "List all pipelines for an object type (e.g. 'deals', 'tickets').", + ), + ( + "get_pipeline(self, object_type: str, pipeline_id: str) -> HubSpotResponse", + " result = self._sdk.crm.pipelines.pipelines_api.get_by_id(object_type=object_type, pipeline_id=pipeline_id)\n" + " return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully retrieved pipeline')", + "Get a specific pipeline by ID for an object type.", + ), +] + +# ========== PROPERTIES ========== +METHODS += [ + ( + "list_properties(self, object_type: str, archived: bool = False) -> HubSpotResponse", + " result = self._sdk.crm.properties.core_api.get_all(object_type=object_type, archived=archived)\n" + " return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully listed properties')", + "List all properties for an object type (e.g. 'contacts', 'companies').", + ), +] + +# ========== OWNERS ========== +METHODS += [ + ( + "list_owners(self, limit: int = 100, after: Optional[str] = None, archived: bool = False) -> HubSpotResponse", + " kwargs: dict[str, Any] = {'limit': limit, 'archived': archived}\n" + " if after is not None:\n" + " kwargs['after'] = after\n" + " result = self._sdk.crm.owners.owners_api.get_page(**kwargs)\n" + " return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully listed owners')", + "List all owners (users who can be assigned to CRM records).", + ), + ( + "get_owner(self, owner_id: str, archived: bool = False) -> HubSpotResponse", + " result = self._sdk.crm.owners.owners_api.get_by_id(owner_id=int(owner_id), archived=archived)\n" + " return HubSpotResponse(success=True, data=_to_dict(result), message='Successfully retrieved owner')", + "Get a specific owner by ID.", + ), +] + + +# --------------------------------------------------------------------------- +# Code emission utilities +# --------------------------------------------------------------------------- + + +def _emit_method(sig: str, body: str, doc: str) -> str: + """Emit a single method with try/except wrapping.""" + method_name = sig.split("(")[0] + # Body strings already have 12-space indentation (method body inside try). + # dedent strips it, then we re-indent to 12 spaces (inside try block). + normalized_body = textwrap.indent(textwrap.dedent(body), " ") + lines = [ + f" def {sig}:", + f' """{doc}"""', + " try:", + f"{normalized_body}", + " except Exception as e:", + f" return HubSpotResponse(success=False, error=str(e), message='Failed to execute {method_name}')", + "", + ] + return "\n".join(lines) + + +def build_class( + response_import: str = DEFAULT_RESPONSE_IMPORT, + class_name: str = DEFAULT_CLASS_NAME, +) -> str: + """Build the complete class source code.""" + parts: list[str] = [] + header = HEADER.replace("{response_import}", response_import).replace( + "{class_name}", class_name + ) + parts.append(header) + for sig, body, doc in METHODS: + parts.append(_emit_method(sig, body, doc)) + parts.append(FOOTER) + return "".join(parts) + + +def write_output(path: str, code: str) -> None: + """Write the generated code to the target file.""" + target = Path(path) + target.parent.mkdir(parents=True, exist_ok=True) + with open(target, "w", encoding="utf-8") as f: + f.write(code) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Generate HubSpotDataSource (hubspot-api-client SDK)." + ) + parser.add_argument( + "--out", + default=DEFAULT_OUT, + help="Output path for the generated data source.", + ) + parser.add_argument( + "--response-import", + default=DEFAULT_RESPONSE_IMPORT, + help="Import line for HubSpotClient and HubSpotResponse.", + ) + parser.add_argument( + "--class-name", + default=DEFAULT_CLASS_NAME, + help="Name of the generated datasource class.", + ) + parser.add_argument( + "--print", + dest="do_print", + action="store_true", + help="Also print generated code to stdout.", + ) + args = parser.parse_args() + + code = build_class( + response_import=args.response_import, class_name=args.class_name + ) + write_output(args.out, code) + + print(f"Generated HubSpotDataSource with {len(METHODS)} methods") + print(f"Saved to: {args.out}") + + if args.do_print: + print(code) + + +if __name__ == "__main__": + main() diff --git a/backend/python/code-generator/intercom.py b/backend/python/code-generator/intercom.py new file mode 100644 index 000000000..64b3d4970 --- /dev/null +++ b/backend/python/code-generator/intercom.py @@ -0,0 +1,641 @@ +# ruff: noqa +""" +Intercom REST API Code Generator + +Generates IntercomDataSource class covering Intercom API: +- Admin operations +- Contact operations (list, get, create, update, search) +- Conversation operations +- Company operations +- Article operations +- Teams, tags, segments, data attributes + +The generated DataSource accepts an IntercomClient and uses the client's +base URL to construct API requests. + +All methods have explicit parameter signatures with no **kwargs usage. +""" + +import sys +from pathlib import Path +from typing import Dict, List, Optional + +# ================================================================================ +# Intercom API Endpoints +# +# Each endpoint defines: +# method: HTTP verb +# path: URL path (appended to base_url: https://api.intercom.io) +# description: Human-readable description +# parameters: Dict of param_name -> {type, location (path/query/body), description} +# required: List of required parameter names +# ================================================================================ + +INTERCOM_API_ENDPOINTS = { + # ================================================================================ + # ADMINS + # ================================================================================ + "get_me": { + "method": "GET", + "path": "/me", + "description": "Get the current admin", + "parameters": {}, + "required": [], + }, + "list_admins": { + "method": "GET", + "path": "/admins", + "description": "List all admins", + "parameters": {}, + "required": [], + }, + "get_admin": { + "method": "GET", + "path": "/admins/{id}", + "description": "Get a specific admin by ID", + "parameters": { + "id": {"type": "str", "location": "path", "description": "Admin ID"}, + }, + "required": ["id"], + }, + + # ================================================================================ + # CONTACTS + # ================================================================================ + "list_contacts": { + "method": "GET", + "path": "/contacts", + "description": "List all contacts with optional pagination", + "parameters": { + "per_page": {"type": "Optional[int]", "location": "query", "description": "Number of contacts per page"}, + "starting_after": {"type": "Optional[str]", "location": "query", "description": "Cursor for pagination"}, + }, + "required": [], + }, + "get_contact": { + "method": "GET", + "path": "/contacts/{id}", + "description": "Get a specific contact by ID", + "parameters": { + "id": {"type": "str", "location": "path", "description": "Contact ID"}, + }, + "required": ["id"], + }, + "create_contact": { + "method": "POST", + "path": "/contacts", + "description": "Create a new contact", + "parameters": { + "role": {"type": "Optional[str]", "location": "body", "description": "Role: lead or user"}, + "external_id": {"type": "Optional[str]", "location": "body", "description": "External ID for the contact"}, + "email": {"type": "Optional[str]", "location": "body", "description": "Email address"}, + "phone": {"type": "Optional[str]", "location": "body", "description": "Phone number"}, + "name": {"type": "Optional[str]", "location": "body", "description": "Full name"}, + "avatar": {"type": "Optional[str]", "location": "body", "description": "Avatar URL"}, + "signed_up_at": {"type": "Optional[int]", "location": "body", "description": "Signup timestamp (Unix)"}, + "last_seen_at": {"type": "Optional[int]", "location": "body", "description": "Last seen timestamp (Unix)"}, + "owner_id": {"type": "Optional[int]", "location": "body", "description": "Owner admin ID"}, + "unsubscribed_from_emails": {"type": "Optional[bool]", "location": "body", "description": "Unsubscribed from emails"}, + "custom_attributes": {"type": "Optional[Dict[str, Any]]", "location": "body", "description": "Custom attributes"}, + }, + "required": [], + }, + "update_contact": { + "method": "PUT", + "path": "/contacts/{id}", + "description": "Update an existing contact", + "parameters": { + "id": {"type": "str", "location": "path", "description": "Contact ID"}, + "role": {"type": "Optional[str]", "location": "body", "description": "Role: lead or user"}, + "external_id": {"type": "Optional[str]", "location": "body", "description": "External ID"}, + "email": {"type": "Optional[str]", "location": "body", "description": "Email address"}, + "phone": {"type": "Optional[str]", "location": "body", "description": "Phone number"}, + "name": {"type": "Optional[str]", "location": "body", "description": "Full name"}, + "avatar": {"type": "Optional[str]", "location": "body", "description": "Avatar URL"}, + "signed_up_at": {"type": "Optional[int]", "location": "body", "description": "Signup timestamp (Unix)"}, + "last_seen_at": {"type": "Optional[int]", "location": "body", "description": "Last seen timestamp (Unix)"}, + "owner_id": {"type": "Optional[int]", "location": "body", "description": "Owner admin ID"}, + "unsubscribed_from_emails": {"type": "Optional[bool]", "location": "body", "description": "Unsubscribed from emails"}, + "custom_attributes": {"type": "Optional[Dict[str, Any]]", "location": "body", "description": "Custom attributes"}, + }, + "required": ["id"], + }, + "search_contacts": { + "method": "POST", + "path": "/contacts/search", + "description": "Search contacts with query filters", + "parameters": { + "query": {"type": "Dict[str, Any]", "location": "body", "description": "Search query object with field, operator, and value"}, + "pagination": {"type": "Optional[Dict[str, Any]]", "location": "body", "description": "Pagination options"}, + "sort": {"type": "Optional[Dict[str, Any]]", "location": "body", "description": "Sort options"}, + }, + "required": ["query"], + }, + + # ================================================================================ + # CONVERSATIONS + # ================================================================================ + "list_conversations": { + "method": "GET", + "path": "/conversations", + "description": "List all conversations with optional pagination", + "parameters": { + "per_page": {"type": "Optional[int]", "location": "query", "description": "Number of conversations per page"}, + "starting_after": {"type": "Optional[str]", "location": "query", "description": "Cursor for pagination"}, + }, + "required": [], + }, + "get_conversation": { + "method": "GET", + "path": "/conversations/{id}", + "description": "Get a specific conversation by ID", + "parameters": { + "id": {"type": "str", "location": "path", "description": "Conversation ID"}, + }, + "required": ["id"], + }, + + # ================================================================================ + # COMPANIES + # ================================================================================ + "list_companies": { + "method": "GET", + "path": "/companies", + "description": "List all companies", + "parameters": { + "per_page": {"type": "Optional[int]", "location": "query", "description": "Number of companies per page"}, + "page": {"type": "Optional[int]", "location": "query", "description": "Page number"}, + }, + "required": [], + }, + "get_company": { + "method": "GET", + "path": "/companies/{id}", + "description": "Get a specific company by ID", + "parameters": { + "id": {"type": "str", "location": "path", "description": "Company ID"}, + }, + "required": ["id"], + }, + "create_company": { + "method": "POST", + "path": "/companies", + "description": "Create or update a company", + "parameters": { + "company_id": {"type": "Optional[str]", "location": "body", "description": "External company ID"}, + "name": {"type": "Optional[str]", "location": "body", "description": "Company name"}, + "plan": {"type": "Optional[str]", "location": "body", "description": "Plan name"}, + "monthly_spend": {"type": "Optional[float]", "location": "body", "description": "Monthly spend"}, + "size": {"type": "Optional[int]", "location": "body", "description": "Number of employees"}, + "website": {"type": "Optional[str]", "location": "body", "description": "Website URL"}, + "industry": {"type": "Optional[str]", "location": "body", "description": "Industry"}, + "remote_created_at": {"type": "Optional[int]", "location": "body", "description": "Creation timestamp (Unix)"}, + "custom_attributes": {"type": "Optional[Dict[str, Any]]", "location": "body", "description": "Custom attributes"}, + }, + "required": [], + }, + + # ================================================================================ + # ARTICLES + # ================================================================================ + "list_articles": { + "method": "GET", + "path": "/articles", + "description": "List all articles", + "parameters": { + "per_page": {"type": "Optional[int]", "location": "query", "description": "Number of articles per page"}, + "page": {"type": "Optional[int]", "location": "query", "description": "Page number"}, + }, + "required": [], + }, + "get_article": { + "method": "GET", + "path": "/articles/{id}", + "description": "Get a specific article by ID", + "parameters": { + "id": {"type": "str", "location": "path", "description": "Article ID"}, + }, + "required": ["id"], + }, + "create_article": { + "method": "POST", + "path": "/articles", + "description": "Create a new article", + "parameters": { + "title": {"type": "str", "location": "body", "description": "Article title"}, + "author_id": {"type": "int", "location": "body", "description": "Author admin ID"}, + "description": {"type": "Optional[str]", "location": "body", "description": "Article description"}, + "body": {"type": "Optional[str]", "location": "body", "description": "Article body (HTML)"}, + "state": {"type": "Optional[str]", "location": "body", "description": "State: published or draft"}, + "parent_id": {"type": "Optional[int]", "location": "body", "description": "Parent collection/section ID"}, + "parent_type": {"type": "Optional[str]", "location": "body", "description": "Parent type: collection or section"}, + "translated_content": {"type": "Optional[Dict[str, Any]]", "location": "body", "description": "Translated content by locale"}, + }, + "required": ["title", "author_id"], + }, + + # ================================================================================ + # TEAMS + # ================================================================================ + "list_teams": { + "method": "GET", + "path": "/teams", + "description": "List all teams", + "parameters": {}, + "required": [], + }, + + # ================================================================================ + # TAGS + # ================================================================================ + "list_tags": { + "method": "GET", + "path": "/tags", + "description": "List all tags", + "parameters": {}, + "required": [], + }, + + # ================================================================================ + # SEGMENTS + # ================================================================================ + "list_segments": { + "method": "GET", + "path": "/segments", + "description": "List all segments", + "parameters": {}, + "required": [], + }, + + # ================================================================================ + # DATA ATTRIBUTES + # ================================================================================ + "list_data_attributes": { + "method": "GET", + "path": "/data_attributes", + "description": "List all data attributes", + "parameters": {}, + "required": [], + }, +} + + +class IntercomDataSourceGenerator: + """Generator for Intercom REST API datasource class.""" + + def __init__(self): + self.generated_methods: List[Dict[str, str]] = [] + + def _sanitize_parameter_name(self, name: str) -> str: + """Sanitize parameter names to be valid Python identifiers.""" + sanitized = name.replace("-", "_").replace(".", "_").replace("/", "_") + if sanitized and not (sanitized[0].isalpha() or sanitized[0] == "_"): + sanitized = f"param_{sanitized}" + if sanitized == "type": + sanitized = "type_" + elif sanitized == "query": + sanitized = "query_" + return sanitized + + def _build_query_params(self, endpoint_info: Dict) -> List[str]: + """Build query parameter handling code.""" + lines = [" query_params: dict[str, Any] = {}"] + + for param_name, param_info in endpoint_info["parameters"].items(): + if param_info["location"] == "query": + sanitized_name = self._sanitize_parameter_name(param_name) + + if "Optional[bool]" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}'] = str({sanitized_name}).lower()", + ]) + elif "Optional[int]" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}'] = str({sanitized_name})", + ]) + else: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}'] = {sanitized_name}", + ]) + + return lines + + def _build_path_formatting(self, path: str, endpoint_info: Dict) -> str: + """Build URL path with parameter substitution.""" + path_params = [ + name + for name, info in endpoint_info["parameters"].items() + if info["location"] == "path" + ] + + if path_params: + format_dict = ", ".join( + f"{param}={self._sanitize_parameter_name(param)}" + for param in path_params + ) + return f' url = self.base_url + "{path}".format({format_dict})' + else: + return f' url = self.base_url + "{path}"' + + def _build_request_body(self, endpoint_info: Dict) -> List[str]: + """Build request body handling.""" + body_params = { + name: info + for name, info in endpoint_info["parameters"].items() + if info["location"] == "body" + } + + if not body_params: + return [] + + lines = [" request_body: dict[str, Any] = {}"] + + for param_name, param_info in body_params.items(): + sanitized_name = self._sanitize_parameter_name(param_name) + + if param_name in endpoint_info["required"]: + lines.append(f" request_body['{param_name}'] = {sanitized_name}") + else: + lines.extend([ + f" if {sanitized_name} is not None:", + f" request_body['{param_name}'] = {sanitized_name}", + ]) + + return lines + + @staticmethod + def _modernize_type(type_str: str) -> str: + """Convert typing-style annotations to modern Python 3.10+ syntax.""" + if type_str.startswith("Optional[") and type_str.endswith("]"): + inner = type_str[len("Optional["):-1] + inner = IntercomDataSourceGenerator._modernize_type(inner) + return f"{inner} | None" + if type_str.startswith("Dict["): + inner = type_str[len("Dict["):-1] + parts = IntercomDataSourceGenerator._split_type_args(inner) + modernized = ", ".join( + IntercomDataSourceGenerator._modernize_type(p.strip()) for p in parts + ) + return f"dict[{modernized}]" + if type_str == "Dict": + return "dict" + if type_str.startswith("List["): + inner = type_str[len("List["):-1] + parts = IntercomDataSourceGenerator._split_type_args(inner) + modernized = ", ".join( + IntercomDataSourceGenerator._modernize_type(p.strip()) for p in parts + ) + return f"list[{modernized}]" + if type_str == "List": + return "list" + return type_str + + @staticmethod + def _split_type_args(s: str) -> List[str]: + """Split type arguments respecting nested brackets.""" + parts = [] + depth = 0 + current = "" + for ch in s: + if ch == "[": + depth += 1 + elif ch == "]": + depth -= 1 + if ch == "," and depth == 0: + parts.append(current.strip()) + current = "" + else: + current += ch + if current.strip(): + parts.append(current.strip()) + return parts + + def _generate_method_signature(self, method_name: str, endpoint_info: Dict) -> str: + """Generate method signature with explicit parameters.""" + params = ["self"] + + # Required params + for param_name in endpoint_info["required"]: + if param_name in endpoint_info["parameters"]: + param_info = endpoint_info["parameters"][param_name] + sanitized_name = self._sanitize_parameter_name(param_name) + modern_type = self._modernize_type(param_info["type"]) + params.append(f"{sanitized_name}: {modern_type}") + + # Optional parameters + for param_name, param_info in endpoint_info["parameters"].items(): + if param_name not in endpoint_info["required"]: + sanitized_name = self._sanitize_parameter_name(param_name) + modern_type = self._modernize_type(param_info["type"]) + if "| None" not in modern_type: + modern_type = f"{modern_type} | None" + params.append(f"{sanitized_name}: {modern_type} = None") + + signature_params = ",\n ".join(params) + return f" async def {method_name}(\n {signature_params}\n ) -> IntercomResponse:" + + def _generate_method_docstring(self, endpoint_info: Dict) -> List[str]: + """Generate method docstring.""" + lines = [f' """{endpoint_info["description"]}', ""] + + if endpoint_info["parameters"]: + lines.append(" Args:") + for param_name, param_info in endpoint_info["parameters"].items(): + sanitized_name = self._sanitize_parameter_name(param_name) + lines.append( + f" {sanitized_name}: {param_info['description']}" + ) + lines.append("") + + lines.extend([ + " Returns:", + " IntercomResponse with operation result", + ' """', + ]) + + return lines + + def _generate_method(self, method_name: str, endpoint_info: Dict) -> str: + """Generate a complete method for an API endpoint.""" + lines = [] + + # Method signature + lines.append(self._generate_method_signature(method_name, endpoint_info)) + + # Docstring + lines.extend(self._generate_method_docstring(endpoint_info)) + + # Query parameters + has_query = any( + info["location"] == "query" + for info in endpoint_info["parameters"].values() + ) + if has_query: + query_lines = self._build_query_params(endpoint_info) + lines.extend(query_lines) + lines.append("") + + # URL construction + lines.append(self._build_path_formatting(endpoint_info["path"], endpoint_info)) + + # Request body + body_lines = self._build_request_body(endpoint_info) + if body_lines: + lines.append("") + lines.extend(body_lines) + + # Request construction and execution + lines.append("") + lines.append(" try:") + lines.append(" request = HTTPRequest(") + lines.append(f' method="{endpoint_info["method"]}",') + lines.append(" url=url,") + lines.append(' headers={"Content-Type": "application/json", "Accept": "application/json"},') + if has_query: + lines.append(" query=query_params,") + if body_lines: + lines.append(" body=request_body,") + lines.append(" )") + lines.extend([ + " response = await self.http.execute(request) # type: ignore[reportUnknownMemberType]", + " response_data = response.json() if response.text() else None", + " return IntercomResponse(", + " success=response.status < HTTP_ERROR_THRESHOLD,", + " data=response_data,", + f' message="Successfully executed {method_name}" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {{response.status}}"', + " )", + " except Exception as e:", + f' return IntercomResponse(success=False, error=str(e), message="Failed to execute {method_name}")', + ]) + + self.generated_methods.append({ + "name": method_name, + "endpoint": endpoint_info["path"], + "method": endpoint_info["method"], + "description": endpoint_info["description"], + }) + + return "\n".join(lines) + + def generate_intercom_datasource(self) -> str: + """Generate the complete Intercom datasource class.""" + + class_lines = [ + "# ruff: noqa: A002, FBT001", + '"""', + "Intercom REST API DataSource - Auto-generated API wrapper", + "", + "Generated from Intercom REST API documentation.", + "Uses HTTP client for direct REST API interactions.", + "All methods have explicit parameter signatures.", + '"""', + "", + "from __future__ import annotations", + "", + "from typing import Any", + "", + "from app.sources.client.intercom.intercom import IntercomClient, IntercomResponse", + "from app.sources.client.http.http_request import HTTPRequest", + "", + "# HTTP status code constant", + "HTTP_ERROR_THRESHOLD = 400", + "", + "", + "class IntercomDataSource:", + ' """Intercom REST API DataSource', + "", + " Provides async wrapper methods for Intercom REST API operations:", + " - Admin management", + " - Contact CRUD and search", + " - Conversation management", + " - Company management", + " - Article management", + " - Teams, tags, segments, data attributes", + "", + " All methods return IntercomResponse objects.", + ' """', + "", + " def __init__(self, client: IntercomClient) -> None:", + ' """Initialize with IntercomClient.', + "", + " Args:", + " client: IntercomClient instance with configured authentication", + ' """', + " self._client = client", + " self.http = client.get_client()", + " try:", + " self.base_url = self.http.get_base_url().rstrip('/')", + " except AttributeError as exc:", + " raise ValueError('HTTP client does not have get_base_url method') from exc", + "", + " def get_data_source(self) -> 'IntercomDataSource':", + ' """Return the data source instance."""', + " return self", + "", + " def get_client(self) -> IntercomClient:", + ' """Return the underlying IntercomClient."""', + " return self._client", + "", + ] + + # Generate all API methods + for method_name, endpoint_info in INTERCOM_API_ENDPOINTS.items(): + class_lines.append(self._generate_method(method_name, endpoint_info)) + class_lines.append("") + + return "\n".join(class_lines) + + def save_to_file(self, filename: Optional[str] = None) -> None: + """Generate and save the Intercom datasource to a file.""" + if filename is None: + filename = "intercom.py" + + script_dir = Path(__file__).parent if __file__ else Path(".") + target_dir = script_dir.parent / "app" / "sources" / "external" / "intercom" + target_dir.mkdir(parents=True, exist_ok=True) + + full_path = target_dir / filename + + class_code = self.generate_intercom_datasource() + + full_path.write_text(class_code, encoding="utf-8") + + print(f"Generated Intercom data source with {len(self.generated_methods)} methods") + print(f"Saved to: {full_path}") + + # Print summary + method_counts: Dict[str, int] = {} + for method in self.generated_methods: + http_method = method["method"] + method_counts[http_method] = method_counts.get(http_method, 0) + 1 + + print(f"\nMethods by HTTP verb:") + for verb, count in sorted(method_counts.items()): + print(f" - {verb}: {count}") + + +def main(): + """Main function for Intercom data source generator.""" + import argparse + + parser = argparse.ArgumentParser( + description="Generate Intercom REST API data source" + ) + parser.add_argument("--filename", "-f", help="Output filename (optional)") + + args = parser.parse_args() + + try: + generator = IntercomDataSourceGenerator() + generator.save_to_file(args.filename) + return 0 + except Exception as e: + print(f"Failed to generate Intercom data source: {e}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/python/code-generator/invision.py b/backend/python/code-generator/invision.py new file mode 100644 index 000000000..92edaec34 --- /dev/null +++ b/backend/python/code-generator/invision.py @@ -0,0 +1,594 @@ +# ruff: noqa +""" +InVision REST API Code Generator + +Generates InVisionDataSource class covering InVision API v2: +- Project management (list, get, create) +- Screen operations (list, get) +- Comment operations +- Team and member management +- Space operations +- User profile + +The generated DataSource accepts an InVisionClient and uses the client's +configured base URL. Methods are generated for all API endpoints. + +All methods have explicit parameter signatures with no **kwargs usage. +""" + +import sys +from pathlib import Path +from typing import Dict, List, Optional + +# ================================================================================ +# InVision API Endpoints - organized by resource +# +# Each endpoint defines: +# method: HTTP verb +# path: URL path (appended to base_url which is https://api.invisionapp.com/v2) +# description: Human-readable description +# parameters: Dict of param_name -> {type, location (path/query/body), description} +# required: List of required parameter names +# version: API version tag +# ================================================================================ + +INVISION_API_ENDPOINTS = { + # ================================================================================ + # USERS + # ================================================================================ + "get_current_user": { + "method": "GET", + "path": "/users/me", + "description": "Get the current authenticated user details", + "parameters": {}, + "required": [], + "version": "v2", + }, + + # ================================================================================ + # PROJECTS + # ================================================================================ + "list_projects": { + "method": "GET", + "path": "/projects", + "description": "List all projects accessible to the authenticated user", + "parameters": { + "limit": {"type": "Optional[int]", "location": "query", "description": "Maximum number of results to return"}, + "offset": {"type": "Optional[int]", "location": "query", "description": "Number of results to skip for pagination"}, + "sortBy": {"type": "Optional[str]", "location": "query", "description": "Field to sort results by"}, + "archived": {"type": "Optional[bool]", "location": "query", "description": "Filter by archived status"}, + }, + "required": [], + "version": "v2", + }, + "get_project": { + "method": "GET", + "path": "/projects/{projectId}", + "description": "Get a specific project by ID", + "parameters": { + "projectId": {"type": "str", "location": "path", "description": "The project ID"}, + }, + "required": ["projectId"], + "version": "v2", + }, + "create_project": { + "method": "POST", + "path": "/projects", + "description": "Create a new project", + "parameters": { + "name": {"type": "str", "location": "body", "description": "The name of the project"}, + "project_type": {"type": "Optional[str]", "location": "body", "description": "The type of the project"}, + "description": {"type": "Optional[str]", "location": "body", "description": "The project description"}, + }, + "required": ["name"], + "version": "v2", + }, + + # ================================================================================ + # SCREENS + # ================================================================================ + "list_project_screens": { + "method": "GET", + "path": "/projects/{projectId}/screens", + "description": "List all screens in a project", + "parameters": { + "projectId": {"type": "str", "location": "path", "description": "The project ID"}, + "limit": {"type": "Optional[int]", "location": "query", "description": "Maximum number of results to return"}, + "offset": {"type": "Optional[int]", "location": "query", "description": "Number of results to skip for pagination"}, + "sortBy": {"type": "Optional[str]", "location": "query", "description": "Field to sort results by"}, + }, + "required": ["projectId"], + "version": "v2", + }, + "get_screen": { + "method": "GET", + "path": "/screens/{screenId}", + "description": "Get a specific screen by ID", + "parameters": { + "screenId": {"type": "str", "location": "path", "description": "The screen ID"}, + }, + "required": ["screenId"], + "version": "v2", + }, + + # ================================================================================ + # COMMENTS + # ================================================================================ + "list_project_comments": { + "method": "GET", + "path": "/projects/{projectId}/comments", + "description": "List all comments in a project", + "parameters": { + "projectId": {"type": "str", "location": "path", "description": "The project ID"}, + "limit": {"type": "Optional[int]", "location": "query", "description": "Maximum number of results to return"}, + "offset": {"type": "Optional[int]", "location": "query", "description": "Number of results to skip for pagination"}, + }, + "required": ["projectId"], + "version": "v2", + }, + + # ================================================================================ + # TEAMS + # ================================================================================ + "list_teams": { + "method": "GET", + "path": "/teams", + "description": "List all teams", + "parameters": {}, + "required": [], + "version": "v2", + }, + "get_team": { + "method": "GET", + "path": "/teams/{teamId}", + "description": "Get a specific team by ID", + "parameters": { + "teamId": {"type": "str", "location": "path", "description": "The team ID"}, + }, + "required": ["teamId"], + "version": "v2", + }, + "list_team_members": { + "method": "GET", + "path": "/teams/{teamId}/members", + "description": "List all members of a team", + "parameters": { + "teamId": {"type": "str", "location": "path", "description": "The team ID"}, + }, + "required": ["teamId"], + "version": "v2", + }, + + # ================================================================================ + # SPACES + # ================================================================================ + "list_spaces": { + "method": "GET", + "path": "/spaces", + "description": "List all spaces", + "parameters": { + "limit": {"type": "Optional[int]", "location": "query", "description": "Maximum number of results to return"}, + "offset": {"type": "Optional[int]", "location": "query", "description": "Number of results to skip for pagination"}, + }, + "required": [], + "version": "v2", + }, + "get_space": { + "method": "GET", + "path": "/spaces/{spaceId}", + "description": "Get a specific space by ID", + "parameters": { + "spaceId": {"type": "str", "location": "path", "description": "The space ID"}, + }, + "required": ["spaceId"], + "version": "v2", + }, +} + + +class InVisionDataSourceGenerator: + """Generator for comprehensive InVision REST API datasource class. + + Generates methods for InVision API v2 endpoints. + The generated DataSource class accepts an InVisionClient whose base URL + is https://api.invisionapp.com/v2. + """ + + def __init__(self): + self.generated_methods: List[Dict[str, str]] = [] + + def _sanitize_parameter_name(self, name: str) -> str: + """Sanitize parameter names to be valid Python identifiers.""" + sanitized = name.replace("-", "_").replace(".", "_").replace("/", "_") + if sanitized and not (sanitized[0].isalpha() or sanitized[0] == "_"): + sanitized = f"param_{sanitized}" + return sanitized + + def _build_query_params(self, endpoint_info: Dict) -> List[str]: + """Build query parameter handling code.""" + lines = [" query_params: dict[str, Any] = {}"] + + for param_name, param_info in endpoint_info["parameters"].items(): + if param_info["location"] == "query": + sanitized_name = self._sanitize_parameter_name(param_name) + + if "Optional[bool]" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}'] = str({sanitized_name}).lower()", + ]) + elif "Optional[int]" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}'] = str({sanitized_name})", + ]) + elif "List[" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}[]'] = {sanitized_name}", + ]) + else: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}'] = {sanitized_name}", + ]) + + return lines + + def _build_path_formatting(self, path: str, endpoint_info: Dict) -> str: + """Build URL path with parameter substitution.""" + path_params = [ + name + for name, info in endpoint_info["parameters"].items() + if info["location"] == "path" + ] + + if path_params: + format_dict = ", ".join( + f"{param}={self._sanitize_parameter_name(param)}" + for param in path_params + ) + return f' url = self.base_url + "{path}".format({format_dict})' + else: + return f' url = self.base_url + "{path}"' + + def _build_request_body(self, endpoint_info: Dict) -> List[str]: + """Build request body handling.""" + body_params = { + name: info + for name, info in endpoint_info["parameters"].items() + if info["location"] == "body" + } + + if not body_params: + return [] + + lines = [" body: dict[str, Any] = {}"] + + for param_name, param_info in body_params.items(): + sanitized_name = self._sanitize_parameter_name(param_name) + + if param_name in endpoint_info["required"]: + lines.append(f" body['{param_name}'] = {sanitized_name}") + else: + lines.extend([ + f" if {sanitized_name} is not None:", + f" body['{param_name}'] = {sanitized_name}", + ]) + + return lines + + @staticmethod + def _modernize_type(type_str: str) -> str: + """Convert typing-style annotations to modern Python 3.10+ syntax.""" + if type_str.startswith("Optional[") and type_str.endswith("]"): + inner = type_str[len("Optional["):-1] + inner = InVisionDataSourceGenerator._modernize_type(inner) + return f"{inner} | None" + if type_str.startswith("Dict["): + inner = type_str[len("Dict["):-1] + parts = InVisionDataSourceGenerator._split_type_args(inner) + modernized = ", ".join( + InVisionDataSourceGenerator._modernize_type(p.strip()) for p in parts + ) + return f"dict[{modernized}]" + if type_str == "Dict": + return "dict" + if type_str.startswith("List["): + inner = type_str[len("List["):-1] + parts = InVisionDataSourceGenerator._split_type_args(inner) + modernized = ", ".join( + InVisionDataSourceGenerator._modernize_type(p.strip()) for p in parts + ) + return f"list[{modernized}]" + if type_str == "List": + return "list" + return type_str + + @staticmethod + def _split_type_args(s: str) -> List[str]: + """Split type arguments respecting nested brackets.""" + parts = [] + depth = 0 + current = "" + for ch in s: + if ch == "[": + depth += 1 + elif ch == "]": + depth -= 1 + if ch == "," and depth == 0: + parts.append(current.strip()) + current = "" + else: + current += ch + if current.strip(): + parts.append(current.strip()) + return parts + + def _generate_method_signature(self, method_name: str, endpoint_info: Dict) -> str: + """Generate method signature with explicit parameters.""" + params = ["self"] + has_any_bool = False + + # Collect required params, split into non-bool and bool groups + required_non_bool: List[str] = [] + required_bool: List[str] = [] + for param_name in endpoint_info["required"]: + if param_name in endpoint_info["parameters"]: + param_info = endpoint_info["parameters"][param_name] + sanitized_name = self._sanitize_parameter_name(param_name) + modern_type = self._modernize_type(param_info["type"]) + param_str = f"{sanitized_name}: {modern_type}" + if "bool" in param_info.get("type", ""): + required_bool.append(param_str) + has_any_bool = True + else: + required_non_bool.append(param_str) + + # Collect optional parameters + optional_params: List[str] = [] + for param_name, param_info in endpoint_info["parameters"].items(): + if param_name not in endpoint_info["required"]: + sanitized_name = self._sanitize_parameter_name(param_name) + modern_type = self._modernize_type(param_info["type"]) + if "| None" not in modern_type: + modern_type = f"{modern_type} | None" + optional_params.append(f"{sanitized_name}: {modern_type} = None") + if "bool" in param_info.get("type", ""): + has_any_bool = True + + # Build signature: non-bool required first, then * if needed, then bool required + optional + params.extend(required_non_bool) + if has_any_bool and (required_bool or optional_params): + params.append("*") + params.extend(required_bool) + params.extend(optional_params) + + signature_params = ",\n ".join(params) + return f" async def {method_name}(\n {signature_params}\n ) -> InVisionResponse:" + + def _generate_method_docstring(self, endpoint_info: Dict) -> List[str]: + """Generate method docstring.""" + version = endpoint_info.get("version", "v2") + lines = [f' """{endpoint_info["description"]} (API {version})', ""] + + if endpoint_info["parameters"]: + lines.append(" Args:") + for param_name, param_info in endpoint_info["parameters"].items(): + sanitized_name = self._sanitize_parameter_name(param_name) + lines.append( + f" {sanitized_name}: {param_info['description']}" + ) + lines.append("") + + lines.extend([ + " Returns:", + " InVisionResponse with operation result", + ' """', + ]) + + return lines + + def _generate_method(self, method_name: str, endpoint_info: Dict) -> str: + """Generate a complete method for an API endpoint.""" + lines = [] + + # Method signature + lines.append(self._generate_method_signature(method_name, endpoint_info)) + + # Docstring + lines.extend(self._generate_method_docstring(endpoint_info)) + + # Query parameters + has_query = any( + info["location"] == "query" + for info in endpoint_info["parameters"].values() + ) + if has_query: + query_lines = self._build_query_params(endpoint_info) + lines.extend(query_lines) + lines.append("") + + # URL construction + lines.append(self._build_path_formatting(endpoint_info["path"], endpoint_info)) + + # Request body + body_lines = self._build_request_body(endpoint_info) + if body_lines: + lines.append("") + lines.extend(body_lines) + + # Request construction and execution + lines.append("") + lines.append(" try:") + lines.append(" request = HTTPRequest(") + lines.append(f' method="{endpoint_info["method"]}",') + lines.append(" url=url,") + lines.append(' headers={"Content-Type": "application/json"},') + if has_query: + lines.append(" query=query_params,") + if body_lines: + lines.append(" body=body,") + lines.append(" )") + lines.extend([ + " response = await self.http.execute(request) # type: ignore[reportUnknownMemberType]", + " response_data = response.json() if response.text() else None", + " return InVisionResponse(", + " success=response.status < HTTP_ERROR_THRESHOLD,", + " data=response_data,", + f' message="Successfully executed {method_name}" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {{response.status}}"', + " )", + " except Exception as e:", + f' return InVisionResponse(success=False, error=str(e), message="Failed to execute {method_name}")', + ]) + + self.generated_methods.append({ + "name": method_name, + "endpoint": endpoint_info["path"], + "method": endpoint_info["method"], + "description": endpoint_info["description"], + "version": endpoint_info.get("version", "v2"), + }) + + return "\n".join(lines) + + def generate_invision_datasource(self) -> str: + """Generate the complete InVision datasource class.""" + + class_lines = [ + '"""', + "InVision REST API DataSource - Auto-generated API wrapper", + "", + "Generated from InVision REST API v2 documentation.", + "Uses HTTP client for direct REST API interactions.", + "All methods have explicit parameter signatures.", + '"""', + "", + "from __future__ import annotations", + "", + "from typing import Any", + "", + "from app.sources.client.invision.invision import InVisionClient, InVisionResponse", + "from app.sources.client.http.http_request import HTTPRequest", + "", + "# HTTP status code constant", + "HTTP_ERROR_THRESHOLD = 400", + "", + "", + "class InVisionDataSource:", + ' """InVision REST API DataSource', + "", + " Provides async wrapper methods for InVision REST API operations:", + " - User profile", + " - Project management (list, get, create)", + " - Screen operations (list, get)", + " - Comment management", + " - Team and member management", + " - Space operations", + "", + " The base URL is https://api.invisionapp.com/v2.", + "", + " All methods return InVisionResponse objects.", + ' """', + "", + " def __init__(self, client: InVisionClient) -> None:", + ' """Initialize with InVisionClient.', + "", + " Args:", + " client: InVisionClient instance with configured authentication", + ' """', + " self._client = client", + " self.http = client.get_client()", + " try:", + " self.base_url = self.http.get_base_url().rstrip('/')", + " except AttributeError as exc:", + " raise ValueError('HTTP client does not have get_base_url method') from exc", + "", + " def get_data_source(self) -> 'InVisionDataSource':", + ' """Return the data source instance."""', + " return self", + "", + " def get_client(self) -> InVisionClient:", + ' """Return the underlying InVisionClient."""', + " return self._client", + "", + ] + + # Generate all API methods + for method_name, endpoint_info in INVISION_API_ENDPOINTS.items(): + class_lines.append(self._generate_method(method_name, endpoint_info)) + class_lines.append("") + + return "\n".join(class_lines) + + def save_to_file(self, filename: Optional[str] = None) -> None: + """Generate and save the InVision datasource to a file.""" + if filename is None: + filename = "invision.py" + + script_dir = Path(__file__).parent if __file__ else Path(".") + invision_dir = script_dir.parent / "app" / "sources" / "external" / "invision" + invision_dir.mkdir(parents=True, exist_ok=True) + + full_path = invision_dir / filename + + class_code = self.generate_invision_datasource() + + full_path.write_text(class_code, encoding="utf-8") + + print(f"Generated InVision data source with {len(self.generated_methods)} methods") + print(f"Saved to: {full_path}") + + # Print summary by category + resource_categories = { + "User": 0, + "Project": 0, + "Screen": 0, + "Comment": 0, + "Team": 0, + "Space": 0, + } + + for method in self.generated_methods: + name = method["name"] + if "user" in name: + resource_categories["User"] += 1 + elif "project" in name and "screen" not in name and "comment" not in name: + resource_categories["Project"] += 1 + elif "screen" in name: + resource_categories["Screen"] += 1 + elif "comment" in name: + resource_categories["Comment"] += 1 + elif "team" in name or "member" in name: + resource_categories["Team"] += 1 + elif "space" in name: + resource_categories["Space"] += 1 + + print(f"\nMethods by Resource:") + for category, count in resource_categories.items(): + if count > 0: + print(f" - {category}: {count}") + + +def main(): + """Main function for InVision data source generator.""" + import argparse + + parser = argparse.ArgumentParser( + description="Generate InVision REST API data source" + ) + parser.add_argument("--filename", "-f", help="Output filename (optional)") + + args = parser.parse_args() + + try: + generator = InVisionDataSourceGenerator() + generator.save_to_file(args.filename) + return 0 + except Exception as e: + print(f"Failed to generate InVision data source: {e}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/python/code-generator/ironclad.py b/backend/python/code-generator/ironclad.py new file mode 100644 index 000000000..4ba8d0f48 --- /dev/null +++ b/backend/python/code-generator/ironclad.py @@ -0,0 +1,648 @@ +# ruff: noqa +""" +Ironclad REST API Code Generator + +Generates IroncladDataSource class covering Ironclad API v1: +- Workflow operations (list, get, launch, update) +- Workflow approvals +- Records management +- Templates +- Webhooks +- Users and Groups + +The generated DataSource accepts an IroncladClient and uses the client's +configured base URL. All methods have explicit parameter signatures with +no **kwargs usage. + +API Reference: https://developer.ironcladapp.com/reference +""" + +import sys +from pathlib import Path +from typing import Dict, List, Optional + +# ================================================================================ +# Ironclad API Endpoints +# +# Each endpoint defines: +# method: HTTP verb +# path: URL path (appended to base_url) +# description: Human-readable description +# parameters: Dict of param_name -> {type, location (path/query/body), description} +# required: List of required parameter names +# ================================================================================ + +IRONCLAD_API_ENDPOINTS = { + # ================================================================================ + # WORKFLOWS + # ================================================================================ + "list_workflows": { + "method": "GET", + "path": "/workflows", + "description": "List workflows with optional filters", + "parameters": { + "page": {"type": "Optional[int]", "location": "query", "description": "Page number for pagination"}, + "page_size": {"type": "Optional[int]", "location": "query", "description": "Number of results per page"}, + "status": {"type": "Optional[str]", "location": "query", "description": "Filter by workflow status"}, + "template_id": {"type": "Optional[str]", "location": "query", "description": "Filter by template ID"}, + "created_after": {"type": "Optional[str]", "location": "query", "description": "Filter workflows created after this ISO 8601 date"}, + "created_before": {"type": "Optional[str]", "location": "query", "description": "Filter workflows created before this ISO 8601 date"}, + }, + "required": [], + }, + "get_workflow": { + "method": "GET", + "path": "/workflows/{workflow_id}", + "description": "Get a specific workflow by ID", + "parameters": { + "workflow_id": {"type": "str", "location": "path", "description": "The workflow ID"}, + }, + "required": ["workflow_id"], + }, + "launch_workflow": { + "method": "POST", + "path": "/workflows", + "description": "Launch a new workflow", + "parameters": { + "template_id": {"type": "str", "location": "body", "description": "The template ID to launch the workflow from"}, + "attributes": {"type": "Optional[Dict[str, Any]]", "location": "body", "description": "Workflow attribute values"}, + "creator": {"type": "Optional[Dict[str, Any]]", "location": "body", "description": "Creator information"}, + }, + "required": ["template_id"], + }, + "update_workflow": { + "method": "PATCH", + "path": "/workflows/{workflow_id}", + "description": "Update a workflow", + "parameters": { + "workflow_id": {"type": "str", "location": "path", "description": "The workflow ID"}, + "attributes": {"type": "Optional[Dict[str, Any]]", "location": "body", "description": "Workflow attribute values to update"}, + }, + "required": ["workflow_id"], + }, + + # ================================================================================ + # WORKFLOW APPROVALS + # ================================================================================ + "list_workflow_approvals": { + "method": "GET", + "path": "/workflows/{workflow_id}/approvals", + "description": "List approvals for a workflow", + "parameters": { + "workflow_id": {"type": "str", "location": "path", "description": "The workflow ID"}, + }, + "required": ["workflow_id"], + }, + "create_workflow_approval": { + "method": "POST", + "path": "/workflows/{workflow_id}/approvals", + "description": "Create an approval for a workflow", + "parameters": { + "workflow_id": {"type": "str", "location": "path", "description": "The workflow ID"}, + "role_id": {"type": "Optional[str]", "location": "body", "description": "The role ID for the approval"}, + "user_id": {"type": "Optional[str]", "location": "body", "description": "The user ID for the approval"}, + "status": {"type": "Optional[str]", "location": "body", "description": "Approval status"}, + }, + "required": ["workflow_id"], + }, + + # ================================================================================ + # RECORDS + # ================================================================================ + "list_records": { + "method": "GET", + "path": "/records", + "description": "List records with optional filters", + "parameters": { + "page": {"type": "Optional[int]", "location": "query", "description": "Page number for pagination"}, + "page_size": {"type": "Optional[int]", "location": "query", "description": "Number of results per page"}, + "template_id": {"type": "Optional[str]", "location": "query", "description": "Filter by template ID"}, + "filter": {"type": "Optional[str]", "location": "query", "description": "Filter expression"}, + }, + "required": [], + }, + "get_record": { + "method": "GET", + "path": "/records/{record_id}", + "description": "Get a specific record by ID", + "parameters": { + "record_id": {"type": "str", "location": "path", "description": "The record ID"}, + }, + "required": ["record_id"], + }, + "update_record": { + "method": "PATCH", + "path": "/records/{record_id}", + "description": "Update a record", + "parameters": { + "record_id": {"type": "str", "location": "path", "description": "The record ID"}, + "attributes": {"type": "Optional[Dict[str, Any]]", "location": "body", "description": "Record attribute values to update"}, + }, + "required": ["record_id"], + }, + + # ================================================================================ + # TEMPLATES + # ================================================================================ + "list_templates": { + "method": "GET", + "path": "/templates", + "description": "List all templates", + "parameters": {}, + "required": [], + }, + "get_template": { + "method": "GET", + "path": "/templates/{template_id}", + "description": "Get a specific template by ID", + "parameters": { + "template_id": {"type": "str", "location": "path", "description": "The template ID"}, + }, + "required": ["template_id"], + }, + + # ================================================================================ + # WEBHOOKS + # ================================================================================ + "list_webhooks": { + "method": "GET", + "path": "/webhooks", + "description": "List all webhooks", + "parameters": {}, + "required": [], + }, + "create_webhook": { + "method": "POST", + "path": "/webhooks", + "description": "Create a webhook", + "parameters": { + "target_url": {"type": "str", "location": "body", "description": "The URL to send webhook events to"}, + "events": {"type": "Optional[List[str]]", "location": "body", "description": "List of event types to subscribe to"}, + }, + "required": ["target_url"], + }, + "delete_webhook": { + "method": "DELETE", + "path": "/webhooks/{webhook_id}", + "description": "Delete a webhook", + "parameters": { + "webhook_id": {"type": "str", "location": "path", "description": "The webhook ID"}, + }, + "required": ["webhook_id"], + }, + + # ================================================================================ + # USERS + # ================================================================================ + "list_users": { + "method": "GET", + "path": "/users", + "description": "List users with optional pagination", + "parameters": { + "page": {"type": "Optional[int]", "location": "query", "description": "Page number for pagination"}, + "page_size": {"type": "Optional[int]", "location": "query", "description": "Number of results per page"}, + }, + "required": [], + }, + + # ================================================================================ + # GROUPS + # ================================================================================ + "list_groups": { + "method": "GET", + "path": "/groups", + "description": "List all groups", + "parameters": {}, + "required": [], + }, +} + + +# ================================================================================ +# Code Generator +# ================================================================================ + + +class IroncladDataSourceGenerator: + """Generator for comprehensive Ironclad REST API datasource class. + + Generates methods for Ironclad API v1 endpoints. + The generated DataSource class accepts an IroncladClient whose base URL + is pre-configured. + """ + + def __init__(self): + self.generated_methods: List[Dict[str, str]] = [] + + # Python builtins that must not be used as parameter names + _PYTHON_BUILTINS = frozenset({ + "filter", "format", "type", "id", "input", "hash", "help", "list", + "map", "max", "min", "next", "object", "open", "print", "range", + "set", "slice", "sorted", "sum", "super", "tuple", "vars", "zip", + }) + + def _sanitize_parameter_name(self, name: str) -> str: + """Sanitize parameter names to be valid Python identifiers.""" + sanitized = name.replace("-", "_").replace(".", "_").replace("/", "_") + if sanitized and not (sanitized[0].isalpha() or sanitized[0] == "_"): + sanitized = f"param_{sanitized}" + if sanitized in self._PYTHON_BUILTINS: + sanitized = f"{sanitized}_value" + return sanitized + + def _build_query_params(self, endpoint_info: Dict) -> List[str]: + """Build query parameter handling code.""" + lines = [" query_params: dict[str, Any] = {}"] + required = endpoint_info.get("required", []) + + for param_name, param_info in endpoint_info["parameters"].items(): + if param_info["location"] == "query": + sanitized_name = self._sanitize_parameter_name(param_name) + is_required = param_name in required + + if is_required: + # Required query params: assign directly, no None check + lines.append( + f" query_params['{param_name}'] = {sanitized_name}" + ) + elif "Optional[bool]" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}'] = str({sanitized_name}).lower()", + ]) + elif "Optional[int]" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}'] = str({sanitized_name})", + ]) + elif "List[" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}[]'] = {sanitized_name}", + ]) + else: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}'] = {sanitized_name}", + ]) + + return lines + + def _build_path_formatting(self, path: str, endpoint_info: Dict) -> str: + """Build URL path with parameter substitution.""" + path_params = [ + name + for name, info in endpoint_info["parameters"].items() + if info["location"] == "path" + ] + + if path_params: + format_dict = ", ".join( + f"{param}={self._sanitize_parameter_name(param)}" + for param in path_params + ) + return f' url = self.base_url + "{path}".format({format_dict})' + else: + return f' url = self.base_url + "{path}"' + + def _build_request_body(self, endpoint_info: Dict) -> List[str]: + """Build request body handling.""" + body_params = { + name: info + for name, info in endpoint_info["parameters"].items() + if info["location"] == "body" + } + + if not body_params: + return [] + + lines = [" body: dict[str, Any] = {}"] + + for param_name, param_info in body_params.items(): + sanitized_name = self._sanitize_parameter_name(param_name) + + if param_name in endpoint_info["required"]: + lines.append(f" body['{param_name}'] = {sanitized_name}") + else: + lines.extend([ + f" if {sanitized_name} is not None:", + f" body['{param_name}'] = {sanitized_name}", + ]) + + return lines + + @staticmethod + def _modernize_type(type_str: str) -> str: + """Convert typing-style annotations to modern Python 3.10+ syntax.""" + if type_str.startswith("Optional[") and type_str.endswith("]"): + inner = type_str[len("Optional["):-1] + inner = IroncladDataSourceGenerator._modernize_type(inner) + return f"{inner} | None" + if type_str.startswith("Dict["): + inner = type_str[len("Dict["):-1] + parts = IroncladDataSourceGenerator._split_type_args(inner) + modernized = ", ".join( + IroncladDataSourceGenerator._modernize_type(p.strip()) for p in parts + ) + return f"dict[{modernized}]" + if type_str == "Dict": + return "dict" + if type_str.startswith("List["): + inner = type_str[len("List["):-1] + parts = IroncladDataSourceGenerator._split_type_args(inner) + modernized = ", ".join( + IroncladDataSourceGenerator._modernize_type(p.strip()) for p in parts + ) + return f"list[{modernized}]" + if type_str == "List": + return "list" + return type_str + + @staticmethod + def _split_type_args(s: str) -> List[str]: + """Split type arguments respecting nested brackets.""" + parts = [] + depth = 0 + current = "" + for ch in s: + if ch == "[": + depth += 1 + elif ch == "]": + depth -= 1 + if ch == "," and depth == 0: + parts.append(current.strip()) + current = "" + else: + current += ch + if current.strip(): + parts.append(current.strip()) + return parts + + def _generate_method_signature(self, method_name: str, endpoint_info: Dict) -> str: + """Generate method signature with explicit parameters.""" + params = ["self"] + has_any_bool = False + + # Collect required params, split into non-bool and bool groups + required_non_bool: List[str] = [] + required_bool: List[str] = [] + for param_name in endpoint_info["required"]: + if param_name in endpoint_info["parameters"]: + param_info = endpoint_info["parameters"][param_name] + sanitized_name = self._sanitize_parameter_name(param_name) + modern_type = self._modernize_type(param_info["type"]) + param_str = f"{sanitized_name}: {modern_type}" + if "bool" in param_info.get("type", ""): + required_bool.append(param_str) + has_any_bool = True + else: + required_non_bool.append(param_str) + + # Collect optional parameters + optional_params: List[str] = [] + for param_name, param_info in endpoint_info["parameters"].items(): + if param_name not in endpoint_info["required"]: + sanitized_name = self._sanitize_parameter_name(param_name) + modern_type = self._modernize_type(param_info["type"]) + if "| None" not in modern_type: + modern_type = f"{modern_type} | None" + optional_params.append(f"{sanitized_name}: {modern_type} = None") + if "bool" in param_info.get("type", ""): + has_any_bool = True + + # Build signature: non-bool required first, then * if needed, then bool required + optional + params.extend(required_non_bool) + if has_any_bool and (required_bool or optional_params): + params.append("*") + params.extend(required_bool) + params.extend(optional_params) + + signature_params = ",\n ".join(params) + return f" async def {method_name}(\n {signature_params}\n ) -> IroncladResponse:" + + def _generate_method_docstring(self, endpoint_info: Dict) -> List[str]: + """Generate method docstring.""" + lines = [f' """{endpoint_info["description"]}', ""] + + if endpoint_info["parameters"]: + lines.append(" Args:") + for param_name, param_info in endpoint_info["parameters"].items(): + sanitized_name = self._sanitize_parameter_name(param_name) + lines.append( + f" {sanitized_name}: {param_info['description']}" + ) + lines.append("") + + lines.extend([ + " Returns:", + " IroncladResponse with operation result", + ' """', + ]) + + return lines + + def _generate_method(self, method_name: str, endpoint_info: Dict) -> str: + """Generate a complete method for an API endpoint.""" + lines = [] + + # Method signature + lines.append(self._generate_method_signature(method_name, endpoint_info)) + + # Docstring + lines.extend(self._generate_method_docstring(endpoint_info)) + + # Query parameters + has_query = any( + info["location"] == "query" + for info in endpoint_info["parameters"].values() + ) + if has_query: + query_lines = self._build_query_params(endpoint_info) + lines.extend(query_lines) + lines.append("") + + # URL construction + lines.append(self._build_path_formatting(endpoint_info["path"], endpoint_info)) + + # Request body + body_lines = self._build_request_body(endpoint_info) + if body_lines: + lines.append("") + lines.extend(body_lines) + + # Request construction and execution + lines.append("") + lines.append(" try:") + lines.append(" request = HTTPRequest(") + lines.append(f' method="{endpoint_info["method"]}",') + lines.append(" url=url,") + lines.append(' headers={"Content-Type": "application/json"},') + if has_query: + lines.append(" query=query_params,") + if body_lines: + lines.append(" body=body,") + lines.append(" )") + lines.extend([ + " response = await self.http.execute(request) # type: ignore[reportUnknownMemberType]", + " response_data = response.json() if response.text() else None", + " return IroncladResponse(", + " success=response.status < HTTP_ERROR_THRESHOLD,", + " data=response_data,", + f' message="Successfully executed {method_name}" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {{response.status}}"', + " )", + " except Exception as e:", + f' return IroncladResponse(success=False, error=str(e), message="Failed to execute {method_name}")', + ]) + + self.generated_methods.append({ + "name": method_name, + "endpoint": endpoint_info["path"], + "method": endpoint_info["method"], + "description": endpoint_info["description"], + }) + + return "\n".join(lines) + + def generate_ironclad_datasource(self) -> str: + """Generate the complete Ironclad datasource class.""" + + class_lines = [ + '"""', + "Ironclad REST API DataSource - Auto-generated API wrapper", + "", + "Generated from Ironclad REST API v1 documentation.", + "Uses HTTP client for direct REST API interactions.", + "All methods have explicit parameter signatures.", + '"""', + "", + "from __future__ import annotations", + "", + "from typing import Any", + "", + "from app.sources.client.ironclad.ironclad import IroncladClient, IroncladResponse", + "from app.sources.client.http.http_request import HTTPRequest", + "", + "# HTTP status code constant", + "HTTP_ERROR_THRESHOLD = 400", + "", + "", + "class IroncladDataSource:", + ' """Ironclad REST API DataSource', + "", + " Provides async wrapper methods for Ironclad REST API operations:", + " - Workflow management (list, get, launch, update)", + " - Workflow approvals", + " - Records management", + " - Templates", + " - Webhooks", + " - Users and Groups", + "", + " The base URL is determined by the IroncladClient's configuration.", + "", + " All methods return IroncladResponse objects.", + ' """', + "", + " def __init__(self, client: IroncladClient) -> None:", + ' """Initialize with IroncladClient.', + "", + " Args:", + " client: IroncladClient instance with configured authentication", + ' """', + " self._client = client", + " self.http = client.get_client()", + " try:", + " self.base_url = self.http.get_base_url().rstrip('/')", + " except AttributeError as exc:", + " raise ValueError('HTTP client does not have get_base_url method') from exc", + "", + " def get_data_source(self) -> 'IroncladDataSource':", + ' """Return the data source instance."""', + " return self", + "", + " def get_client(self) -> IroncladClient:", + ' """Return the underlying IroncladClient."""', + " return self._client", + "", + ] + + # Generate all API methods + for method_name, endpoint_info in IRONCLAD_API_ENDPOINTS.items(): + class_lines.append(self._generate_method(method_name, endpoint_info)) + class_lines.append("") + + return "\n".join(class_lines) + + def save_to_file(self, filename: Optional[str] = None) -> None: + """Generate and save the Ironclad datasource to a file.""" + if filename is None: + filename = "ironclad.py" + + script_dir = Path(__file__).parent if __file__ else Path(".") + ironclad_dir = script_dir.parent / "app" / "sources" / "external" / "ironclad" + ironclad_dir.mkdir(parents=True, exist_ok=True) + + full_path = ironclad_dir / filename + + class_code = self.generate_ironclad_datasource() + + full_path.write_text(class_code, encoding="utf-8") + + print(f"Generated Ironclad data source with {len(self.generated_methods)} methods") + print(f"Saved to: {full_path}") + + # Print summary by category + resource_categories = { + "Workflow": 0, + "Approval": 0, + "Record": 0, + "Template": 0, + "Webhook": 0, + "User": 0, + "Group": 0, + } + + for method in self.generated_methods: + name = method["name"] + if "approval" in name: + resource_categories["Approval"] += 1 + elif "workflow" in name: + resource_categories["Workflow"] += 1 + elif "record" in name: + resource_categories["Record"] += 1 + elif "template" in name: + resource_categories["Template"] += 1 + elif "webhook" in name: + resource_categories["Webhook"] += 1 + elif "user" in name: + resource_categories["User"] += 1 + elif "group" in name: + resource_categories["Group"] += 1 + + print(f"\nMethods by Resource:") + for category, count in resource_categories.items(): + if count > 0: + print(f" - {category}: {count}") + + +def main(): + """Main function for Ironclad data source generator.""" + import argparse + + parser = argparse.ArgumentParser( + description="Generate Ironclad REST API data source" + ) + parser.add_argument("--filename", "-f", help="Output filename (optional)") + + args = parser.parse_args() + + try: + generator = IroncladDataSourceGenerator() + generator.save_to_file(args.filename) + return 0 + except Exception as e: + print(f"Failed to generate Ironclad data source: {e}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/python/code-generator/jenkins.py b/backend/python/code-generator/jenkins.py new file mode 100644 index 000000000..247cc1ff8 --- /dev/null +++ b/backend/python/code-generator/jenkins.py @@ -0,0 +1,701 @@ +# ruff: noqa +""" +Jenkins REST API Code Generator + +Generates JenkinsDataSource class covering Jenkins REST API endpoints: +- Instance info and status +- Job management (info, build triggers, enable/disable) +- Build info and console output +- Build queue and nodes/agents +- Plugin management +- User info and views +- CSRF crumb issuer + +The generated DataSource accepts a JenkinsClient and uses the client's +configured Jenkins URL as the base URL. All endpoints append /api/json +for JSON responses where applicable. + +All methods have explicit parameter signatures with no **kwargs usage. + +Usage: + python code-generator/jenkins.py + python code-generator/jenkins.py --filename jenkins.py + +Output: + app/sources/external/jenkins/jenkins.py +""" + +import sys +from pathlib import Path +from typing import Dict, List, Optional + +# ================================================================================ +# Jenkins API Endpoints - organized by resource +# +# Each endpoint defines: +# method: HTTP verb +# path: URL path (appended to base_url which is the Jenkins instance URL) +# description: Human-readable description +# parameters: Dict of param_name -> {type, location (path/query/body), description} +# required: List of required parameter names +# ================================================================================ + +JENKINS_API_ENDPOINTS = { + # ================================================================================ + # INSTANCE INFO + # ================================================================================ + "get_instance_info": { + "method": "GET", + "path": "/api/json", + "description": "Get Jenkins instance information including jobs, views, and system status", + "parameters": { + "tree": {"type": "Optional[str]", "location": "query", "description": "Tree filter to limit response fields (e.g. 'jobs[name,url,color]')"}, + "depth": {"type": "Optional[int]", "location": "query", "description": "Depth of nested objects to return"}, + }, + "required": [], + }, + + # ================================================================================ + # CURRENT USER + # ================================================================================ + "get_current_user": { + "method": "GET", + "path": "/me/api/json", + "description": "Get the currently authenticated user information", + "parameters": { + "tree": {"type": "Optional[str]", "location": "query", "description": "Tree filter to limit response fields"}, + "depth": {"type": "Optional[int]", "location": "query", "description": "Depth of nested objects to return"}, + }, + "required": [], + }, + + # ================================================================================ + # USER INFO + # ================================================================================ + "get_user": { + "method": "GET", + "path": "/user/{username}/api/json", + "description": "Get information about a specific Jenkins user", + "parameters": { + "username": {"type": "str", "location": "path", "description": "The Jenkins username"}, + "tree": {"type": "Optional[str]", "location": "query", "description": "Tree filter to limit response fields"}, + "depth": {"type": "Optional[int]", "location": "query", "description": "Depth of nested objects to return"}, + }, + "required": ["username"], + }, + + # ================================================================================ + # JOBS + # ================================================================================ + "get_job_info": { + "method": "GET", + "path": "/job/{job_name}/api/json", + "description": "Get detailed information about a specific job", + "parameters": { + "job_name": {"type": "str", "location": "path", "description": "The job name (URL-encoded if contains slashes for folders)"}, + "tree": {"type": "Optional[str]", "location": "query", "description": "Tree filter to limit response fields"}, + "depth": {"type": "Optional[int]", "location": "query", "description": "Depth of nested objects to return"}, + }, + "required": ["job_name"], + }, + "trigger_build": { + "method": "POST", + "path": "/job/{job_name}/build", + "description": "Trigger a new build for a job without parameters", + "parameters": { + "job_name": {"type": "str", "location": "path", "description": "The job name"}, + }, + "required": ["job_name"], + }, + "trigger_parameterized_build": { + "method": "POST", + "path": "/job/{job_name}/buildWithParameters", + "description": "Trigger a new build for a parameterized job", + "parameters": { + "job_name": {"type": "str", "location": "path", "description": "The job name"}, + "parameters": {"type": "Optional[dict[str, Any]]", "location": "query", "description": "Build parameters as key-value pairs"}, + }, + "required": ["job_name"], + }, + "disable_job": { + "method": "POST", + "path": "/job/{job_name}/disable", + "description": "Disable a job to prevent new builds from being triggered", + "parameters": { + "job_name": {"type": "str", "location": "path", "description": "The job name"}, + }, + "required": ["job_name"], + }, + "enable_job": { + "method": "POST", + "path": "/job/{job_name}/enable", + "description": "Enable a previously disabled job", + "parameters": { + "job_name": {"type": "str", "location": "path", "description": "The job name"}, + }, + "required": ["job_name"], + }, + + # ================================================================================ + # BUILDS + # ================================================================================ + "get_build_info": { + "method": "GET", + "path": "/job/{job_name}/{build_number}/api/json", + "description": "Get detailed information about a specific build", + "parameters": { + "job_name": {"type": "str", "location": "path", "description": "The job name"}, + "build_number": {"type": "str", "location": "path", "description": "The build number"}, + "tree": {"type": "Optional[str]", "location": "query", "description": "Tree filter to limit response fields"}, + "depth": {"type": "Optional[int]", "location": "query", "description": "Depth of nested objects to return"}, + }, + "required": ["job_name", "build_number"], + }, + "get_last_build": { + "method": "GET", + "path": "/job/{job_name}/lastBuild/api/json", + "description": "Get information about the last build of a job", + "parameters": { + "job_name": {"type": "str", "location": "path", "description": "The job name"}, + "tree": {"type": "Optional[str]", "location": "query", "description": "Tree filter to limit response fields"}, + "depth": {"type": "Optional[int]", "location": "query", "description": "Depth of nested objects to return"}, + }, + "required": ["job_name"], + }, + "get_last_successful_build": { + "method": "GET", + "path": "/job/{job_name}/lastSuccessfulBuild/api/json", + "description": "Get information about the last successful build of a job", + "parameters": { + "job_name": {"type": "str", "location": "path", "description": "The job name"}, + "tree": {"type": "Optional[str]", "location": "query", "description": "Tree filter to limit response fields"}, + "depth": {"type": "Optional[int]", "location": "query", "description": "Depth of nested objects to return"}, + }, + "required": ["job_name"], + }, + "get_build_console_output": { + "method": "GET", + "path": "/job/{job_name}/{build_number}/consoleText", + "description": "Get the console output (build log) for a specific build as plain text", + "parameters": { + "job_name": {"type": "str", "location": "path", "description": "The job name"}, + "build_number": {"type": "str", "location": "path", "description": "The build number"}, + }, + "required": ["job_name", "build_number"], + }, + + # ================================================================================ + # BUILD QUEUE + # ================================================================================ + "get_build_queue": { + "method": "GET", + "path": "/queue/api/json", + "description": "Get the current build queue with all pending build items", + "parameters": { + "tree": {"type": "Optional[str]", "location": "query", "description": "Tree filter to limit response fields"}, + "depth": {"type": "Optional[int]", "location": "query", "description": "Depth of nested objects to return"}, + }, + "required": [], + }, + + # ================================================================================ + # NODES / AGENTS + # ================================================================================ + "get_nodes": { + "method": "GET", + "path": "/computer/api/json", + "description": "Get information about all nodes (build agents) including master", + "parameters": { + "tree": {"type": "Optional[str]", "location": "query", "description": "Tree filter to limit response fields"}, + "depth": {"type": "Optional[int]", "location": "query", "description": "Depth of nested objects to return"}, + }, + "required": [], + }, + + # ================================================================================ + # PLUGINS + # ================================================================================ + "get_plugins": { + "method": "GET", + "path": "/pluginManager/api/json", + "description": "Get information about all installed plugins", + "parameters": { + "tree": {"type": "Optional[str]", "location": "query", "description": "Tree filter to limit response fields (e.g. 'plugins[shortName,version,active]')"}, + "depth": {"type": "Optional[int]", "location": "query", "description": "Depth of nested objects to return"}, + }, + "required": [], + }, + + # ================================================================================ + # CSRF CRUMB + # ================================================================================ + "get_crumb": { + "method": "GET", + "path": "/crumbIssuer/api/json", + "description": "Get a CSRF crumb token required for POST requests on CSRF-protected Jenkins instances", + "parameters": {}, + "required": [], + }, + + # ================================================================================ + # VIEWS + # ================================================================================ + "get_view": { + "method": "GET", + "path": "/view/{view_name}/api/json", + "description": "Get information about a specific view including its jobs", + "parameters": { + "view_name": {"type": "str", "location": "path", "description": "The view name"}, + "tree": {"type": "Optional[str]", "location": "query", "description": "Tree filter to limit response fields"}, + "depth": {"type": "Optional[int]", "location": "query", "description": "Depth of nested objects to return"}, + }, + "required": ["view_name"], + }, +} + + +class JenkinsDataSourceGenerator: + """Generator for comprehensive Jenkins REST API datasource class. + + Generates methods for Jenkins API endpoints. + The generated DataSource class accepts a JenkinsClient whose + configured Jenkins URL is used as the base URL. + """ + + def __init__(self): + self.generated_methods: List[Dict[str, str]] = [] + + def _sanitize_parameter_name(self, name: str) -> str: + """Sanitize parameter names to be valid Python identifiers.""" + sanitized = name.replace("-", "_").replace(".", "_").replace("/", "_") + if sanitized and not (sanitized[0].isalpha() or sanitized[0] == "_"): + sanitized = f"param_{sanitized}" + return sanitized + + def _build_query_params(self, endpoint_info: Dict) -> List[str]: + """Build query parameter handling code.""" + lines = [" query_params: dict[str, Any] = {}"] + + for param_name, param_info in endpoint_info["parameters"].items(): + if param_info["location"] == "query": + sanitized_name = self._sanitize_parameter_name(param_name) + + if "Optional[bool]" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}'] = str({sanitized_name}).lower()", + ]) + elif "Optional[int]" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}'] = str({sanitized_name})", + ]) + elif "dict[" in param_info["type"]: + # For dict-type query params (like build parameters), + # merge them directly into query_params + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params.update({{str(k): str(v) for k, v in {sanitized_name}.items()}})", + ]) + elif "List[" in param_info["type"] or "list[" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}[]'] = {sanitized_name}", + ]) + else: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}'] = {sanitized_name}", + ]) + + return lines + + def _build_path_formatting(self, path: str, endpoint_info: Dict) -> str: + """Build URL path with parameter substitution.""" + path_params = [ + name + for name, info in endpoint_info["parameters"].items() + if info["location"] == "path" + ] + + if path_params: + format_dict = ", ".join( + f"{param}={self._sanitize_parameter_name(param)}" + for param in path_params + ) + return f' url = self.base_url + "{path}".format({format_dict})' + else: + return f' url = self.base_url + "{path}"' + + def _build_request_body(self, endpoint_info: Dict) -> List[str]: + """Build request body handling.""" + body_params = { + name: info + for name, info in endpoint_info["parameters"].items() + if info["location"] == "body" + } + + if not body_params: + return [] + + lines = [" body: dict[str, Any] = {}"] + + for param_name, param_info in body_params.items(): + sanitized_name = self._sanitize_parameter_name(param_name) + + if param_name in endpoint_info["required"]: + lines.append(f" body['{param_name}'] = {sanitized_name}") + else: + lines.extend([ + f" if {sanitized_name} is not None:", + f" body['{param_name}'] = {sanitized_name}", + ]) + + return lines + + @staticmethod + def _modernize_type(type_str: str) -> str: + """Convert typing-style annotations to modern Python 3.10+ syntax. + + Optional[str] -> str | None, Dict[str, Any] -> dict[str, Any], + List[str] -> list[str], etc. + """ + if type_str.startswith("Optional[") and type_str.endswith("]"): + inner = type_str[len("Optional["):-1] + inner = JenkinsDataSourceGenerator._modernize_type(inner) + return f"{inner} | None" + if type_str.startswith("Dict["): + inner = type_str[len("Dict["):-1] + parts = JenkinsDataSourceGenerator._split_type_args(inner) + modernized = ", ".join( + JenkinsDataSourceGenerator._modernize_type(p.strip()) for p in parts + ) + return f"dict[{modernized}]" + if type_str == "Dict": + return "dict" + if type_str.startswith("List["): + inner = type_str[len("List["):-1] + parts = JenkinsDataSourceGenerator._split_type_args(inner) + modernized = ", ".join( + JenkinsDataSourceGenerator._modernize_type(p.strip()) for p in parts + ) + return f"list[{modernized}]" + if type_str == "List": + return "list" + return type_str + + @staticmethod + def _split_type_args(s: str) -> List[str]: + """Split type arguments respecting nested brackets.""" + parts = [] + depth = 0 + current = "" + for ch in s: + if ch == "[": + depth += 1 + elif ch == "]": + depth -= 1 + if ch == "," and depth == 0: + parts.append(current.strip()) + current = "" + else: + current += ch + if current.strip(): + parts.append(current.strip()) + return parts + + def _generate_method_signature(self, method_name: str, endpoint_info: Dict) -> str: + """Generate method signature with explicit parameters.""" + params = ["self"] + has_any_bool = False + + # Collect required params, split into non-bool and bool groups + required_non_bool: List[str] = [] + required_bool: List[str] = [] + for param_name in endpoint_info["required"]: + if param_name in endpoint_info["parameters"]: + param_info = endpoint_info["parameters"][param_name] + sanitized_name = self._sanitize_parameter_name(param_name) + modern_type = self._modernize_type(param_info["type"]) + param_str = f"{sanitized_name}: {modern_type}" + if "bool" in param_info.get("type", ""): + required_bool.append(param_str) + has_any_bool = True + else: + required_non_bool.append(param_str) + + # Collect optional parameters + optional_params: List[str] = [] + for param_name, param_info in endpoint_info["parameters"].items(): + if param_name not in endpoint_info["required"]: + sanitized_name = self._sanitize_parameter_name(param_name) + modern_type = self._modernize_type(param_info["type"]) + if "| None" not in modern_type: + modern_type = f"{modern_type} | None" + optional_params.append(f"{sanitized_name}: {modern_type} = None") + if "bool" in param_info.get("type", ""): + has_any_bool = True + + # Build signature: non-bool required first, then * if needed, then bool required + optional + params.extend(required_non_bool) + if has_any_bool and (required_bool or optional_params): + params.append("*") + params.extend(required_bool) + params.extend(optional_params) + + signature_params = ",\n ".join(params) + return f" async def {method_name}(\n {signature_params}\n ) -> JenkinsResponse:" + + def _generate_method_docstring(self, endpoint_info: Dict) -> List[str]: + """Generate method docstring.""" + lines = [f' """{endpoint_info["description"]}', ""] + + if endpoint_info["parameters"]: + lines.append(" Args:") + for param_name, param_info in endpoint_info["parameters"].items(): + sanitized_name = self._sanitize_parameter_name(param_name) + lines.append( + f" {sanitized_name}: {param_info['description']}" + ) + lines.append("") + + lines.extend([ + " Returns:", + " JenkinsResponse with operation result", + ' """', + ]) + + return lines + + def _generate_method(self, method_name: str, endpoint_info: Dict) -> str: + """Generate a complete method for an API endpoint.""" + lines = [] + + # Method signature + lines.append(self._generate_method_signature(method_name, endpoint_info)) + + # Docstring + lines.extend(self._generate_method_docstring(endpoint_info)) + + # Query parameters + has_query = any( + info["location"] == "query" + for info in endpoint_info["parameters"].values() + ) + if has_query: + query_lines = self._build_query_params(endpoint_info) + lines.extend(query_lines) + lines.append("") + + # URL construction + lines.append(self._build_path_formatting(endpoint_info["path"], endpoint_info)) + + # Request body + body_lines = self._build_request_body(endpoint_info) + if body_lines: + lines.append("") + lines.extend(body_lines) + + # Determine if this endpoint returns plain text (consoleText) + is_text_response = "consoleText" in endpoint_info["path"] + + # Request construction and execution + lines.append("") + lines.append(" try:") + lines.append(" request = HTTPRequest(") + lines.append(f' method="{endpoint_info["method"]}",') + lines.append(" url=url,") + lines.append(' headers={"Content-Type": "application/json"},') + if has_query: + lines.append(" query=query_params,") + if body_lines: + lines.append(" body=body,") + lines.append(" )") + + lines.append(" response = await self.http.execute(request) # type: ignore[reportUnknownMemberType]") + + if is_text_response: + # Console output returns plain text, not JSON + lines.extend([ + ' text_data = response.text() or ""', + " return JenkinsResponse(", + " success=response.status < HTTP_ERROR_THRESHOLD,", + ' data={"console_output": text_data},', + f' message="Successfully executed {method_name}" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {{response.status}}"', + " )", + ]) + else: + lines.extend([ + " response_data = response.json() if response.text() else None", + " return JenkinsResponse(", + " success=response.status < HTTP_ERROR_THRESHOLD,", + " data=response_data,", + f' message="Successfully executed {method_name}" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {{response.status}}"', + " )", + ]) + + lines.extend([ + " except Exception as e:", + f' return JenkinsResponse(success=False, error=str(e), message="Failed to execute {method_name}")', + ]) + + self.generated_methods.append({ + "name": method_name, + "endpoint": endpoint_info["path"], + "method": endpoint_info["method"], + "description": endpoint_info["description"], + }) + + return "\n".join(lines) + + def generate_jenkins_datasource(self) -> str: + """Generate the complete Jenkins datasource class.""" + + class_lines = [ + '"""', + "Jenkins REST API DataSource - Auto-generated API wrapper", + "", + "Generated from Jenkins REST API documentation.", + "Uses HTTP client for direct REST API interactions.", + "All methods have explicit parameter signatures.", + '"""', + "", + "from __future__ import annotations", + "", + "from typing import Any", + "", + "from app.sources.client.http.http_request import HTTPRequest", + "from app.sources.client.jenkins.jenkins import JenkinsClient, JenkinsResponse", + "", + "# HTTP status code constant", + "HTTP_ERROR_THRESHOLD = 400", + "", + "", + "class JenkinsDataSource:", + ' """Jenkins REST API DataSource', + "", + " Provides async wrapper methods for Jenkins REST API operations:", + " - Instance info and system status", + " - Job management (info, triggers, enable/disable)", + " - Build info and console output", + " - Build queue management", + " - Node/agent monitoring", + " - Plugin management", + " - User info and views", + " - CSRF crumb retrieval", + "", + " The base URL is the Jenkins instance URL configured in the JenkinsClient.", + "", + " All methods return JenkinsResponse objects.", + ' """', + "", + " def __init__(self, client: JenkinsClient) -> None:", + ' """Initialize with JenkinsClient.', + "", + " Args:", + " client: JenkinsClient instance with configured authentication", + ' """', + " self._client = client", + " self.http = client.get_client()", + " try:", + " self.base_url = self.http.get_base_url().rstrip('/')", + " except AttributeError as exc:", + " raise ValueError('HTTP client does not have get_base_url method') from exc", + "", + " def get_data_source(self) -> 'JenkinsDataSource':", + ' """Return the data source instance."""', + " return self", + "", + " def get_client(self) -> JenkinsClient:", + ' """Return the underlying JenkinsClient."""', + " return self._client", + "", + ] + + # Generate all API methods + for method_name, endpoint_info in JENKINS_API_ENDPOINTS.items(): + class_lines.append(self._generate_method(method_name, endpoint_info)) + class_lines.append("") + + return "\n".join(class_lines) + + def save_to_file(self, filename: Optional[str] = None) -> None: + """Generate and save the Jenkins datasource to a file.""" + if filename is None: + filename = "jenkins.py" + + script_dir = Path(__file__).parent if __file__ else Path(".") + jenkins_dir = script_dir.parent / "app" / "sources" / "external" / "jenkins" + jenkins_dir.mkdir(parents=True, exist_ok=True) + + full_path = jenkins_dir / filename + + class_code = self.generate_jenkins_datasource() + + full_path.write_text(class_code, encoding="utf-8") + + print(f"Generated Jenkins data source with {len(self.generated_methods)} methods") + print(f"Saved to: {full_path}") + + # Print summary by category + resource_categories = { + "Instance": 0, + "User": 0, + "Job": 0, + "Build": 0, + "Queue": 0, + "Node": 0, + "Plugin": 0, + "CSRF": 0, + "View": 0, + } + + for method in self.generated_methods: + name = method["name"] + if "instance" in name: + resource_categories["Instance"] += 1 + elif "user" in name or "current" in name: + resource_categories["User"] += 1 + elif "build" in name and "queue" not in name: + resource_categories["Build"] += 1 + elif "job" in name or "trigger" in name or "disable" in name or "enable" in name: + resource_categories["Job"] += 1 + elif "queue" in name: + resource_categories["Queue"] += 1 + elif "node" in name: + resource_categories["Node"] += 1 + elif "plugin" in name: + resource_categories["Plugin"] += 1 + elif "crumb" in name: + resource_categories["CSRF"] += 1 + elif "view" in name: + resource_categories["View"] += 1 + + print(f"\nMethods by Resource:") + for category, count in resource_categories.items(): + if count > 0: + print(f" - {category}: {count}") + + +def main(): + """Main function for Jenkins data source generator.""" + import argparse + + parser = argparse.ArgumentParser( + description="Generate Jenkins REST API data source" + ) + parser.add_argument("--filename", "-f", help="Output filename (optional)") + + args = parser.parse_args() + + try: + generator = JenkinsDataSourceGenerator() + generator.save_to_file(args.filename) + return 0 + except Exception as e: + print(f"Failed to generate Jenkins data source: {e}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/python/code-generator/lucid.py b/backend/python/code-generator/lucid.py new file mode 100644 index 000000000..06d67a9dc --- /dev/null +++ b/backend/python/code-generator/lucid.py @@ -0,0 +1,594 @@ +# ruff: noqa +""" +Lucid REST API Code Generator + +Generates LucidDataSource class covering Lucid API v1: +- User profile operations +- Document management (list, get, create, delete) +- Folder management (list, get, create, folder documents) +- Page listing +- User listing +- Data source operations + +The generated DataSource accepts a LucidClient and uses the client's +configured base URL. Methods are generated for all API endpoints. + +All methods have explicit parameter signatures with no **kwargs usage. +""" + +import sys +from pathlib import Path +from typing import Dict, List, Optional + +# ================================================================================ +# Lucid API Endpoints - organized by resource +# +# Each endpoint defines: +# method: HTTP verb +# path: URL path (appended to base_url which is https://api.lucid.co/v1) +# description: Human-readable description +# parameters: Dict of param_name -> {type, location (path/query/body), description} +# required: List of required parameter names +# version: API version tag +# ================================================================================ + +LUCID_API_ENDPOINTS = { + # ================================================================================ + # USERS + # ================================================================================ + "get_current_user": { + "method": "GET", + "path": "/users/me", + "description": "Get the current authenticated user details", + "parameters": {}, + "required": [], + "version": "v1", + }, + "list_users": { + "method": "GET", + "path": "/users", + "description": "List users in the account", + "parameters": { + "pageSize": {"type": "Optional[int]", "location": "query", "description": "Number of results per page"}, + "cursor": {"type": "Optional[str]", "location": "query", "description": "Cursor for pagination"}, + }, + "required": [], + "version": "v1", + }, + + # ================================================================================ + # DOCUMENTS + # ================================================================================ + "list_documents": { + "method": "GET", + "path": "/documents", + "description": "List all documents accessible to the authenticated user", + "parameters": { + "pageSize": {"type": "Optional[int]", "location": "query", "description": "Number of results per page"}, + "cursor": {"type": "Optional[str]", "location": "query", "description": "Cursor for pagination"}, + "product": {"type": "Optional[str]", "location": "query", "description": "Filter by product (e.g., lucidchart, lucidspark)"}, + }, + "required": [], + "version": "v1", + }, + "get_document": { + "method": "GET", + "path": "/documents/{documentId}", + "description": "Get a specific document by ID", + "parameters": { + "documentId": {"type": "str", "location": "path", "description": "The document ID"}, + }, + "required": ["documentId"], + "version": "v1", + }, + "create_document": { + "method": "POST", + "path": "/documents", + "description": "Create a new document", + "parameters": { + "title": {"type": "Optional[str]", "location": "body", "description": "The title of the document"}, + "product": {"type": "Optional[str]", "location": "body", "description": "The product type (e.g., lucidchart, lucidspark)"}, + "folderId": {"type": "Optional[str]", "location": "body", "description": "The folder ID to create the document in"}, + }, + "required": [], + "version": "v1", + }, + "delete_document": { + "method": "DELETE", + "path": "/documents/{documentId}", + "description": "Delete a document by ID", + "parameters": { + "documentId": {"type": "str", "location": "path", "description": "The document ID to delete"}, + }, + "required": ["documentId"], + "version": "v1", + }, + + # ================================================================================ + # FOLDERS + # ================================================================================ + "list_folders": { + "method": "GET", + "path": "/folders", + "description": "List all folders accessible to the authenticated user", + "parameters": { + "pageSize": {"type": "Optional[int]", "location": "query", "description": "Number of results per page"}, + "cursor": {"type": "Optional[str]", "location": "query", "description": "Cursor for pagination"}, + }, + "required": [], + "version": "v1", + }, + "get_folder": { + "method": "GET", + "path": "/folders/{folderId}", + "description": "Get a specific folder by ID", + "parameters": { + "folderId": {"type": "str", "location": "path", "description": "The folder ID"}, + }, + "required": ["folderId"], + "version": "v1", + }, + "list_folder_documents": { + "method": "GET", + "path": "/folders/{folderId}/documents", + "description": "List documents in a specific folder", + "parameters": { + "folderId": {"type": "str", "location": "path", "description": "The folder ID"}, + "pageSize": {"type": "Optional[int]", "location": "query", "description": "Number of results per page"}, + "cursor": {"type": "Optional[str]", "location": "query", "description": "Cursor for pagination"}, + }, + "required": ["folderId"], + "version": "v1", + }, + "create_folder": { + "method": "POST", + "path": "/folders", + "description": "Create a new folder", + "parameters": { + "name": {"type": "str", "location": "body", "description": "The name of the folder"}, + "parentFolderId": {"type": "Optional[str]", "location": "body", "description": "The parent folder ID"}, + }, + "required": ["name"], + "version": "v1", + }, + + # ================================================================================ + # PAGES + # ================================================================================ + "list_pages": { + "method": "GET", + "path": "/pages/{documentId}", + "description": "List all pages in a document", + "parameters": { + "documentId": {"type": "str", "location": "path", "description": "The document ID"}, + }, + "required": ["documentId"], + "version": "v1", + }, + + # ================================================================================ + # DATA SOURCES + # ================================================================================ + "list_data_sources": { + "method": "GET", + "path": "/data-sources", + "description": "List all data sources", + "parameters": {}, + "required": [], + "version": "v1", + }, + "get_data_source_by_id": { + "method": "GET", + "path": "/data-sources/{dataSourceId}", + "description": "Get a specific data source by ID", + "parameters": { + "dataSourceId": {"type": "str", "location": "path", "description": "The data source ID"}, + }, + "required": ["dataSourceId"], + "version": "v1", + }, +} + + +class LucidDataSourceGenerator: + """Generator for comprehensive Lucid REST API datasource class. + + Generates methods for Lucid API v1 endpoints. + The generated DataSource class accepts a LucidClient whose base URL + is https://api.lucid.co/v1. + """ + + def __init__(self): + self.generated_methods: List[Dict[str, str]] = [] + + def _sanitize_parameter_name(self, name: str) -> str: + """Sanitize parameter names to be valid Python identifiers.""" + sanitized = name.replace("-", "_").replace(".", "_").replace("/", "_") + if sanitized and not (sanitized[0].isalpha() or sanitized[0] == "_"): + sanitized = f"param_{sanitized}" + return sanitized + + def _build_query_params(self, endpoint_info: Dict) -> List[str]: + """Build query parameter handling code.""" + lines = [" query_params: dict[str, Any] = {}"] + + for param_name, param_info in endpoint_info["parameters"].items(): + if param_info["location"] == "query": + sanitized_name = self._sanitize_parameter_name(param_name) + + if "Optional[bool]" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}'] = str({sanitized_name}).lower()", + ]) + elif "Optional[int]" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}'] = str({sanitized_name})", + ]) + elif "List[" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}[]'] = {sanitized_name}", + ]) + else: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}'] = {sanitized_name}", + ]) + + return lines + + def _build_path_formatting(self, path: str, endpoint_info: Dict) -> str: + """Build URL path with parameter substitution.""" + path_params = [ + name + for name, info in endpoint_info["parameters"].items() + if info["location"] == "path" + ] + + if path_params: + format_dict = ", ".join( + f"{param}={self._sanitize_parameter_name(param)}" + for param in path_params + ) + return f' url = self.base_url + "{path}".format({format_dict})' + else: + return f' url = self.base_url + "{path}"' + + def _build_request_body(self, endpoint_info: Dict) -> List[str]: + """Build request body handling.""" + body_params = { + name: info + for name, info in endpoint_info["parameters"].items() + if info["location"] == "body" + } + + if not body_params: + return [] + + lines = [" body: dict[str, Any] = {}"] + + for param_name, param_info in body_params.items(): + sanitized_name = self._sanitize_parameter_name(param_name) + + if param_name in endpoint_info["required"]: + lines.append(f" body['{param_name}'] = {sanitized_name}") + else: + lines.extend([ + f" if {sanitized_name} is not None:", + f" body['{param_name}'] = {sanitized_name}", + ]) + + return lines + + @staticmethod + def _modernize_type(type_str: str) -> str: + """Convert typing-style annotations to modern Python 3.10+ syntax.""" + if type_str.startswith("Optional[") and type_str.endswith("]"): + inner = type_str[len("Optional["):-1] + inner = LucidDataSourceGenerator._modernize_type(inner) + return f"{inner} | None" + if type_str.startswith("Dict["): + inner = type_str[len("Dict["):-1] + parts = LucidDataSourceGenerator._split_type_args(inner) + modernized = ", ".join( + LucidDataSourceGenerator._modernize_type(p.strip()) for p in parts + ) + return f"dict[{modernized}]" + if type_str == "Dict": + return "dict" + if type_str.startswith("List["): + inner = type_str[len("List["):-1] + parts = LucidDataSourceGenerator._split_type_args(inner) + modernized = ", ".join( + LucidDataSourceGenerator._modernize_type(p.strip()) for p in parts + ) + return f"list[{modernized}]" + if type_str == "List": + return "list" + return type_str + + @staticmethod + def _split_type_args(s: str) -> List[str]: + """Split type arguments respecting nested brackets.""" + parts = [] + depth = 0 + current = "" + for ch in s: + if ch == "[": + depth += 1 + elif ch == "]": + depth -= 1 + if ch == "," and depth == 0: + parts.append(current.strip()) + current = "" + else: + current += ch + if current.strip(): + parts.append(current.strip()) + return parts + + def _generate_method_signature(self, method_name: str, endpoint_info: Dict) -> str: + """Generate method signature with explicit parameters.""" + params = ["self"] + has_any_bool = False + + # Collect required params, split into non-bool and bool groups + required_non_bool: List[str] = [] + required_bool: List[str] = [] + for param_name in endpoint_info["required"]: + if param_name in endpoint_info["parameters"]: + param_info = endpoint_info["parameters"][param_name] + sanitized_name = self._sanitize_parameter_name(param_name) + modern_type = self._modernize_type(param_info["type"]) + param_str = f"{sanitized_name}: {modern_type}" + if "bool" in param_info.get("type", ""): + required_bool.append(param_str) + has_any_bool = True + else: + required_non_bool.append(param_str) + + # Collect optional parameters + optional_params: List[str] = [] + for param_name, param_info in endpoint_info["parameters"].items(): + if param_name not in endpoint_info["required"]: + sanitized_name = self._sanitize_parameter_name(param_name) + modern_type = self._modernize_type(param_info["type"]) + if "| None" not in modern_type: + modern_type = f"{modern_type} | None" + optional_params.append(f"{sanitized_name}: {modern_type} = None") + if "bool" in param_info.get("type", ""): + has_any_bool = True + + # Build signature: non-bool required first, then * if needed, then bool required + optional + params.extend(required_non_bool) + if has_any_bool and (required_bool or optional_params): + params.append("*") + params.extend(required_bool) + params.extend(optional_params) + + signature_params = ",\n ".join(params) + return f" async def {method_name}(\n {signature_params}\n ) -> LucidResponse:" + + def _generate_method_docstring(self, endpoint_info: Dict) -> List[str]: + """Generate method docstring.""" + version = endpoint_info.get("version", "v1") + lines = [f' """{endpoint_info["description"]} (API {version})', ""] + + if endpoint_info["parameters"]: + lines.append(" Args:") + for param_name, param_info in endpoint_info["parameters"].items(): + sanitized_name = self._sanitize_parameter_name(param_name) + lines.append( + f" {sanitized_name}: {param_info['description']}" + ) + lines.append("") + + lines.extend([ + " Returns:", + " LucidResponse with operation result", + ' """', + ]) + + return lines + + def _generate_method(self, method_name: str, endpoint_info: Dict) -> str: + """Generate a complete method for an API endpoint.""" + lines = [] + + # Method signature + lines.append(self._generate_method_signature(method_name, endpoint_info)) + + # Docstring + lines.extend(self._generate_method_docstring(endpoint_info)) + + # Query parameters + has_query = any( + info["location"] == "query" + for info in endpoint_info["parameters"].values() + ) + if has_query: + query_lines = self._build_query_params(endpoint_info) + lines.extend(query_lines) + lines.append("") + + # URL construction + lines.append(self._build_path_formatting(endpoint_info["path"], endpoint_info)) + + # Request body + body_lines = self._build_request_body(endpoint_info) + if body_lines: + lines.append("") + lines.extend(body_lines) + + # Request construction and execution + lines.append("") + lines.append(" try:") + lines.append(" request = HTTPRequest(") + lines.append(f' method="{endpoint_info["method"]}",') + lines.append(" url=url,") + lines.append(' headers={"Content-Type": "application/json"},') + if has_query: + lines.append(" query=query_params,") + if body_lines: + lines.append(" body=body,") + lines.append(" )") + lines.extend([ + " response = await self.http.execute(request) # type: ignore[reportUnknownMemberType]", + " response_data = response.json() if response.text() else None", + " return LucidResponse(", + " success=response.status < HTTP_ERROR_THRESHOLD,", + " data=response_data,", + f' message="Successfully executed {method_name}" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {{response.status}}"', + " )", + " except Exception as e:", + f' return LucidResponse(success=False, error=str(e), message="Failed to execute {method_name}")', + ]) + + self.generated_methods.append({ + "name": method_name, + "endpoint": endpoint_info["path"], + "method": endpoint_info["method"], + "description": endpoint_info["description"], + "version": endpoint_info.get("version", "v1"), + }) + + return "\n".join(lines) + + def generate_lucid_datasource(self) -> str: + """Generate the complete Lucid datasource class.""" + + class_lines = [ + '"""', + "Lucid REST API DataSource - Auto-generated API wrapper", + "", + "Generated from Lucid REST API v1 documentation.", + "Uses HTTP client for direct REST API interactions.", + "All methods have explicit parameter signatures.", + '"""', + "", + "from __future__ import annotations", + "", + "from typing import Any", + "", + "from app.sources.client.lucid.lucid import LucidClient, LucidResponse", + "from app.sources.client.http.http_request import HTTPRequest", + "", + "# HTTP status code constant", + "HTTP_ERROR_THRESHOLD = 400", + "", + "", + "class LucidDataSource:", + ' """Lucid REST API DataSource', + "", + " Provides async wrapper methods for Lucid REST API operations:", + " - User profile management", + " - Document CRUD operations", + " - Folder management", + " - Page listing", + " - Data source operations", + "", + " The base URL is https://api.lucid.co/v1.", + "", + " All methods return LucidResponse objects.", + ' """', + "", + " def __init__(self, client: LucidClient) -> None:", + ' """Initialize with LucidClient.', + "", + " Args:", + " client: LucidClient instance with configured authentication", + ' """', + " self._client = client", + " self.http = client.get_client()", + " try:", + " self.base_url = self.http.get_base_url().rstrip('/')", + " except AttributeError as exc:", + " raise ValueError('HTTP client does not have get_base_url method') from exc", + "", + " def get_data_source(self) -> 'LucidDataSource':", + ' """Return the data source instance."""', + " return self", + "", + " def get_client(self) -> LucidClient:", + ' """Return the underlying LucidClient."""', + " return self._client", + "", + ] + + # Generate all API methods + for method_name, endpoint_info in LUCID_API_ENDPOINTS.items(): + class_lines.append(self._generate_method(method_name, endpoint_info)) + class_lines.append("") + + return "\n".join(class_lines) + + def save_to_file(self, filename: Optional[str] = None) -> None: + """Generate and save the Lucid datasource to a file.""" + if filename is None: + filename = "lucid.py" + + script_dir = Path(__file__).parent if __file__ else Path(".") + lucid_dir = script_dir.parent / "app" / "sources" / "external" / "lucid" + lucid_dir.mkdir(parents=True, exist_ok=True) + + full_path = lucid_dir / filename + + class_code = self.generate_lucid_datasource() + + full_path.write_text(class_code, encoding="utf-8") + + print(f"Generated Lucid data source with {len(self.generated_methods)} methods") + print(f"Saved to: {full_path}") + + # Print summary by category + resource_categories = { + "User": 0, + "Document": 0, + "Folder": 0, + "Page": 0, + "Data Source": 0, + } + + for method in self.generated_methods: + name = method["name"] + if "user" in name: + resource_categories["User"] += 1 + elif "document" in name: + resource_categories["Document"] += 1 + elif "folder" in name: + resource_categories["Folder"] += 1 + elif "page" in name: + resource_categories["Page"] += 1 + elif "data_source" in name: + resource_categories["Data Source"] += 1 + + print(f"\nMethods by Resource:") + for category, count in resource_categories.items(): + if count > 0: + print(f" - {category}: {count}") + + +def main(): + """Main function for Lucid data source generator.""" + import argparse + + parser = argparse.ArgumentParser( + description="Generate Lucid REST API data source" + ) + parser.add_argument("--filename", "-f", help="Output filename (optional)") + + args = parser.parse_args() + + try: + generator = LucidDataSourceGenerator() + generator.save_to_file(args.filename) + return 0 + except Exception as e: + print(f"Failed to generate Lucid data source: {e}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/python/code-generator/miro.py b/backend/python/code-generator/miro.py new file mode 100644 index 000000000..c8e7df159 --- /dev/null +++ b/backend/python/code-generator/miro.py @@ -0,0 +1,321 @@ +#!/usr/bin/env python3 +# ruff: noqa +from __future__ import annotations + +""" +Miro (miro_api SDK) -- Code Generator (strict, no `Any` in public signatures) + +Emits a `MiroDataSource` with explicit, typed methods mapped to *real* miro_api SDK APIs. +- Wraps the official `MiroApi` class from the `miro_api` package. +- Accepts either a raw `MiroApi` instance or any client exposing `.get_sdk() -> MiroApi`. +- All methods return `MiroResponse` for a uniform interface. + +SDK reference: https://miroapp.github.io/api-clients/python/ +""" + +import argparse +import textwrap +from pathlib import Path +from typing import List, Tuple + +# ----------------------------- +# Configuration knobs (CLI-set) +# ----------------------------- + +DEFAULT_RESPONSE_IMPORT = ( + "from app.sources.client.miro.miro import MiroResponse" +) +DEFAULT_CLASS_NAME = "MiroDataSource" +DEFAULT_OUT = "app/sources/external/miro/miro.py" + + +HEADER = '''\ +# ruff: noqa +from __future__ import annotations + +from miro_api import MiroApi +from typing import Dict, List, Optional, Union, cast + +{response_import} + + +class {class_name}: + """ + Strict, typed wrapper over the official miro_api SDK for common Miro + business operations. + + Accepts either a `MiroApi` instance *or* any object with + `.get_sdk() -> MiroApi`. + + All methods return `MiroResponse` for a uniform success/error envelope. + """ + + def __init__(self, client_or_sdk: Union[MiroApi, object]) -> None: + # Support a raw SDK or a wrapper that exposes `.get_sdk()` + if hasattr(client_or_sdk, "get_sdk"): + sdk_obj = getattr(client_or_sdk, "get_sdk")() + self._sdk: MiroApi = cast(MiroApi, sdk_obj) + else: + self._sdk = cast(MiroApi, client_or_sdk) + + @staticmethod + def _params(**kwargs: object) -> Dict[str, object]: + """Filter out None values to avoid overriding SDK defaults.""" + out: Dict[str, object] = {} + for k, v in kwargs.items(): + if v is None: + continue + if isinstance(v, (list, dict)) and len(v) == 0: + continue + out[k] = v + return out +''' + +FOOTER = """ +""" + +# Each tuple: (signature, body, short_doc) +METHODS: List[Tuple[str, str, str]] = [] + +# ---------- Boards ---------- +METHODS += [ + ( + "list_boards(self, team_id: Optional[str] = None, query: Optional[str] = None, owner: Optional[str] = None, sort: Optional[str] = None, limit: Optional[str] = None, offset: Optional[str] = None) -> MiroResponse", + " params = self._params(team_id=team_id, query=query, owner=owner, sort=sort, limit=limit, offset=offset)\n" + " result = self._sdk.get_boards(**params)\n" + " return MiroResponse(success=True, data=result)", + "List boards accessible to the authenticated user. [boards]", + ), + ( + "get_board(self, board_id: str) -> MiroResponse", + " result = self._sdk.get_specific_board(board_id)\n" + " return MiroResponse(success=True, data=result)", + "Get a single board by ID. [boards]", + ), + ( + "create_board(self, board_changes: object) -> MiroResponse", + " result = self._sdk.create_board(board_changes)\n" + " return MiroResponse(success=True, data=result)", + "Create a new board. Pass a BoardChanges model or compatible dict. [boards]", + ), + ( + "update_board(self, board_id: str, board_changes: object) -> MiroResponse", + " result = self._sdk.update_board(board_id, board_changes)\n" + " return MiroResponse(success=True, data=result)", + "Update an existing board. [boards]", + ), + ( + "delete_board(self, board_id: str) -> MiroResponse", + " self._sdk.delete_board(board_id)\n" + " return MiroResponse(success=True, data=True)", + "Delete a board by ID. [boards]", + ), +] + +# ---------- Board Items ---------- +METHODS += [ + ( + "list_board_items(self, board_id: str, limit: Optional[str] = None, type: Optional[str] = None, cursor: Optional[str] = None) -> MiroResponse", + " params = self._params(limit=limit, type=type, cursor=cursor)\n" + " result = self._sdk.get_items(board_id, **params)\n" + " return MiroResponse(success=True, data=result)", + "List all items on a board with optional filters. [items]", + ), + ( + "get_board_item(self, board_id: str, item_id: str) -> MiroResponse", + " result = self._sdk.get_specific_item(board_id, item_id)\n" + " return MiroResponse(success=True, data=result)", + "Get a specific item on a board. [items]", + ), +] + +# ---------- Sticky Notes ---------- +METHODS += [ + ( + "create_sticky_note(self, board_id: str, sticky_note_create_request: object) -> MiroResponse", + " result = self._sdk.create_sticky_note_item(board_id, sticky_note_create_request)\n" + " return MiroResponse(success=True, data=result)", + "Create a sticky note on a board. Pass a StickyNoteCreateRequest model. [sticky_notes]", + ), +] + +# ---------- Cards ---------- +METHODS += [ + ( + "create_card(self, board_id: str, card_create_request: object) -> MiroResponse", + " result = self._sdk.create_card_item(board_id, card_create_request)\n" + " return MiroResponse(success=True, data=result)", + "Create a card on a board. Pass a CardCreateRequest model. [cards]", + ), +] + +# ---------- Text ---------- +METHODS += [ + ( + "create_text(self, board_id: str, text_create_request: object) -> MiroResponse", + " result = self._sdk.create_text_item(board_id, text_create_request)\n" + " return MiroResponse(success=True, data=result)", + "Create a text item on a board. Pass a TextCreateRequest model. [text]", + ), +] + +# ---------- Shapes ---------- +METHODS += [ + ( + "create_shape(self, board_id: str, shape_create_request: object) -> MiroResponse", + " result = self._sdk.create_shape_item(board_id, shape_create_request)\n" + " return MiroResponse(success=True, data=result)", + "Create a shape on a board. Pass a ShapeCreateRequest model. [shapes]", + ), +] + +# ---------- Connectors ---------- +METHODS += [ + ( + "list_connectors(self, board_id: str, limit: Optional[str] = None, cursor: Optional[str] = None) -> MiroResponse", + " params = self._params(limit=limit, cursor=cursor)\n" + " result = self._sdk.get_connectors(board_id, **params)\n" + " return MiroResponse(success=True, data=result)", + "List all connectors on a board. [connectors]", + ), + ( + "create_connector(self, board_id: str, connector_creation_data: object) -> MiroResponse", + " result = self._sdk.create_connector(board_id, connector_creation_data)\n" + " return MiroResponse(success=True, data=result)", + "Create a connector between two items on a board. [connectors]", + ), +] + +# ---------- Board Members ---------- +METHODS += [ + ( + "list_board_members(self, board_id: str, limit: Optional[str] = None, offset: Optional[str] = None) -> MiroResponse", + " params = self._params(limit=limit, offset=offset)\n" + " result = self._sdk.get_board_members(board_id, **params)\n" + " return MiroResponse(success=True, data=result)", + "List all members of a board. [members]", + ), + ( + "share_board(self, board_id: str, board_members_invite: object) -> MiroResponse", + " result = self._sdk.share_board(board_id, board_members_invite)\n" + " return MiroResponse(success=True, data=result)", + "Share a board by inviting members. Pass a BoardMembersInvite model. [members]", + ), +] + +# ---------- Tags ---------- +METHODS += [ + ( + "list_board_tags(self, board_id: str, limit: Optional[str] = None, offset: Optional[str] = None) -> MiroResponse", + " params = self._params(limit=limit, offset=offset)\n" + " result = self._sdk.get_tags_from_board(board_id, **params)\n" + " return MiroResponse(success=True, data=result)", + "List all tags on a board. [tags]", + ), + ( + "create_tag(self, board_id: str, tag_create_request: object) -> MiroResponse", + " result = self._sdk.create_tag(board_id, tag_create_request)\n" + " return MiroResponse(success=True, data=result)", + "Create a tag on a board. Pass a TagCreateRequest model. [tags]", + ), +] + +# ---------- Frames ---------- +METHODS += [ + ( + "list_frames(self, board_id: str, limit: Optional[str] = None, type: Optional[str] = None, cursor: Optional[str] = None) -> MiroResponse", + " params = self._params(limit=limit, type=type, cursor=cursor)\n" + " result = self._sdk.get_items(board_id, **params)\n" + " return MiroResponse(success=True, data=result)", + "List items on a board (use type='frame' to filter frames). [frames]", + ), +] + +# ---------- Organizations ---------- +METHODS += [ + ( + "list_organizations(self, org_id: str) -> MiroResponse", + " result = self._sdk.enterprise_get_organization(org_id)\n" + " return MiroResponse(success=True, data=result)", + "Get organization details by ID. Requires enterprise plan. [organizations]", + ), + ( + "list_org_members(self, org_id: str, role: Optional[str] = None, limit: Optional[int] = None, cursor: Optional[str] = None) -> MiroResponse", + " params = self._params(role=role, limit=limit, cursor=cursor)\n" + " result = self._sdk.enterprise_get_organization_members(org_id, **params)\n" + " return MiroResponse(success=True, data=result)", + "List members of an organization. Requires enterprise plan. [organizations]", + ), +] + +# ------------------------- +# Code emission utilities +# ------------------------- + + +def _emit_method(sig: str, body: str, doc: str) -> str: + normalized_body = textwrap.indent(textwrap.dedent(body), " ") + return f' def {sig}:\n """{doc}"""\n{normalized_body}\n' + + +def build_class( + response_import: str = DEFAULT_RESPONSE_IMPORT, + class_name: str = DEFAULT_CLASS_NAME, +) -> str: + parts: List[str] = [] + header = ( + HEADER.replace("{response_import}", response_import) + .replace("{class_name}", class_name) + ) + parts.append(header) + for sig, body, doc in METHODS: + parts.append(_emit_method(sig, body, doc)) + parts.append(FOOTER) + return "".join(parts) + + +def write_output(path: str, code: str) -> None: + out = Path(path) + out.parent.mkdir(parents=True, exist_ok=True) + with open(out, "w", encoding="utf-8") as f: + f.write(code) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Generate MiroDataSource (miro_api SDK)." + ) + parser.add_argument( + "--out", + default=DEFAULT_OUT, + help="Output path for the generated data source.", + ) + parser.add_argument( + "--response-import", + default=DEFAULT_RESPONSE_IMPORT, + help="Import line to bring in MiroResponse.", + ) + parser.add_argument( + "--class-name", + default=DEFAULT_CLASS_NAME, + help="Name of the generated datasource class.", + ) + parser.add_argument( + "--print", + dest="do_print", + action="store_true", + help="Also print generated code to stdout.", + ) + args = parser.parse_args() + + code = build_class( + response_import=args.response_import, class_name=args.class_name + ) + write_output(args.out, code) + print(f"Generated MiroDataSource with {len(METHODS)} methods -> {args.out}") + if args.do_print: + print(code) + + +if __name__ == "__main__": + main() diff --git a/backend/python/code-generator/okta.py b/backend/python/code-generator/okta.py new file mode 100644 index 000000000..1f86c1ab9 --- /dev/null +++ b/backend/python/code-generator/okta.py @@ -0,0 +1,300 @@ +#!/usr/bin/env python3 +# ruff: noqa +from __future__ import annotations + +""" +Okta (okta-sdk-python) -- Code Generator (strict, no `Any`, no `None` passthrough) + +Emits an `OktaDataSource` with explicit, typed async methods mapped to the *real* okta SDK APIs. +- No `Any` in signatures or implementation. +- Never forwards None to the SDK (filters optionals via `_params`). +- Accepts either a raw `okta.client.Client` instance or any wrapper exposing `.get_sdk()`. + +SDK API patterns (all methods are async and return (result, resp, err)): +- Users: okta_client.list_users(query_params={}), .get_user(user_id) +- Groups: okta_client.list_groups(query_params={}), .get_group(group_id), .list_group_users(group_id) +- Apps: okta_client.list_applications(query_params={}), .get_application(app_id) +- Logs: okta_client.get_logs(query_params={}) +- Auth Srvrs: okta_client.list_authorization_servers(), .get_authorization_server(auth_server_id) +- Policies: okta_client.list_policies(query_params={}) + +References: +- SDK: https://github.com/okta/okta-sdk-python +- API: https://developer.okta.com/docs/api/ +""" + +import argparse +import textwrap +from typing import List, Tuple + +# ----------------------------- +# Configuration knobs (CLI-set) +# ----------------------------- + +DEFAULT_RESPONSE_IMPORT = "from app.sources.client.okta.okta import OktaResponse" +DEFAULT_CLASS_NAME = "OktaDataSource" +DEFAULT_OUT = "okta_data_source.py" + + +HEADER = '''\ +# ruff: noqa +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownParameterType=false +from __future__ import annotations + +from typing import Dict, List, Optional, Union, cast + +from okta.client import Client as OktaSDKClient + +{response_import} + +class {class_name}: + """ + Strict, typed async wrapper over okta-sdk-python for common Okta business operations. + + Accepts either an okta SDK `Client` instance *or* any object with `.get_sdk() -> Client`. + All methods are async because the okta SDK is natively async. + """ + + def __init__(self, client_or_sdk: Union[OktaSDKClient, object]) -> None: + if hasattr(client_or_sdk, "get_sdk"): + sdk_obj = getattr(client_or_sdk, "get_sdk")() + self._sdk: OktaSDKClient = cast(OktaSDKClient, sdk_obj) + else: + self._sdk = cast(OktaSDKClient, client_or_sdk) + + # ---- helpers ---- + @staticmethod + def _params(**kwargs: object) -> Dict[str, object]: + out: Dict[str, object] = {} + for k, v in kwargs.items(): + if v is None: + continue + if isinstance(v, (list, dict)) and len(v) == 0: + continue + out[k] = v + return out +''' + +FOOTER = """ +""" + +# Each tuple: (signature, body, short_doc) +METHODS: List[Tuple[str, str, str]] = [] + +# ---------- Users ---------- +METHODS += [ + ( + "async def list_users(self, q: Optional[str] = None, filter_expr: Optional[str] = None, search: Optional[str] = None, limit: Optional[int] = None, after: Optional[str] = None) -> OktaResponse", + " query_params = self._params(q=q, filter=filter_expr, search=search, limit=limit, after=after)\n" + " users, resp, err = await self._sdk.list_users(query_params=query_params)\n" + " if err:\n" + " return OktaResponse(success=False, error=str(err), message='Failed to list users')\n" + " return OktaResponse(success=True, data=users)", + "List users with optional search/filter. [users]", + ), + ( + "async def get_user(self, user_id: str) -> OktaResponse", + " user, resp, err = await self._sdk.get_user(user_id)\n" + " if err:\n" + " return OktaResponse(success=False, error=str(err), message='Failed to get user')\n" + " return OktaResponse(success=True, data=user)", + "Get a single user by ID or login. [users]", + ), + ( + "async def get_current_user(self) -> OktaResponse", + " user, resp, err = await self._sdk.get_user('me')\n" + " if err:\n" + " return OktaResponse(success=False, error=str(err), message='Failed to get current user')\n" + " return OktaResponse(success=True, data=user)", + "Get the current authenticated user (me). [users]", + ), +] + +# ---------- Groups ---------- +METHODS += [ + ( + "async def list_groups(self, q: Optional[str] = None, filter_expr: Optional[str] = None, limit: Optional[int] = None, after: Optional[str] = None) -> OktaResponse", + " query_params = self._params(q=q, filter=filter_expr, limit=limit, after=after)\n" + " groups, resp, err = await self._sdk.list_groups(query_params=query_params)\n" + " if err:\n" + " return OktaResponse(success=False, error=str(err), message='Failed to list groups')\n" + " return OktaResponse(success=True, data=groups)", + "List groups with optional search/filter. [groups]", + ), + ( + "async def get_group(self, group_id: str) -> OktaResponse", + " group, resp, err = await self._sdk.get_group(group_id)\n" + " if err:\n" + " return OktaResponse(success=False, error=str(err), message='Failed to get group')\n" + " return OktaResponse(success=True, data=group)", + "Get a single group by ID. [groups]", + ), + ( + "async def list_group_members(self, group_id: str, limit: Optional[int] = None, after: Optional[str] = None) -> OktaResponse", + " query_params = self._params(limit=limit, after=after)\n" + " users, resp, err = await self._sdk.list_group_users(group_id, query_params=query_params)\n" + " if err:\n" + " return OktaResponse(success=False, error=str(err), message='Failed to list group members')\n" + " return OktaResponse(success=True, data=users)", + "List members of a group. [groups]", + ), +] + +# ---------- Applications ---------- +METHODS += [ + ( + "async def list_applications(self, q: Optional[str] = None, limit: Optional[int] = None, after: Optional[str] = None) -> OktaResponse", + " query_params = self._params(q=q, limit=limit, after=after)\n" + " apps, resp, err = await self._sdk.list_applications(query_params=query_params)\n" + " if err:\n" + " return OktaResponse(success=False, error=str(err), message='Failed to list applications')\n" + " return OktaResponse(success=True, data=apps)", + "List applications. [apps]", + ), + ( + "async def get_application(self, app_id: str) -> OktaResponse", + " app, resp, err = await self._sdk.get_application(app_id)\n" + " if err:\n" + " return OktaResponse(success=False, error=str(err), message='Failed to get application')\n" + " return OktaResponse(success=True, data=app)", + "Get a specific application by ID. [apps]", + ), + ( + "async def list_application_users(self, app_id: str, limit: Optional[int] = None, after: Optional[str] = None) -> OktaResponse", + " query_params = self._params(limit=limit, after=after)\n" + " users, resp, err = await self._sdk.list_application_users(app_id, query_params=query_params)\n" + " if err:\n" + " return OktaResponse(success=False, error=str(err), message='Failed to list application users')\n" + " return OktaResponse(success=True, data=users)", + "List users assigned to an application. [apps]", + ), +] + +# ---------- System Logs ---------- +METHODS += [ + ( + "async def get_system_logs(self, since: Optional[str] = None, until: Optional[str] = None, filter_expr: Optional[str] = None, q: Optional[str] = None, limit: Optional[int] = None, after: Optional[str] = None) -> OktaResponse", + " query_params = self._params(since=since, until=until, filter=filter_expr, q=q, limit=limit, after=after)\n" + " logs, resp, err = await self._sdk.get_logs(query_params=query_params)\n" + " if err:\n" + " return OktaResponse(success=False, error=str(err), message='Failed to get system logs')\n" + " return OktaResponse(success=True, data=logs)", + "Get system log events with optional filters. [logs]", + ), +] + +# ---------- Authorization Servers ---------- +METHODS += [ + ( + "async def list_authorization_servers(self) -> OktaResponse", + " servers, resp, err = await self._sdk.list_authorization_servers()\n" + " if err:\n" + " return OktaResponse(success=False, error=str(err), message='Failed to list authorization servers')\n" + " return OktaResponse(success=True, data=servers)", + "List authorization servers. [auth_servers]", + ), + ( + "async def get_authorization_server(self, auth_server_id: str) -> OktaResponse", + " server, resp, err = await self._sdk.get_authorization_server(auth_server_id)\n" + " if err:\n" + " return OktaResponse(success=False, error=str(err), message='Failed to get authorization server')\n" + " return OktaResponse(success=True, data=server)", + "Get a specific authorization server. [auth_servers]", + ), +] + +# ---------- Policies ---------- +METHODS += [ + ( + "async def list_policies(self, type_filter: Optional[str] = None) -> OktaResponse", + " query_params = self._params(type=type_filter)\n" + " policies, resp, err = await self._sdk.list_policies(query_params=query_params)\n" + " if err:\n" + " return OktaResponse(success=False, error=str(err), message='Failed to list policies')\n" + " return OktaResponse(success=True, data=policies)", + "List policies with optional type filter. [policies]", + ), + ( + "async def get_policy(self, policy_id: str) -> OktaResponse", + " policy, resp, err = await self._sdk.get_policy(policy_id)\n" + " if err:\n" + " return OktaResponse(success=False, error=str(err), message='Failed to get policy')\n" + " return OktaResponse(success=True, data=policy)", + "Get a specific policy by ID. [policies]", + ), +] + +# ---------- Roles ---------- +METHODS += [ + ( + "async def list_assigned_roles_for_user(self, user_id: str) -> OktaResponse", + " roles, resp, err = await self._sdk.list_assigned_roles_for_user(user_id)\n" + " if err:\n" + " return OktaResponse(success=False, error=str(err), message='Failed to list roles for user')\n" + " return OktaResponse(success=True, data=roles)", + "List roles assigned to a user. [roles]", + ), +] + +# ------------------------- +# Code emission utilities +# ------------------------- + + +def _emit_method(sig: str, body: str, doc: str) -> str: + normalized_body = textwrap.indent(textwrap.dedent(body), " ") + return f' {sig}:\n """{doc}"""\n{normalized_body}\n' + + +def build_class( + response_import: str = DEFAULT_RESPONSE_IMPORT, class_name: str = DEFAULT_CLASS_NAME +) -> str: + parts: List[str] = [] + header = HEADER.replace("{response_import}", response_import).replace( + "{class_name}", class_name + ) + parts.append(header) + for sig, body, doc in METHODS: + parts.append(_emit_method(sig, body, doc)) + parts.append(FOOTER) + return "".join(parts) + + +def write_output(path: str, code: str) -> None: + with open(path, "w", encoding="utf-8") as f: + f.write(code) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Generate OktaDataSource (okta-sdk-python)." + ) + parser.add_argument( + "--out", default=DEFAULT_OUT, help="Output path for the generated data source." + ) + parser.add_argument( + "--response-import", + default=DEFAULT_RESPONSE_IMPORT, + help="Import line to bring in OktaResponse.", + ) + parser.add_argument( + "--class-name", + default=DEFAULT_CLASS_NAME, + help="Name of the generated datasource class.", + ) + parser.add_argument( + "--print", + dest="do_print", + action="store_true", + help="Also print generated code to stdout.", + ) + args = parser.parse_args() + + code = build_class(response_import=args.response_import, class_name=args.class_name) + write_output(args.out, code) + if args.do_print: + print(code) + + +if __name__ == "__main__": + main() diff --git a/backend/python/code-generator/onelogin.py b/backend/python/code-generator/onelogin.py new file mode 100644 index 000000000..8084950c7 --- /dev/null +++ b/backend/python/code-generator/onelogin.py @@ -0,0 +1,287 @@ +#!/usr/bin/env python3 +# ruff: noqa +from __future__ import annotations + +""" +OneLogin (onelogin-python-sdk) -- Code Generator + +Emits a `OneLoginDataSource` with explicit, typed methods mapped to the *real* onelogin SDK APIs. +- Accepts either a raw `onelogin.ApiClient` instance or any wrapper exposing `.get_sdk() -> ApiClient`. +- Each method instantiates the appropriate API class (UsersV2Api, GroupsApi, etc.) from the SDK. + +SDK API patterns (all synchronous): +- Users: UsersV2Api(api_client).list_users2(), .get_user_by_id2(user_id) +- Groups: GroupsApi(api_client).get_groups(), .get_group_by_id(group_id) +- Roles: RolesApi(api_client).list_roles(), .get_role_by_id(role_id) +- Apps: AppsApi(api_client).list_apps(), .get_app(app_id) +- Events: EventsApi(api_client).get_events(), .get_event_by_id(event_id) +- Privileges: PrivilegesApi(api_client).list_privileges(), .get_privilege(privilege_id) +- Mappings: MappingsApi(api_client).list_mapping_action_values(mapping_id), .list_mappings() +- Brands: BrandsApi(api_client).list_brands() +- Auth Srvrs: SmartHooksApi(api_client).list_hooks() + +References: +- SDK: https://github.com/onelogin/onelogin-python-sdk +- API: https://developers.onelogin.com/api-docs/2/getting-started/dev-overview +""" + +import argparse +import textwrap +from typing import List, Tuple + +# ----------------------------- +# Configuration knobs (CLI-set) +# ----------------------------- + +DEFAULT_RESPONSE_IMPORT = "from app.sources.client.onelogin.onelogin import OneLoginResponse" +DEFAULT_CLASS_NAME = "OneLoginDataSource" +DEFAULT_OUT = "onelogin_data_source.py" + + +HEADER = '''\ +# ruff: noqa +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownParameterType=false +from __future__ import annotations + +from typing import Dict, List, Optional, Union, cast + +import onelogin + +{response_import} + +class {class_name}: + """ + Strict, typed wrapper over onelogin-python-sdk for common OneLogin business operations. + + Accepts either a onelogin `ApiClient` instance *or* any object with `.get_sdk() -> ApiClient`. + """ + + def __init__(self, client_or_sdk: Union[onelogin.ApiClient, object]) -> None: + if hasattr(client_or_sdk, "get_sdk"): + sdk_obj = getattr(client_or_sdk, "get_sdk")() + self._sdk: onelogin.ApiClient = cast(onelogin.ApiClient, sdk_obj) + else: + self._sdk = cast(onelogin.ApiClient, client_or_sdk) + + # ---- helpers ---- + @staticmethod + def _params(**kwargs: object) -> Dict[str, object]: + out: Dict[str, object] = {} + for k, v in kwargs.items(): + if v is None: + continue + if isinstance(v, (list, dict)) and len(v) == 0: + continue + out[k] = v + return out +''' + +FOOTER = """ +""" + +# Each tuple: (signature, body, short_doc) +METHODS: List[Tuple[str, str, str]] = [] + +# ---------- Users ---------- +METHODS += [ + ( + "def list_users(self, limit: Optional[int] = None, page: Optional[int] = None, search: Optional[str] = None) -> OneLoginResponse", + " api = onelogin.UsersV2Api(self._sdk)\n" + " params = self._params(limit=limit, page=page, search=search)\n" + " users = api.list_users2(**params)\n" + " return OneLoginResponse(success=True, data=users)", + "List all users. [users]", + ), + ( + "def get_user(self, user_id: int) -> OneLoginResponse", + " api = onelogin.UsersV2Api(self._sdk)\n" + " user = api.get_user_by_id2(user_id)\n" + " return OneLoginResponse(success=True, data=user)", + "Get a specific user by ID. [users]", + ), +] + +# ---------- Groups ---------- +METHODS += [ + ( + "def list_groups(self) -> OneLoginResponse", + " api = onelogin.GroupsApi(self._sdk)\n" + " groups = api.get_groups()\n" + " return OneLoginResponse(success=True, data=groups)", + "List all groups. [groups]", + ), + ( + "def get_group(self, group_id: int) -> OneLoginResponse", + " api = onelogin.GroupsApi(self._sdk)\n" + " group = api.get_group_by_id(str(group_id))\n" + " return OneLoginResponse(success=True, data=group)", + "Get a specific group by ID. [groups]", + ), +] + +# ---------- Roles ---------- +METHODS += [ + ( + "def list_roles(self) -> OneLoginResponse", + " api = onelogin.RolesApi(self._sdk)\n" + " roles = api.list_roles()\n" + " return OneLoginResponse(success=True, data=roles)", + "List all roles. [roles]", + ), + ( + "def get_role(self, role_id: int) -> OneLoginResponse", + " api = onelogin.RolesApi(self._sdk)\n" + " role = api.get_role_by_id(str(role_id))\n" + " return OneLoginResponse(success=True, data=role)", + "Get a specific role by ID. [roles]", + ), +] + +# ---------- Apps ---------- +METHODS += [ + ( + "def list_apps(self, limit: Optional[int] = None, page: Optional[int] = None) -> OneLoginResponse", + " api = onelogin.AppsApi(self._sdk)\n" + " params = self._params(limit=limit, page=page)\n" + " apps = api.list_apps(**params)\n" + " return OneLoginResponse(success=True, data=apps)", + "List all apps. [apps]", + ), + ( + "def get_app(self, app_id: int) -> OneLoginResponse", + " api = onelogin.AppsApi(self._sdk)\n" + " app = api.get_app(app_id)\n" + " return OneLoginResponse(success=True, data=app)", + "Get a specific app by ID. [apps]", + ), + ( + "def get_app_users(self, app_id: int, limit: Optional[int] = None, page: Optional[int] = None) -> OneLoginResponse", + " api = onelogin.AppsApi(self._sdk)\n" + " params = self._params(limit=limit, page=page)\n" + " users = api.list_app_users(app_id, **params)\n" + " return OneLoginResponse(success=True, data=users)", + "Get users assigned to a specific app. [apps]", + ), +] + +# ---------- Events ---------- +METHODS += [ + ( + "def list_events(self, limit: Optional[int] = None, page: Optional[int] = None) -> OneLoginResponse", + " api = onelogin.EventsApi(self._sdk)\n" + " params = self._params(limit=limit, page=page)\n" + " events = api.get_events(**params)\n" + " return OneLoginResponse(success=True, data=events)", + "List all events. [events]", + ), + ( + "def get_event(self, event_id: int) -> OneLoginResponse", + " api = onelogin.EventsApi(self._sdk)\n" + " event = api.get_event_by_id(event_id)\n" + " return OneLoginResponse(success=True, data=event)", + "Get a specific event by ID. [events]", + ), +] + +# ---------- Privileges ---------- +METHODS += [ + ( + "def list_privileges(self) -> OneLoginResponse", + " api = onelogin.PrivilegesApi(self._sdk)\n" + " privileges = api.list_privileges()\n" + " return OneLoginResponse(success=True, data=privileges)", + "List all privileges. [privileges]", + ), + ( + "def get_privilege(self, privilege_id: str) -> OneLoginResponse", + " api = onelogin.PrivilegesApi(self._sdk)\n" + " privilege = api.get_privilege(privilege_id)\n" + " return OneLoginResponse(success=True, data=privilege)", + "Get a specific privilege by ID. [privileges]", + ), +] + +# ---------- Mappings ---------- +METHODS += [ + ( + "def list_mappings(self) -> OneLoginResponse", + " api = onelogin.MappingsApi(self._sdk)\n" + " mappings = api.list_mappings()\n" + " return OneLoginResponse(success=True, data=mappings)", + "List all user mappings. [mappings]", + ), +] + +# ---------- Brands ---------- +METHODS += [ + ( + "def list_brands(self) -> OneLoginResponse", + " api = onelogin.BrandsApi(self._sdk)\n" + " brands = api.list_brands()\n" + " return OneLoginResponse(success=True, data=brands)", + "List all brands. [brands]", + ), +] + +# ------------------------- +# Code emission utilities +# ------------------------- + + +def _emit_method(sig: str, body: str, doc: str) -> str: + normalized_body = textwrap.indent(textwrap.dedent(body), " ") + return f' {sig}:\n """{doc}"""\n{normalized_body}\n' + + +def build_class( + response_import: str = DEFAULT_RESPONSE_IMPORT, class_name: str = DEFAULT_CLASS_NAME +) -> str: + parts: List[str] = [] + header = HEADER.replace("{response_import}", response_import).replace( + "{class_name}", class_name + ) + parts.append(header) + for sig, body, doc in METHODS: + parts.append(_emit_method(sig, body, doc)) + parts.append(FOOTER) + return "".join(parts) + + +def write_output(path: str, code: str) -> None: + with open(path, "w", encoding="utf-8") as f: + f.write(code) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Generate OneLoginDataSource (onelogin-python-sdk)." + ) + parser.add_argument( + "--out", default=DEFAULT_OUT, help="Output path for the generated data source." + ) + parser.add_argument( + "--response-import", + default=DEFAULT_RESPONSE_IMPORT, + help="Import line to bring in OneLoginResponse.", + ) + parser.add_argument( + "--class-name", + default=DEFAULT_CLASS_NAME, + help="Name of the generated datasource class.", + ) + parser.add_argument( + "--print", + dest="do_print", + action="store_true", + help="Also print generated code to stdout.", + ) + args = parser.parse_args() + + code = build_class(response_import=args.response_import, class_name=args.class_name) + write_output(args.out, code) + if args.do_print: + print(code) + + +if __name__ == "__main__": + main() diff --git a/backend/python/code-generator/opsgenie.py b/backend/python/code-generator/opsgenie.py new file mode 100644 index 000000000..caa0ed61e --- /dev/null +++ b/backend/python/code-generator/opsgenie.py @@ -0,0 +1,320 @@ +#!/usr/bin/env python3 +# ruff: noqa +from __future__ import annotations + +""" +Opsgenie (opsgenie-sdk) -- Code Generator + +Emits an `OpsgenieDataSource` with explicit, typed methods mapped to the *real* opsgenie_sdk APIs. +- Accepts either a raw `opsgenie_sdk.ApiClient` instance or any wrapper exposing `.get_sdk() -> ApiClient`. +- Each method instantiates the appropriate API class (AlertApi, IncidentApi, etc.) from the SDK. + +SDK API patterns (all synchronous): +- Alerts: AlertApi(api_client).list_alerts(), .get_alert(identifier), .create_alert(body=...), .close_alert(identifier, body=...), .acknowledge_alert(identifier, body=...) +- Incidents: IncidentApi(api_client).list_incidents(query=...), .get_incident(identifier) +- Schedules: ScheduleApi(api_client).list_schedules(), .get_schedule(identifier) +- Teams: TeamApi(api_client).list_teams(), .get_team(identifier) +- Users: UserApi(api_client).list_users(), .get_user(identifier) +- Services: ServiceApi(api_client).list_services(), .get_service(identifier) +- Heartbeats: HeartbeatApi(api_client).list_heart_beats() + +References: +- SDK: https://github.com/opsgenie/opsgenie-python-sdk +- API: https://docs.opsgenie.com/docs/api-overview +""" + +import argparse +import textwrap +from typing import List, Tuple + +# ----------------------------- +# Configuration knobs (CLI-set) +# ----------------------------- + +DEFAULT_RESPONSE_IMPORT = "from app.sources.client.opsgenie.opsgenie import OpsgenieResponse" +DEFAULT_CLASS_NAME = "OpsgenieDataSource" +DEFAULT_OUT = "opsgenie_data_source.py" + + +HEADER = '''\ +# ruff: noqa +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownParameterType=false +from __future__ import annotations + +from typing import Dict, List, Optional, Union, cast + +import opsgenie_sdk + +{response_import} + +class {class_name}: + """ + Strict, typed wrapper over opsgenie-sdk for common Opsgenie business operations. + + Accepts either an opsgenie_sdk `ApiClient` instance *or* any object with `.get_sdk() -> ApiClient`. + """ + + def __init__(self, client_or_sdk: Union[opsgenie_sdk.ApiClient, object]) -> None: + if hasattr(client_or_sdk, "get_sdk"): + sdk_obj = getattr(client_or_sdk, "get_sdk")() + self._sdk: opsgenie_sdk.ApiClient = cast(opsgenie_sdk.ApiClient, sdk_obj) + else: + self._sdk = cast(opsgenie_sdk.ApiClient, client_or_sdk) + + # ---- helpers ---- + @staticmethod + def _params(**kwargs: object) -> Dict[str, object]: + out: Dict[str, object] = {} + for k, v in kwargs.items(): + if v is None: + continue + if isinstance(v, (list, dict)) and len(v) == 0: + continue + out[k] = v + return out +''' + +FOOTER = """ +""" + +# Each tuple: (signature, body, short_doc) +METHODS: List[Tuple[str, str, str]] = [] + +# ---------- Alerts ---------- +METHODS += [ + ( + "def list_alerts(self, limit: Optional[int] = None, offset: Optional[int] = None, sort: Optional[str] = None, order: Optional[str] = None, search_identifier: Optional[str] = None, search_identifier_type: Optional[str] = None, query: Optional[str] = None) -> OpsgenieResponse", + " api = opsgenie_sdk.AlertApi(self._sdk)\n" + " params = self._params(limit=limit, offset=offset, sort=sort, order=order, search_identifier=search_identifier, search_identifier_type=search_identifier_type, query=query)\n" + " result = api.list_alerts(**params)\n" + " return OpsgenieResponse(success=True, data=result)", + "List all alerts with optional filters. [alerts]", + ), + ( + "def get_alert(self, identifier: str) -> OpsgenieResponse", + " api = opsgenie_sdk.AlertApi(self._sdk)\n" + " result = api.get_alert(identifier=identifier)\n" + " return OpsgenieResponse(success=True, data=result)", + "Get a specific alert. [alerts]", + ), + ( + "def create_alert(self, message: str, alias: Optional[str] = None, description: Optional[str] = None, responders: Optional[List[Dict[str, str]]] = None, tags: Optional[List[str]] = None, entity: Optional[str] = None, source: Optional[str] = None, priority: Optional[str] = None, user: Optional[str] = None, note: Optional[str] = None) -> OpsgenieResponse", + " api = opsgenie_sdk.AlertApi(self._sdk)\n" + " payload_kwargs = self._params(message=message, alias=alias, description=description, responders=responders, tags=tags, entity=entity, source=source, priority=priority, user=user, note=note)\n" + " body = opsgenie_sdk.CreateAlertPayload(**payload_kwargs)\n" + " result = api.create_alert(body=body)\n" + " return OpsgenieResponse(success=True, data=result)", + "Create a new alert. [alerts]", + ), + ( + "def close_alert(self, identifier: str, user: Optional[str] = None, source: Optional[str] = None, note: Optional[str] = None) -> OpsgenieResponse", + " api = opsgenie_sdk.AlertApi(self._sdk)\n" + " payload_kwargs = self._params(user=user, source=source, note=note)\n" + " body = opsgenie_sdk.CloseAlertPayload(**payload_kwargs)\n" + " result = api.close_alert(identifier=identifier, body=body)\n" + " return OpsgenieResponse(success=True, data=result)", + "Close an alert. [alerts]", + ), + ( + "def acknowledge_alert(self, identifier: str, user: Optional[str] = None, source: Optional[str] = None, note: Optional[str] = None) -> OpsgenieResponse", + " api = opsgenie_sdk.AlertApi(self._sdk)\n" + " payload_kwargs = self._params(user=user, source=source, note=note)\n" + " body = opsgenie_sdk.AcknowledgeAlertPayload(**payload_kwargs)\n" + " result = api.acknowledge_alert(identifier=identifier, body=body)\n" + " return OpsgenieResponse(success=True, data=result)", + "Acknowledge an alert. [alerts]", + ), + ( + "def add_note_to_alert(self, identifier: str, note: str, user: Optional[str] = None, source: Optional[str] = None) -> OpsgenieResponse", + " api = opsgenie_sdk.AlertApi(self._sdk)\n" + " payload_kwargs = self._params(note=note, user=user, source=source)\n" + " body = opsgenie_sdk.AddNoteToAlertPayload(**payload_kwargs)\n" + " result = api.add_note(identifier=identifier, body=body)\n" + " return OpsgenieResponse(success=True, data=result)", + "Add a note to an alert. [alerts]", + ), + ( + "def list_alert_notes(self, identifier: str, limit: Optional[int] = None, offset: Optional[int] = None, order: Optional[str] = None, direction: Optional[str] = None) -> OpsgenieResponse", + " api = opsgenie_sdk.AlertApi(self._sdk)\n" + " params = self._params(limit=limit, offset=offset, order=order, direction=direction)\n" + " result = api.list_notes(identifier=identifier, **params)\n" + " return OpsgenieResponse(success=True, data=result)", + "List notes of an alert. [alerts]", + ), +] + +# ---------- Incidents ---------- +METHODS += [ + ( + "def list_incidents(self, limit: Optional[int] = None, offset: Optional[int] = None, sort: Optional[str] = None, order: Optional[str] = None, query: Optional[str] = None) -> OpsgenieResponse", + " api = opsgenie_sdk.IncidentApi(self._sdk)\n" + " params = self._params(limit=limit, offset=offset, sort=sort, order=order, query=query)\n" + " result = api.list_incidents(**params)\n" + " return OpsgenieResponse(success=True, data=result)", + "List all incidents. [incidents]", + ), + ( + "def get_incident(self, identifier: str) -> OpsgenieResponse", + " api = opsgenie_sdk.IncidentApi(self._sdk)\n" + " result = api.get_incident(identifier=identifier)\n" + " return OpsgenieResponse(success=True, data=result)", + "Get a specific incident. [incidents]", + ), + ( + "def create_incident(self, message: str, description: Optional[str] = None, responders: Optional[List[Dict[str, str]]] = None, tags: Optional[List[str]] = None, details: Optional[Dict[str, str]] = None, priority: Optional[str] = None, note: Optional[str] = None, service_id: Optional[str] = None, notify_stakeholders: Optional[bool] = None) -> OpsgenieResponse", + " api = opsgenie_sdk.IncidentApi(self._sdk)\n" + " payload_kwargs = self._params(message=message, description=description, responders=responders, tags=tags, details=details, priority=priority, note=note, serviceId=service_id, notifyStakeholders=notify_stakeholders)\n" + " body = opsgenie_sdk.CreateIncidentPayload(**payload_kwargs)\n" + " result = api.create_incident(body=body)\n" + " return OpsgenieResponse(success=True, data=result)", + "Create a new incident. [incidents]", + ), +] + +# ---------- Schedules ---------- +METHODS += [ + ( + "def list_schedules(self) -> OpsgenieResponse", + " api = opsgenie_sdk.ScheduleApi(self._sdk)\n" + " result = api.list_schedules()\n" + " return OpsgenieResponse(success=True, data=result)", + "List all schedules. [schedules]", + ), + ( + "def get_schedule(self, identifier: str) -> OpsgenieResponse", + " api = opsgenie_sdk.ScheduleApi(self._sdk)\n" + " result = api.get_schedule(identifier=identifier)\n" + " return OpsgenieResponse(success=True, data=result)", + "Get a specific schedule. [schedules]", + ), +] + +# ---------- Teams ---------- +METHODS += [ + ( + "def list_teams(self) -> OpsgenieResponse", + " api = opsgenie_sdk.TeamApi(self._sdk)\n" + " result = api.list_teams()\n" + " return OpsgenieResponse(success=True, data=result)", + "List all teams. [teams]", + ), + ( + "def get_team(self, identifier: str) -> OpsgenieResponse", + " api = opsgenie_sdk.TeamApi(self._sdk)\n" + " result = api.get_team(identifier=identifier)\n" + " return OpsgenieResponse(success=True, data=result)", + "Get a specific team. [teams]", + ), +] + +# ---------- Users ---------- +METHODS += [ + ( + "def list_users(self, limit: Optional[int] = None, offset: Optional[int] = None, sort: Optional[str] = None, order: Optional[str] = None, query: Optional[str] = None) -> OpsgenieResponse", + " api = opsgenie_sdk.UserApi(self._sdk)\n" + " params = self._params(limit=limit, offset=offset, sort=sort, order=order, query=query)\n" + " result = api.list_users(**params)\n" + " return OpsgenieResponse(success=True, data=result)", + "List all users. [users]", + ), + ( + "def get_user(self, identifier: str) -> OpsgenieResponse", + " api = opsgenie_sdk.UserApi(self._sdk)\n" + " result = api.get_user(identifier=identifier)\n" + " return OpsgenieResponse(success=True, data=result)", + "Get a specific user. [users]", + ), +] + +# ---------- Services ---------- +METHODS += [ + ( + "def list_services(self, limit: Optional[int] = None, offset: Optional[int] = None) -> OpsgenieResponse", + " api = opsgenie_sdk.ServiceApi(self._sdk)\n" + " params = self._params(limit=limit, offset=offset)\n" + " result = api.list_services(**params)\n" + " return OpsgenieResponse(success=True, data=result)", + "List all services. [services]", + ), + ( + "def get_service(self, identifier: str) -> OpsgenieResponse", + " api = opsgenie_sdk.ServiceApi(self._sdk)\n" + " result = api.get_service(identifier=identifier)\n" + " return OpsgenieResponse(success=True, data=result)", + "Get a specific service. [services]", + ), +] + +# ---------- Heartbeats ---------- +METHODS += [ + ( + "def list_heartbeats(self) -> OpsgenieResponse", + " api = opsgenie_sdk.HeartbeatApi(self._sdk)\n" + " result = api.list_heart_beats()\n" + " return OpsgenieResponse(success=True, data=result)", + "List all heartbeats. [heartbeats]", + ), +] + +# ------------------------- +# Code emission utilities +# ------------------------- + + +def _emit_method(sig: str, body: str, doc: str) -> str: + normalized_body = textwrap.indent(textwrap.dedent(body), " ") + return f' {sig}:\n """{doc}"""\n{normalized_body}\n' + + +def build_class( + response_import: str = DEFAULT_RESPONSE_IMPORT, class_name: str = DEFAULT_CLASS_NAME +) -> str: + parts: List[str] = [] + header = HEADER.replace("{response_import}", response_import).replace( + "{class_name}", class_name + ) + parts.append(header) + for sig, body, doc in METHODS: + parts.append(_emit_method(sig, body, doc)) + parts.append(FOOTER) + return "".join(parts) + + +def write_output(path: str, code: str) -> None: + with open(path, "w", encoding="utf-8") as f: + f.write(code) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Generate OpsgenieDataSource (opsgenie-sdk)." + ) + parser.add_argument( + "--out", default=DEFAULT_OUT, help="Output path for the generated data source." + ) + parser.add_argument( + "--response-import", + default=DEFAULT_RESPONSE_IMPORT, + help="Import line to bring in OpsgenieResponse.", + ) + parser.add_argument( + "--class-name", + default=DEFAULT_CLASS_NAME, + help="Name of the generated datasource class.", + ) + parser.add_argument( + "--print", + dest="do_print", + action="store_true", + help="Also print generated code to stdout.", + ) + args = parser.parse_args() + + code = build_class(response_import=args.response_import, class_name=args.class_name) + write_output(args.out, code) + if args.do_print: + print(code) + + +if __name__ == "__main__": + main() diff --git a/backend/python/code-generator/pipedrive.py b/backend/python/code-generator/pipedrive.py new file mode 100644 index 000000000..309f8180f --- /dev/null +++ b/backend/python/code-generator/pipedrive.py @@ -0,0 +1,873 @@ +# ruff: noqa +""" +Pipedrive REST API Code Generator + +Generates PipedriveDataSource class covering Pipedrive API v1: +- Users management +- Deals CRUD and management +- Persons (contacts) CRUD +- Organizations CRUD +- Activities management +- Pipelines and Stages +- Products management +- Notes CRUD +- Leads management +- Custom fields (Deal, Person, Organization) + +The generated DataSource accepts a PipedriveClient and uses the client's +configured base URL. Methods are generated for all API v1 endpoints. + +All methods have explicit parameter signatures with no **kwargs usage. +""" + +import sys +from pathlib import Path +from typing import Dict, List, Optional + +# ================================================================================ +# Pipedrive API Endpoints - organized by resource +# +# Each endpoint defines: +# method: HTTP verb +# path: URL path (appended to base_url which already includes /v1) +# description: Human-readable description +# parameters: Dict of param_name -> {type, location (path/query/body), description} +# required: List of required parameter names +# ================================================================================ + +PIPEDRIVE_API_ENDPOINTS = { + # ================================================================================ + # USERS + # ================================================================================ + "list_users": { + "method": "GET", + "path": "/users", + "description": "List all users in the company", + "parameters": {}, + "required": [], + }, + "get_user": { + "method": "GET", + "path": "/users/{id}", + "description": "Get details of a specific user", + "parameters": { + "id": {"type": "str", "location": "path", "description": "The user ID"}, + }, + "required": ["id"], + }, + "get_current_user": { + "method": "GET", + "path": "/users/me", + "description": "Get the current authenticated user", + "parameters": {}, + "required": [], + }, + + # ================================================================================ + # DEALS + # ================================================================================ + "list_deals": { + "method": "GET", + "path": "/deals", + "description": "List all deals", + "parameters": { + "status": {"type": "Optional[str]", "location": "query", "description": "Filter by deal status (open, won, lost, deleted, all_not_deleted)"}, + "start": {"type": "Optional[int]", "location": "query", "description": "Pagination start (default 0)"}, + "limit": {"type": "Optional[int]", "location": "query", "description": "Items shown per page (default 100)"}, + "sort": {"type": "Optional[str]", "location": "query", "description": "Field name and sorting mode (e.g. 'title ASC')"}, + "filter_id": {"type": "Optional[int]", "location": "query", "description": "ID of the filter to use"}, + }, + "required": [], + }, + "get_deal": { + "method": "GET", + "path": "/deals/{id}", + "description": "Get details of a specific deal", + "parameters": { + "id": {"type": "str", "location": "path", "description": "The deal ID"}, + }, + "required": ["id"], + }, + "create_deal": { + "method": "POST", + "path": "/deals", + "description": "Create a new deal", + "parameters": { + "title": {"type": "str", "location": "body", "description": "The title of the deal"}, + "value": {"type": "Optional[str]", "location": "body", "description": "Value of the deal"}, + "currency": {"type": "Optional[str]", "location": "body", "description": "Currency of the deal (3-letter code)"}, + "user_id": {"type": "Optional[int]", "location": "body", "description": "ID of the user who owns the deal"}, + "person_id": {"type": "Optional[int]", "location": "body", "description": "ID of a person linked to the deal"}, + "org_id": {"type": "Optional[int]", "location": "body", "description": "ID of an organization linked to the deal"}, + "pipeline_id": {"type": "Optional[int]", "location": "body", "description": "ID of the pipeline this deal will be placed in"}, + "stage_id": {"type": "Optional[int]", "location": "body", "description": "ID of the stage this deal will be placed in"}, + "status": {"type": "Optional[str]", "location": "body", "description": "Status of the deal (open, won, lost, deleted)"}, + "expected_close_date": {"type": "Optional[str]", "location": "body", "description": "Expected close date (YYYY-MM-DD)"}, + "probability": {"type": "Optional[int]", "location": "body", "description": "Deal success probability percentage"}, + }, + "required": ["title"], + }, + "update_deal": { + "method": "PUT", + "path": "/deals/{id}", + "description": "Update a deal", + "parameters": { + "id": {"type": "str", "location": "path", "description": "The deal ID"}, + "title": {"type": "Optional[str]", "location": "body", "description": "The title of the deal"}, + "value": {"type": "Optional[str]", "location": "body", "description": "Value of the deal"}, + "currency": {"type": "Optional[str]", "location": "body", "description": "Currency of the deal (3-letter code)"}, + "user_id": {"type": "Optional[int]", "location": "body", "description": "ID of the user who owns the deal"}, + "person_id": {"type": "Optional[int]", "location": "body", "description": "ID of a person linked to the deal"}, + "org_id": {"type": "Optional[int]", "location": "body", "description": "ID of an organization linked to the deal"}, + "pipeline_id": {"type": "Optional[int]", "location": "body", "description": "ID of the pipeline"}, + "stage_id": {"type": "Optional[int]", "location": "body", "description": "ID of the stage"}, + "status": {"type": "Optional[str]", "location": "body", "description": "Status of the deal (open, won, lost, deleted)"}, + "expected_close_date": {"type": "Optional[str]", "location": "body", "description": "Expected close date (YYYY-MM-DD)"}, + "probability": {"type": "Optional[int]", "location": "body", "description": "Deal success probability percentage"}, + }, + "required": ["id"], + }, + "delete_deal": { + "method": "DELETE", + "path": "/deals/{id}", + "description": "Delete a deal", + "parameters": { + "id": {"type": "str", "location": "path", "description": "The deal ID"}, + }, + "required": ["id"], + }, + + # ================================================================================ + # PERSONS (CONTACTS) + # ================================================================================ + "list_persons": { + "method": "GET", + "path": "/persons", + "description": "List all persons (contacts)", + "parameters": { + "start": {"type": "Optional[int]", "location": "query", "description": "Pagination start (default 0)"}, + "limit": {"type": "Optional[int]", "location": "query", "description": "Items shown per page (default 100)"}, + "sort": {"type": "Optional[str]", "location": "query", "description": "Field name and sorting mode"}, + "filter_id": {"type": "Optional[int]", "location": "query", "description": "ID of the filter to use"}, + }, + "required": [], + }, + "get_person": { + "method": "GET", + "path": "/persons/{id}", + "description": "Get details of a specific person", + "parameters": { + "id": {"type": "str", "location": "path", "description": "The person ID"}, + }, + "required": ["id"], + }, + "create_person": { + "method": "POST", + "path": "/persons", + "description": "Create a new person (contact)", + "parameters": { + "name": {"type": "str", "location": "body", "description": "The name of the person"}, + "owner_id": {"type": "Optional[int]", "location": "body", "description": "ID of the user who owns the person"}, + "org_id": {"type": "Optional[int]", "location": "body", "description": "ID of the organization this person belongs to"}, + "email": {"type": "Optional[str]", "location": "body", "description": "Email address of the person"}, + "phone": {"type": "Optional[str]", "location": "body", "description": "Phone number of the person"}, + }, + "required": ["name"], + }, + "update_person": { + "method": "PUT", + "path": "/persons/{id}", + "description": "Update a person", + "parameters": { + "id": {"type": "str", "location": "path", "description": "The person ID"}, + "name": {"type": "Optional[str]", "location": "body", "description": "The name of the person"}, + "owner_id": {"type": "Optional[int]", "location": "body", "description": "ID of the user who owns the person"}, + "org_id": {"type": "Optional[int]", "location": "body", "description": "ID of the organization"}, + "email": {"type": "Optional[str]", "location": "body", "description": "Email address of the person"}, + "phone": {"type": "Optional[str]", "location": "body", "description": "Phone number of the person"}, + }, + "required": ["id"], + }, + + # ================================================================================ + # ORGANIZATIONS + # ================================================================================ + "list_organizations": { + "method": "GET", + "path": "/organizations", + "description": "List all organizations", + "parameters": { + "start": {"type": "Optional[int]", "location": "query", "description": "Pagination start (default 0)"}, + "limit": {"type": "Optional[int]", "location": "query", "description": "Items shown per page (default 100)"}, + "sort": {"type": "Optional[str]", "location": "query", "description": "Field name and sorting mode"}, + "filter_id": {"type": "Optional[int]", "location": "query", "description": "ID of the filter to use"}, + }, + "required": [], + }, + "get_organization": { + "method": "GET", + "path": "/organizations/{id}", + "description": "Get details of a specific organization", + "parameters": { + "id": {"type": "str", "location": "path", "description": "The organization ID"}, + }, + "required": ["id"], + }, + "create_organization": { + "method": "POST", + "path": "/organizations", + "description": "Create a new organization", + "parameters": { + "name": {"type": "str", "location": "body", "description": "The name of the organization"}, + "owner_id": {"type": "Optional[int]", "location": "body", "description": "ID of the user who owns the organization"}, + }, + "required": ["name"], + }, + + # ================================================================================ + # ACTIVITIES + # ================================================================================ + "list_activities": { + "method": "GET", + "path": "/activities", + "description": "List all activities", + "parameters": { + "start": {"type": "Optional[int]", "location": "query", "description": "Pagination start (default 0)"}, + "limit": {"type": "Optional[int]", "location": "query", "description": "Items shown per page (default 100)"}, + "type": {"type": "Optional[str]", "location": "query", "description": "Type of activity (e.g. call, meeting, task, deadline, email)"}, + "done": {"type": "Optional[int]", "location": "query", "description": "Filter by done status (0 = not done, 1 = done)"}, + "user_id": {"type": "Optional[int]", "location": "query", "description": "Filter by user ID"}, + "start_date": {"type": "Optional[str]", "location": "query", "description": "Start date filter (YYYY-MM-DD)"}, + "end_date": {"type": "Optional[str]", "location": "query", "description": "End date filter (YYYY-MM-DD)"}, + }, + "required": [], + }, + "get_activity": { + "method": "GET", + "path": "/activities/{id}", + "description": "Get details of a specific activity", + "parameters": { + "id": {"type": "str", "location": "path", "description": "The activity ID"}, + }, + "required": ["id"], + }, + "create_activity": { + "method": "POST", + "path": "/activities", + "description": "Create a new activity", + "parameters": { + "subject": {"type": "str", "location": "body", "description": "Subject of the activity"}, + "type": {"type": "str", "location": "body", "description": "Type of the activity (e.g. call, meeting, task)"}, + "done": {"type": "Optional[int]", "location": "body", "description": "Whether the activity is done (0 or 1)"}, + "due_date": {"type": "Optional[str]", "location": "body", "description": "Due date of the activity (YYYY-MM-DD)"}, + "due_time": {"type": "Optional[str]", "location": "body", "description": "Due time of the activity (HH:MM)"}, + "duration": {"type": "Optional[str]", "location": "body", "description": "Duration of the activity (HH:MM)"}, + "deal_id": {"type": "Optional[int]", "location": "body", "description": "ID of the deal this activity is linked to"}, + "person_id": {"type": "Optional[int]", "location": "body", "description": "ID of the person this activity is linked to"}, + "org_id": {"type": "Optional[int]", "location": "body", "description": "ID of the organization this activity is linked to"}, + "user_id": {"type": "Optional[int]", "location": "body", "description": "ID of the user who owns the activity"}, + "note": {"type": "Optional[str]", "location": "body", "description": "Note of the activity (HTML format)"}, + }, + "required": ["subject", "type"], + }, + + # ================================================================================ + # PIPELINES + # ================================================================================ + "list_pipelines": { + "method": "GET", + "path": "/pipelines", + "description": "List all pipelines", + "parameters": {}, + "required": [], + }, + "get_pipeline": { + "method": "GET", + "path": "/pipelines/{id}", + "description": "Get details of a specific pipeline", + "parameters": { + "id": {"type": "str", "location": "path", "description": "The pipeline ID"}, + }, + "required": ["id"], + }, + + # ================================================================================ + # STAGES + # ================================================================================ + "list_stages": { + "method": "GET", + "path": "/stages", + "description": "List all stages", + "parameters": { + "pipeline_id": {"type": "Optional[int]", "location": "query", "description": "Filter stages by pipeline ID"}, + }, + "required": [], + }, + "get_stage": { + "method": "GET", + "path": "/stages/{id}", + "description": "Get details of a specific stage", + "parameters": { + "id": {"type": "str", "location": "path", "description": "The stage ID"}, + }, + "required": ["id"], + }, + + # ================================================================================ + # PRODUCTS + # ================================================================================ + "list_products": { + "method": "GET", + "path": "/products", + "description": "List all products", + "parameters": { + "start": {"type": "Optional[int]", "location": "query", "description": "Pagination start (default 0)"}, + "limit": {"type": "Optional[int]", "location": "query", "description": "Items shown per page (default 100)"}, + }, + "required": [], + }, + "get_product": { + "method": "GET", + "path": "/products/{id}", + "description": "Get details of a specific product", + "parameters": { + "id": {"type": "str", "location": "path", "description": "The product ID"}, + }, + "required": ["id"], + }, + + # ================================================================================ + # NOTES + # ================================================================================ + "list_notes": { + "method": "GET", + "path": "/notes", + "description": "List all notes", + "parameters": { + "deal_id": {"type": "Optional[int]", "location": "query", "description": "Filter notes by deal ID"}, + "person_id": {"type": "Optional[int]", "location": "query", "description": "Filter notes by person ID"}, + "org_id": {"type": "Optional[int]", "location": "query", "description": "Filter notes by organization ID"}, + "start": {"type": "Optional[int]", "location": "query", "description": "Pagination start (default 0)"}, + "limit": {"type": "Optional[int]", "location": "query", "description": "Items shown per page (default 100)"}, + }, + "required": [], + }, + "get_note": { + "method": "GET", + "path": "/notes/{id}", + "description": "Get details of a specific note", + "parameters": { + "id": {"type": "str", "location": "path", "description": "The note ID"}, + }, + "required": ["id"], + }, + "create_note": { + "method": "POST", + "path": "/notes", + "description": "Create a new note", + "parameters": { + "content": {"type": "str", "location": "body", "description": "Content of the note (HTML format)"}, + "deal_id": {"type": "Optional[int]", "location": "body", "description": "ID of the deal this note is attached to"}, + "person_id": {"type": "Optional[int]", "location": "body", "description": "ID of the person this note is attached to"}, + "org_id": {"type": "Optional[int]", "location": "body", "description": "ID of the organization this note is attached to"}, + }, + "required": ["content"], + }, + + # ================================================================================ + # LEADS + # ================================================================================ + "list_leads": { + "method": "GET", + "path": "/leads", + "description": "List all leads", + "parameters": { + "limit": {"type": "Optional[int]", "location": "query", "description": "Items shown per page (default 100)"}, + "start": {"type": "Optional[int]", "location": "query", "description": "Pagination start (default 0)"}, + "sort": {"type": "Optional[str]", "location": "query", "description": "Field name and sorting mode"}, + "filter_id": {"type": "Optional[int]", "location": "query", "description": "ID of the filter to use"}, + }, + "required": [], + }, + "get_lead": { + "method": "GET", + "path": "/leads/{id}", + "description": "Get details of a specific lead", + "parameters": { + "id": {"type": "str", "location": "path", "description": "The lead ID"}, + }, + "required": ["id"], + }, + + # ================================================================================ + # CUSTOM FIELDS + # ================================================================================ + "list_deal_fields": { + "method": "GET", + "path": "/dealFields", + "description": "List all deal fields (including custom fields)", + "parameters": { + "start": {"type": "Optional[int]", "location": "query", "description": "Pagination start (default 0)"}, + "limit": {"type": "Optional[int]", "location": "query", "description": "Items shown per page (default 100)"}, + }, + "required": [], + }, + "list_person_fields": { + "method": "GET", + "path": "/personFields", + "description": "List all person fields (including custom fields)", + "parameters": { + "start": {"type": "Optional[int]", "location": "query", "description": "Pagination start (default 0)"}, + "limit": {"type": "Optional[int]", "location": "query", "description": "Items shown per page (default 100)"}, + }, + "required": [], + }, + "list_organization_fields": { + "method": "GET", + "path": "/organizationFields", + "description": "List all organization fields (including custom fields)", + "parameters": { + "start": {"type": "Optional[int]", "location": "query", "description": "Pagination start (default 0)"}, + "limit": {"type": "Optional[int]", "location": "query", "description": "Items shown per page (default 100)"}, + }, + "required": [], + }, +} + + +# ================================================================================ +# Code Generator +# ================================================================================ + + +class PipedriveDataSourceGenerator: + """Generator for comprehensive Pipedrive REST API datasource class. + + Generates methods for Pipedrive API v1 endpoints. + The generated DataSource class accepts a PipedriveClient whose base URL + setting determines the API endpoint. + """ + + def __init__(self): + self.generated_methods: List[Dict[str, str]] = [] + + def _sanitize_parameter_name(self, name: str) -> str: + """Sanitize parameter names to be valid Python identifiers.""" + sanitized = name.replace("-", "_").replace(".", "_").replace("/", "_") + if sanitized and not (sanitized[0].isalpha() or sanitized[0] == "_"): + sanitized = f"param_{sanitized}" + # Avoid shadowing builtins + if sanitized in ("type", "id", "format", "input", "list", "dict", "set", + "map", "filter", "hash", "range", "open", "print", "next"): + sanitized = f"{sanitized}_" + return sanitized + + def _build_query_params(self, endpoint_info: Dict) -> List[str]: + """Build query parameter handling code.""" + lines = [" query_params: dict[str, Any] = {}"] + + for param_name, param_info in endpoint_info["parameters"].items(): + if param_info["location"] == "query": + sanitized_name = self._sanitize_parameter_name(param_name) + + if "Optional[bool]" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}'] = str({sanitized_name}).lower()", + ]) + elif "Optional[int]" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}'] = str({sanitized_name})", + ]) + elif "List[" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}[]'] = {sanitized_name}", + ]) + else: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}'] = {sanitized_name}", + ]) + + return lines + + def _build_path_formatting(self, path: str, endpoint_info: Dict) -> str: + """Build URL path with parameter substitution.""" + path_params = [ + name + for name, info in endpoint_info["parameters"].items() + if info["location"] == "path" + ] + + if path_params: + format_dict = ", ".join( + f"{param}={self._sanitize_parameter_name(param)}" + for param in path_params + ) + return f' url = self.base_url + "{path}".format({format_dict})' + else: + return f' url = self.base_url + "{path}"' + + def _build_request_body(self, endpoint_info: Dict) -> List[str]: + """Build request body handling.""" + body_params = { + name: info + for name, info in endpoint_info["parameters"].items() + if info["location"] == "body" + } + + if not body_params: + return [] + + lines = [" body: dict[str, Any] = {}"] + + for param_name, param_info in body_params.items(): + sanitized_name = self._sanitize_parameter_name(param_name) + + if param_name in endpoint_info["required"]: + lines.append(f" body['{param_name}'] = {sanitized_name}") + else: + lines.extend([ + f" if {sanitized_name} is not None:", + f" body['{param_name}'] = {sanitized_name}", + ]) + + return lines + + @staticmethod + def _modernize_type(type_str: str) -> str: + """Convert typing-style annotations to modern Python 3.10+ syntax. + + Optional[str] -> str | None, Dict[str, Any] -> dict[str, Any], + List[str] -> list[str], etc. + """ + if type_str.startswith("Optional[") and type_str.endswith("]"): + inner = type_str[len("Optional["):-1] + inner = PipedriveDataSourceGenerator._modernize_type(inner) + return f"{inner} | None" + if type_str.startswith("Dict["): + inner = type_str[len("Dict["):-1] + parts = PipedriveDataSourceGenerator._split_type_args(inner) + modernized = ", ".join( + PipedriveDataSourceGenerator._modernize_type(p.strip()) for p in parts + ) + return f"dict[{modernized}]" + if type_str == "Dict": + return "dict" + if type_str.startswith("List["): + inner = type_str[len("List["):-1] + parts = PipedriveDataSourceGenerator._split_type_args(inner) + modernized = ", ".join( + PipedriveDataSourceGenerator._modernize_type(p.strip()) for p in parts + ) + return f"list[{modernized}]" + if type_str == "List": + return "list" + return type_str + + @staticmethod + def _split_type_args(s: str) -> List[str]: + """Split type arguments respecting nested brackets.""" + parts = [] + depth = 0 + current = "" + for ch in s: + if ch == "[": + depth += 1 + elif ch == "]": + depth -= 1 + if ch == "," and depth == 0: + parts.append(current.strip()) + current = "" + else: + current += ch + if current.strip(): + parts.append(current.strip()) + return parts + + def _generate_method_signature(self, method_name: str, endpoint_info: Dict) -> str: + """Generate method signature with explicit parameters.""" + params = ["self"] + has_any_bool = False + + # Collect required params, split into non-bool and bool groups + required_non_bool: List[str] = [] + required_bool: List[str] = [] + for param_name in endpoint_info["required"]: + if param_name in endpoint_info["parameters"]: + param_info = endpoint_info["parameters"][param_name] + sanitized_name = self._sanitize_parameter_name(param_name) + modern_type = self._modernize_type(param_info["type"]) + param_str = f"{sanitized_name}: {modern_type}" + if "bool" in param_info.get("type", ""): + required_bool.append(param_str) + has_any_bool = True + else: + required_non_bool.append(param_str) + + # Collect optional parameters + optional_params: List[str] = [] + for param_name, param_info in endpoint_info["parameters"].items(): + if param_name not in endpoint_info["required"]: + sanitized_name = self._sanitize_parameter_name(param_name) + modern_type = self._modernize_type(param_info["type"]) + if "| None" not in modern_type: + modern_type = f"{modern_type} | None" + optional_params.append(f"{sanitized_name}: {modern_type} = None") + if "bool" in param_info.get("type", ""): + has_any_bool = True + + # Build signature: non-bool required first, then * if needed, then bool required + optional + params.extend(required_non_bool) + if has_any_bool and (required_bool or optional_params): + params.append("*") + params.extend(required_bool) + params.extend(optional_params) + + signature_params = ",\n ".join(params) + return f" async def {method_name}(\n {signature_params}\n ) -> PipedriveResponse:" + + def _generate_method_docstring(self, endpoint_info: Dict) -> List[str]: + """Generate method docstring.""" + lines = [f' """{endpoint_info["description"]}', ""] + + if endpoint_info["parameters"]: + lines.append(" Args:") + for param_name, param_info in endpoint_info["parameters"].items(): + sanitized_name = self._sanitize_parameter_name(param_name) + lines.append( + f" {sanitized_name}: {param_info['description']}" + ) + lines.append("") + + lines.extend([ + " Returns:", + " PipedriveResponse with operation result", + ' """', + ]) + + return lines + + def _generate_method(self, method_name: str, endpoint_info: Dict) -> str: + """Generate a complete method for an API endpoint.""" + lines = [] + + # Method signature + lines.append(self._generate_method_signature(method_name, endpoint_info)) + + # Docstring + lines.extend(self._generate_method_docstring(endpoint_info)) + + # Query parameters + has_query = any( + info["location"] == "query" + for info in endpoint_info["parameters"].values() + ) + if has_query: + query_lines = self._build_query_params(endpoint_info) + lines.extend(query_lines) + lines.append("") + + # URL construction + lines.append(self._build_path_formatting(endpoint_info["path"], endpoint_info)) + + # Request body + body_lines = self._build_request_body(endpoint_info) + if body_lines: + lines.append("") + lines.extend(body_lines) + + # Request construction and execution + lines.append("") + lines.append(" try:") + lines.append(" request = HTTPRequest(") + lines.append(f' method="{endpoint_info["method"]}",') + lines.append(" url=url,") + lines.append(' headers={"Content-Type": "application/json"},') + if has_query: + lines.append(" query=query_params,") + if body_lines: + lines.append(" body=body,") + lines.append(" )") + lines.extend([ + " response = await self.http.execute(request) # type: ignore[reportUnknownMemberType]", + " response_data = response.json() if response.text() else None", + " return PipedriveResponse(", + " success=response.status < HTTP_ERROR_THRESHOLD,", + " data=response_data,", + f' message="Successfully executed {method_name}" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {{response.status}}"', + " )", + " except Exception as e:", + f' return PipedriveResponse(success=False, error=str(e), message="Failed to execute {method_name}")', + ]) + + self.generated_methods.append({ + "name": method_name, + "endpoint": endpoint_info["path"], + "method": endpoint_info["method"], + "description": endpoint_info["description"], + }) + + return "\n".join(lines) + + def generate_pipedrive_datasource(self) -> str: + """Generate the complete Pipedrive datasource class.""" + + class_lines = [ + '"""', + "Pipedrive REST API DataSource - Auto-generated API wrapper", + "", + "Generated from Pipedrive REST API v1 documentation.", + "Uses HTTP client for direct REST API interactions.", + "All methods have explicit parameter signatures.", + '"""', + "", + "from __future__ import annotations", + "", + "from typing import Any", + "", + "from app.sources.client.http.http_request import HTTPRequest", + "from app.sources.client.pipedrive.pipedrive import PipedriveClient, PipedriveResponse", + "", + "# HTTP status code constant", + "HTTP_ERROR_THRESHOLD = 400", + "", + "", + "class PipedriveDataSource:", + ' """Pipedrive REST API DataSource', + "", + " Provides async wrapper methods for Pipedrive REST API operations:", + " - Users management", + " - Deals CRUD and management", + " - Persons (contacts) CRUD", + " - Organizations CRUD", + " - Activities management", + " - Pipelines and Stages", + " - Products management", + " - Notes CRUD", + " - Leads management", + " - Custom fields (Deal, Person, Organization)", + "", + " The base URL is determined by the PipedriveClient's configured base URL.", + "", + " All methods return PipedriveResponse objects.", + ' """', + "", + " def __init__(self, client: PipedriveClient) -> None:", + ' """Initialize with PipedriveClient.', + "", + " Args:", + " client: PipedriveClient instance with configured authentication", + ' """', + " self._client = client", + " self.http = client.get_client()", + " try:", + " self.base_url = self.http.get_base_url().rstrip('/')", + " except AttributeError as exc:", + " raise ValueError('HTTP client does not have get_base_url method') from exc", + "", + " def get_data_source(self) -> 'PipedriveDataSource':", + ' """Return the data source instance."""', + " return self", + "", + " def get_client(self) -> PipedriveClient:", + ' """Return the underlying PipedriveClient."""', + " return self._client", + "", + ] + + # Generate all API methods + for method_name, endpoint_info in PIPEDRIVE_API_ENDPOINTS.items(): + class_lines.append(self._generate_method(method_name, endpoint_info)) + class_lines.append("") + + return "\n".join(class_lines) + + def save_to_file(self, filename: Optional[str] = None) -> None: + """Generate and save the Pipedrive datasource to a file.""" + if filename is None: + filename = "pipedrive.py" + + script_dir = Path(__file__).parent if __file__ else Path(".") + pipedrive_dir = script_dir.parent / "app" / "sources" / "external" / "pipedrive" + pipedrive_dir.mkdir(parents=True, exist_ok=True) + + full_path = pipedrive_dir / filename + + class_code = self.generate_pipedrive_datasource() + + full_path.write_text(class_code, encoding="utf-8") + + print(f"Generated Pipedrive data source with {len(self.generated_methods)} methods") + print(f"Saved to: {full_path}") + + # Print summary by category + resource_categories = { + "Users": 0, + "Deals": 0, + "Persons": 0, + "Organizations": 0, + "Activities": 0, + "Pipelines": 0, + "Stages": 0, + "Products": 0, + "Notes": 0, + "Leads": 0, + "Custom Fields": 0, + } + + for method in self.generated_methods: + name = method["name"] + if "user" in name: + resource_categories["Users"] += 1 + elif "deal" in name and "field" not in name: + resource_categories["Deals"] += 1 + elif "person" in name and "field" not in name: + resource_categories["Persons"] += 1 + elif "organization" in name and "field" not in name: + resource_categories["Organizations"] += 1 + elif "activit" in name: + resource_categories["Activities"] += 1 + elif "pipeline" in name: + resource_categories["Pipelines"] += 1 + elif "stage" in name: + resource_categories["Stages"] += 1 + elif "product" in name: + resource_categories["Products"] += 1 + elif "note" in name: + resource_categories["Notes"] += 1 + elif "lead" in name: + resource_categories["Leads"] += 1 + elif "field" in name: + resource_categories["Custom Fields"] += 1 + + print(f"\nMethods by Resource:") + for category, count in resource_categories.items(): + if count > 0: + print(f" - {category}: {count}") + + +def main(): + """Main function for Pipedrive data source generator.""" + import argparse + + parser = argparse.ArgumentParser( + description="Generate Pipedrive REST API data source" + ) + parser.add_argument("--filename", "-f", help="Output filename (optional)") + + args = parser.parse_args() + + try: + generator = PipedriveDataSourceGenerator() + generator.save_to_file(args.filename) + return 0 + except Exception as e: + print(f"Failed to generate Pipedrive data source: {e}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/python/code-generator/procore.py b/backend/python/code-generator/procore.py new file mode 100644 index 000000000..804dd980e --- /dev/null +++ b/backend/python/code-generator/procore.py @@ -0,0 +1,717 @@ +# ruff: noqa +""" +Procore REST API Code Generator + +Generates ProcoreDataSource class covering Procore API v1.0: +- Current user +- Companies +- Projects +- RFIs, Submittals +- Documents, Drawings +- Daily logs, Incidents +- Users (company and project) +- Tasks, Budgets, Change orders + +The generated DataSource accepts a ProcoreClient and uses the client's +configured base URL. All methods have explicit parameter signatures with +no **kwargs usage. + +API Reference: https://developers.procore.com/reference/rest/v1 +""" + +import sys +from pathlib import Path +from typing import Dict, List, Optional + +# ================================================================================ +# Procore API Endpoints +# +# Each endpoint defines: +# method: HTTP verb +# path: URL path (appended to base_url) +# description: Human-readable description +# parameters: Dict of param_name -> {type, location (path/query/body), description} +# required: List of required parameter names +# ================================================================================ + +PROCORE_API_ENDPOINTS = { + # ================================================================================ + # CURRENT USER + # ================================================================================ + "get_me": { + "method": "GET", + "path": "/me", + "description": "Get the current authenticated user", + "parameters": {}, + "required": [], + }, + + # ================================================================================ + # COMPANIES + # ================================================================================ + "list_companies": { + "method": "GET", + "path": "/companies", + "description": "List all companies accessible to the current user", + "parameters": {}, + "required": [], + }, + "get_company": { + "method": "GET", + "path": "/companies/{company_id}", + "description": "Get a specific company by ID", + "parameters": { + "company_id": {"type": "str", "location": "path", "description": "The company ID"}, + }, + "required": ["company_id"], + }, + + # ================================================================================ + # PROJECTS + # ================================================================================ + "list_projects": { + "method": "GET", + "path": "/projects", + "description": "List projects for a company", + "parameters": { + "company_id": {"type": "str", "location": "query", "description": "The company ID (required)"}, + "page": {"type": "Optional[int]", "location": "query", "description": "Page number for pagination"}, + "per_page": {"type": "Optional[int]", "location": "query", "description": "Number of results per page"}, + "filters_status_id": {"type": "Optional[str]", "location": "query", "description": "Filter by project status ID"}, + }, + "required": ["company_id"], + }, + "get_project": { + "method": "GET", + "path": "/projects/{project_id}", + "description": "Get a specific project by ID", + "parameters": { + "project_id": {"type": "str", "location": "path", "description": "The project ID"}, + }, + "required": ["project_id"], + }, + + # ================================================================================ + # RFIs + # ================================================================================ + "list_rfis": { + "method": "GET", + "path": "/projects/{project_id}/rfis", + "description": "List RFIs for a project", + "parameters": { + "project_id": {"type": "str", "location": "path", "description": "The project ID"}, + "page": {"type": "Optional[int]", "location": "query", "description": "Page number for pagination"}, + "per_page": {"type": "Optional[int]", "location": "query", "description": "Number of results per page"}, + }, + "required": ["project_id"], + }, + "get_rfi": { + "method": "GET", + "path": "/projects/{project_id}/rfis/{rfi_id}", + "description": "Get a specific RFI by ID", + "parameters": { + "project_id": {"type": "str", "location": "path", "description": "The project ID"}, + "rfi_id": {"type": "str", "location": "path", "description": "The RFI ID"}, + }, + "required": ["project_id", "rfi_id"], + }, + + # ================================================================================ + # SUBMITTALS + # ================================================================================ + "list_submittals": { + "method": "GET", + "path": "/projects/{project_id}/submittals", + "description": "List submittals for a project", + "parameters": { + "project_id": {"type": "str", "location": "path", "description": "The project ID"}, + "page": {"type": "Optional[int]", "location": "query", "description": "Page number for pagination"}, + "per_page": {"type": "Optional[int]", "location": "query", "description": "Number of results per page"}, + }, + "required": ["project_id"], + }, + "get_submittal": { + "method": "GET", + "path": "/projects/{project_id}/submittals/{submittal_id}", + "description": "Get a specific submittal by ID", + "parameters": { + "project_id": {"type": "str", "location": "path", "description": "The project ID"}, + "submittal_id": {"type": "str", "location": "path", "description": "The submittal ID"}, + }, + "required": ["project_id", "submittal_id"], + }, + + # ================================================================================ + # DOCUMENTS + # ================================================================================ + "list_documents": { + "method": "GET", + "path": "/projects/{project_id}/documents", + "description": "List documents for a project", + "parameters": { + "project_id": {"type": "str", "location": "path", "description": "The project ID"}, + "page": {"type": "Optional[int]", "location": "query", "description": "Page number for pagination"}, + "per_page": {"type": "Optional[int]", "location": "query", "description": "Number of results per page"}, + }, + "required": ["project_id"], + }, + + # ================================================================================ + # DRAWINGS + # ================================================================================ + "list_drawings": { + "method": "GET", + "path": "/projects/{project_id}/drawings", + "description": "List drawings for a project", + "parameters": { + "project_id": {"type": "str", "location": "path", "description": "The project ID"}, + "page": {"type": "Optional[int]", "location": "query", "description": "Page number for pagination"}, + "per_page": {"type": "Optional[int]", "location": "query", "description": "Number of results per page"}, + }, + "required": ["project_id"], + }, + + # ================================================================================ + # DAILY LOGS + # ================================================================================ + "list_daily_logs": { + "method": "GET", + "path": "/projects/{project_id}/daily_logs", + "description": "List daily logs for a project", + "parameters": { + "project_id": {"type": "str", "location": "path", "description": "The project ID"}, + "page": {"type": "Optional[int]", "location": "query", "description": "Page number for pagination"}, + "per_page": {"type": "Optional[int]", "location": "query", "description": "Number of results per page"}, + "log_date": {"type": "Optional[str]", "location": "query", "description": "Filter by log date (YYYY-MM-DD)"}, + }, + "required": ["project_id"], + }, + + # ================================================================================ + # INCIDENTS + # ================================================================================ + "list_incidents": { + "method": "GET", + "path": "/projects/{project_id}/incidents", + "description": "List incidents for a project", + "parameters": { + "project_id": {"type": "str", "location": "path", "description": "The project ID"}, + "page": {"type": "Optional[int]", "location": "query", "description": "Page number for pagination"}, + "per_page": {"type": "Optional[int]", "location": "query", "description": "Number of results per page"}, + }, + "required": ["project_id"], + }, + + # ================================================================================ + # USERS (COMPANY) + # ================================================================================ + "list_company_users": { + "method": "GET", + "path": "/companies/{company_id}/users", + "description": "List users for a company", + "parameters": { + "company_id": {"type": "str", "location": "path", "description": "The company ID"}, + "page": {"type": "Optional[int]", "location": "query", "description": "Page number for pagination"}, + "per_page": {"type": "Optional[int]", "location": "query", "description": "Number of results per page"}, + }, + "required": ["company_id"], + }, + + # ================================================================================ + # USERS (PROJECT) + # ================================================================================ + "list_project_users": { + "method": "GET", + "path": "/projects/{project_id}/users", + "description": "List users for a project", + "parameters": { + "project_id": {"type": "str", "location": "path", "description": "The project ID"}, + "page": {"type": "Optional[int]", "location": "query", "description": "Page number for pagination"}, + "per_page": {"type": "Optional[int]", "location": "query", "description": "Number of results per page"}, + }, + "required": ["project_id"], + }, + + # ================================================================================ + # TASKS + # ================================================================================ + "list_tasks": { + "method": "GET", + "path": "/projects/{project_id}/tasks", + "description": "List tasks for a project", + "parameters": { + "project_id": {"type": "str", "location": "path", "description": "The project ID"}, + "page": {"type": "Optional[int]", "location": "query", "description": "Page number for pagination"}, + "per_page": {"type": "Optional[int]", "location": "query", "description": "Number of results per page"}, + }, + "required": ["project_id"], + }, + + # ================================================================================ + # BUDGETS + # ================================================================================ + "list_budgets": { + "method": "GET", + "path": "/projects/{project_id}/budgets", + "description": "List budgets for a project", + "parameters": { + "project_id": {"type": "str", "location": "path", "description": "The project ID"}, + }, + "required": ["project_id"], + }, + + # ================================================================================ + # CHANGE ORDERS + # ================================================================================ + "list_change_orders": { + "method": "GET", + "path": "/projects/{project_id}/change_orders", + "description": "List change orders for a project", + "parameters": { + "project_id": {"type": "str", "location": "path", "description": "The project ID"}, + "page": {"type": "Optional[int]", "location": "query", "description": "Page number for pagination"}, + "per_page": {"type": "Optional[int]", "location": "query", "description": "Number of results per page"}, + }, + "required": ["project_id"], + }, +} + + +# ================================================================================ +# Code Generator +# ================================================================================ + + +class ProcoreDataSourceGenerator: + """Generator for comprehensive Procore REST API datasource class. + + Generates methods for Procore API v1.0 endpoints. + The generated DataSource class accepts a ProcoreClient whose base URL + is pre-configured. + """ + + def __init__(self): + self.generated_methods: List[Dict[str, str]] = [] + + def _sanitize_parameter_name(self, name: str) -> str: + """Sanitize parameter names to be valid Python identifiers.""" + sanitized = name.replace("-", "_").replace(".", "_").replace("/", "_") + if sanitized and not (sanitized[0].isalpha() or sanitized[0] == "_"): + sanitized = f"param_{sanitized}" + return sanitized + + def _build_query_params(self, endpoint_info: Dict) -> List[str]: + """Build query parameter handling code.""" + lines = [" query_params: dict[str, Any] = {}"] + required = endpoint_info.get("required", []) + + for param_name, param_info in endpoint_info["parameters"].items(): + if param_info["location"] == "query": + sanitized_name = self._sanitize_parameter_name(param_name) + is_required = param_name in required + + if is_required: + # Required query params: assign directly, no None check + lines.append( + f" query_params['{param_name}'] = {sanitized_name}" + ) + elif "Optional[bool]" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}'] = str({sanitized_name}).lower()", + ]) + elif "Optional[int]" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}'] = str({sanitized_name})", + ]) + elif "List[" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}[]'] = {sanitized_name}", + ]) + else: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}'] = {sanitized_name}", + ]) + + return lines + + def _build_path_formatting(self, path: str, endpoint_info: Dict) -> str: + """Build URL path with parameter substitution.""" + path_params = [ + name + for name, info in endpoint_info["parameters"].items() + if info["location"] == "path" + ] + + if path_params: + format_dict = ", ".join( + f"{param}={self._sanitize_parameter_name(param)}" + for param in path_params + ) + return f' url = self.base_url + "{path}".format({format_dict})' + else: + return f' url = self.base_url + "{path}"' + + def _build_request_body(self, endpoint_info: Dict) -> List[str]: + """Build request body handling.""" + body_params = { + name: info + for name, info in endpoint_info["parameters"].items() + if info["location"] == "body" + } + + if not body_params: + return [] + + lines = [" body: dict[str, Any] = {}"] + + for param_name, param_info in body_params.items(): + sanitized_name = self._sanitize_parameter_name(param_name) + + if param_name in endpoint_info["required"]: + lines.append(f" body['{param_name}'] = {sanitized_name}") + else: + lines.extend([ + f" if {sanitized_name} is not None:", + f" body['{param_name}'] = {sanitized_name}", + ]) + + return lines + + @staticmethod + def _modernize_type(type_str: str) -> str: + """Convert typing-style annotations to modern Python 3.10+ syntax.""" + if type_str.startswith("Optional[") and type_str.endswith("]"): + inner = type_str[len("Optional["):-1] + inner = ProcoreDataSourceGenerator._modernize_type(inner) + return f"{inner} | None" + if type_str.startswith("Dict["): + inner = type_str[len("Dict["):-1] + parts = ProcoreDataSourceGenerator._split_type_args(inner) + modernized = ", ".join( + ProcoreDataSourceGenerator._modernize_type(p.strip()) for p in parts + ) + return f"dict[{modernized}]" + if type_str == "Dict": + return "dict" + if type_str.startswith("List["): + inner = type_str[len("List["):-1] + parts = ProcoreDataSourceGenerator._split_type_args(inner) + modernized = ", ".join( + ProcoreDataSourceGenerator._modernize_type(p.strip()) for p in parts + ) + return f"list[{modernized}]" + if type_str == "List": + return "list" + return type_str + + @staticmethod + def _split_type_args(s: str) -> List[str]: + """Split type arguments respecting nested brackets.""" + parts = [] + depth = 0 + current = "" + for ch in s: + if ch == "[": + depth += 1 + elif ch == "]": + depth -= 1 + if ch == "," and depth == 0: + parts.append(current.strip()) + current = "" + else: + current += ch + if current.strip(): + parts.append(current.strip()) + return parts + + def _generate_method_signature(self, method_name: str, endpoint_info: Dict) -> str: + """Generate method signature with explicit parameters.""" + params = ["self"] + has_any_bool = False + + # Collect required params, split into non-bool and bool groups + required_non_bool: List[str] = [] + required_bool: List[str] = [] + for param_name in endpoint_info["required"]: + if param_name in endpoint_info["parameters"]: + param_info = endpoint_info["parameters"][param_name] + sanitized_name = self._sanitize_parameter_name(param_name) + modern_type = self._modernize_type(param_info["type"]) + param_str = f"{sanitized_name}: {modern_type}" + if "bool" in param_info.get("type", ""): + required_bool.append(param_str) + has_any_bool = True + else: + required_non_bool.append(param_str) + + # Collect optional parameters + optional_params: List[str] = [] + for param_name, param_info in endpoint_info["parameters"].items(): + if param_name not in endpoint_info["required"]: + sanitized_name = self._sanitize_parameter_name(param_name) + modern_type = self._modernize_type(param_info["type"]) + if "| None" not in modern_type: + modern_type = f"{modern_type} | None" + optional_params.append(f"{sanitized_name}: {modern_type} = None") + if "bool" in param_info.get("type", ""): + has_any_bool = True + + # Build signature: non-bool required first, then * if needed, then bool required + optional + params.extend(required_non_bool) + if has_any_bool and (required_bool or optional_params): + params.append("*") + params.extend(required_bool) + params.extend(optional_params) + + signature_params = ",\n ".join(params) + return f" async def {method_name}(\n {signature_params}\n ) -> ProcoreResponse:" + + def _generate_method_docstring(self, endpoint_info: Dict) -> List[str]: + """Generate method docstring.""" + lines = [f' """{endpoint_info["description"]}', ""] + + if endpoint_info["parameters"]: + lines.append(" Args:") + for param_name, param_info in endpoint_info["parameters"].items(): + sanitized_name = self._sanitize_parameter_name(param_name) + lines.append( + f" {sanitized_name}: {param_info['description']}" + ) + lines.append("") + + lines.extend([ + " Returns:", + " ProcoreResponse with operation result", + ' """', + ]) + + return lines + + def _generate_method(self, method_name: str, endpoint_info: Dict) -> str: + """Generate a complete method for an API endpoint.""" + lines = [] + + # Method signature + lines.append(self._generate_method_signature(method_name, endpoint_info)) + + # Docstring + lines.extend(self._generate_method_docstring(endpoint_info)) + + # Query parameters + has_query = any( + info["location"] == "query" + for info in endpoint_info["parameters"].values() + ) + if has_query: + query_lines = self._build_query_params(endpoint_info) + lines.extend(query_lines) + lines.append("") + + # URL construction + lines.append(self._build_path_formatting(endpoint_info["path"], endpoint_info)) + + # Request body + body_lines = self._build_request_body(endpoint_info) + if body_lines: + lines.append("") + lines.extend(body_lines) + + # Request construction and execution + lines.append("") + lines.append(" try:") + lines.append(" request = HTTPRequest(") + lines.append(f' method="{endpoint_info["method"]}",') + lines.append(" url=url,") + lines.append(' headers={"Content-Type": "application/json"},') + if has_query: + lines.append(" query=query_params,") + if body_lines: + lines.append(" body=body,") + lines.append(" )") + lines.extend([ + " response = await self.http.execute(request) # type: ignore[reportUnknownMemberType]", + " response_data = response.json() if response.text() else None", + " return ProcoreResponse(", + " success=response.status < HTTP_ERROR_THRESHOLD,", + " data=response_data,", + f' message="Successfully executed {method_name}" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {{response.status}}"', + " )", + " except Exception as e:", + f' return ProcoreResponse(success=False, error=str(e), message="Failed to execute {method_name}")', + ]) + + self.generated_methods.append({ + "name": method_name, + "endpoint": endpoint_info["path"], + "method": endpoint_info["method"], + "description": endpoint_info["description"], + }) + + return "\n".join(lines) + + def generate_procore_datasource(self) -> str: + """Generate the complete Procore datasource class.""" + + class_lines = [ + '"""', + "Procore REST API DataSource - Auto-generated API wrapper", + "", + "Generated from Procore REST API v1.0 documentation.", + "Uses HTTP client for direct REST API interactions.", + "All methods have explicit parameter signatures.", + '"""', + "", + "from __future__ import annotations", + "", + "from typing import Any", + "", + "from app.sources.client.procore.procore import ProcoreClient, ProcoreResponse", + "from app.sources.client.http.http_request import HTTPRequest", + "", + "# HTTP status code constant", + "HTTP_ERROR_THRESHOLD = 400", + "", + "", + "class ProcoreDataSource:", + ' """Procore REST API DataSource', + "", + " Provides async wrapper methods for Procore REST API operations:", + " - Current user", + " - Companies", + " - Projects", + " - RFIs, Submittals", + " - Documents, Drawings", + " - Daily logs, Incidents", + " - Users (company and project level)", + " - Tasks, Budgets, Change orders", + "", + " The base URL is determined by the ProcoreClient's configuration.", + "", + " All methods return ProcoreResponse objects.", + ' """', + "", + " def __init__(self, client: ProcoreClient) -> None:", + ' """Initialize with ProcoreClient.', + "", + " Args:", + " client: ProcoreClient instance with configured authentication", + ' """', + " self._client = client", + " self.http = client.get_client()", + " try:", + " self.base_url = self.http.get_base_url().rstrip('/')", + " except AttributeError as exc:", + " raise ValueError('HTTP client does not have get_base_url method') from exc", + "", + " def get_data_source(self) -> 'ProcoreDataSource':", + ' """Return the data source instance."""', + " return self", + "", + " def get_client(self) -> ProcoreClient:", + ' """Return the underlying ProcoreClient."""', + " return self._client", + "", + ] + + # Generate all API methods + for method_name, endpoint_info in PROCORE_API_ENDPOINTS.items(): + class_lines.append(self._generate_method(method_name, endpoint_info)) + class_lines.append("") + + return "\n".join(class_lines) + + def save_to_file(self, filename: Optional[str] = None) -> None: + """Generate and save the Procore datasource to a file.""" + if filename is None: + filename = "procore.py" + + script_dir = Path(__file__).parent if __file__ else Path(".") + procore_dir = script_dir.parent / "app" / "sources" / "external" / "procore" + procore_dir.mkdir(parents=True, exist_ok=True) + + full_path = procore_dir / filename + + class_code = self.generate_procore_datasource() + + full_path.write_text(class_code, encoding="utf-8") + + print(f"Generated Procore data source with {len(self.generated_methods)} methods") + print(f"Saved to: {full_path}") + + # Print summary by category + resource_categories = { + "User/Me": 0, + "Company": 0, + "Project": 0, + "RFI": 0, + "Submittal": 0, + "Document": 0, + "Drawing": 0, + "Daily Log": 0, + "Incident": 0, + "Task": 0, + "Budget": 0, + "Change Order": 0, + } + + for method in self.generated_methods: + name = method["name"] + if name == "get_me": + resource_categories["User/Me"] += 1 + elif "company" in name and "user" not in name: + resource_categories["Company"] += 1 + elif "project" in name and "user" not in name: + resource_categories["Project"] += 1 + elif "rfi" in name: + resource_categories["RFI"] += 1 + elif "submittal" in name: + resource_categories["Submittal"] += 1 + elif "document" in name: + resource_categories["Document"] += 1 + elif "drawing" in name: + resource_categories["Drawing"] += 1 + elif "daily_log" in name: + resource_categories["Daily Log"] += 1 + elif "incident" in name: + resource_categories["Incident"] += 1 + elif "user" in name: + resource_categories["User/Me"] += 1 + elif "task" in name: + resource_categories["Task"] += 1 + elif "budget" in name: + resource_categories["Budget"] += 1 + elif "change_order" in name: + resource_categories["Change Order"] += 1 + + print(f"\nMethods by Resource:") + for category, count in resource_categories.items(): + if count > 0: + print(f" - {category}: {count}") + + +def main(): + """Main function for Procore data source generator.""" + import argparse + + parser = argparse.ArgumentParser( + description="Generate Procore REST API data source" + ) + parser.add_argument("--filename", "-f", help="Output filename (optional)") + + args = parser.parse_args() + + try: + generator = ProcoreDataSourceGenerator() + generator.save_to_file(args.filename) + return 0 + except Exception as e: + print(f"Failed to generate Procore data source: {e}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/python/code-generator/quip.py b/backend/python/code-generator/quip.py new file mode 100644 index 000000000..075d79ae0 --- /dev/null +++ b/backend/python/code-generator/quip.py @@ -0,0 +1,622 @@ +# ruff: noqa +""" +Quip REST API Code Generator + +Generates QuipDataSource class covering Quip Automation API: +- Users (current user, get user) +- Threads (documents) - get, create, edit, search, recent +- Messages (thread comments) +- Folders - get, create + +The generated DataSource accepts a QuipClient and uses the client's +configured base URL. + +All methods have explicit parameter signatures with no **kwargs usage. + +Usage: + python code-generator/quip.py + python code-generator/quip.py --filename quip.py +""" + +import sys +from pathlib import Path +from typing import Dict, List, Optional + +# ================================================================================ +# Quip API Endpoints +# +# Each endpoint defines: +# method: HTTP verb +# path: URL path (appended to base_url which is https://platform.quip.com/1) +# description: Human-readable description +# parameters: Dict of param_name -> {type, location (path/query/body), description} +# required: List of required parameter names +# ================================================================================ + +QUIP_API_ENDPOINTS = { + # ================================================================================ + # USERS + # ================================================================================ + "get_current_user": { + "method": "GET", + "path": "/users/current", + "description": "Get the authenticated user's information", + "parameters": {}, + "required": [], + }, + "get_user": { + "method": "GET", + "path": "/users/{user_id}", + "description": "Get a specific user by ID", + "parameters": { + "user_id": {"type": "str", "location": "path", "description": "The user ID"}, + }, + "required": ["user_id"], + }, + "get_users": { + "method": "GET", + "path": "/users/{user_ids}", + "description": "Get multiple users by IDs (comma-separated)", + "parameters": { + "user_ids": {"type": "str", "location": "path", "description": "Comma-separated user IDs"}, + }, + "required": ["user_ids"], + }, + "get_contacts": { + "method": "GET", + "path": "/users/contacts", + "description": "Get the authenticated user's contacts", + "parameters": {}, + "required": [], + }, + + # ================================================================================ + # THREADS (Documents) + # ================================================================================ + "get_thread": { + "method": "GET", + "path": "/threads/{thread_id}", + "description": "Get a specific thread (document) by ID", + "parameters": { + "thread_id": {"type": "str", "location": "path", "description": "The thread ID"}, + }, + "required": ["thread_id"], + }, + "get_threads": { + "method": "GET", + "path": "/threads/{thread_ids}", + "description": "Get multiple threads by IDs (comma-separated)", + "parameters": { + "thread_ids": {"type": "str", "location": "path", "description": "Comma-separated thread IDs"}, + }, + "required": ["thread_ids"], + }, + "get_recent_threads": { + "method": "GET", + "path": "/threads/recent", + "description": "Get recently accessed threads for the authenticated user", + "parameters": { + "count": {"type": "Optional[int]", "location": "query", "description": "Number of threads to return"}, + "max_updated_usec": {"type": "Optional[int]", "location": "query", "description": "Max updated time in microseconds (for pagination)"}, + }, + "required": [], + }, + "search_threads": { + "method": "GET", + "path": "/threads/search", + "description": "Search for threads (documents)", + "parameters": { + "query": {"type": "str", "location": "query", "description": "Search query string"}, + "count": {"type": "Optional[int]", "location": "query", "description": "Number of results to return"}, + "only_match_titles": {"type": "Optional[bool]", "location": "query", "description": "Only match thread titles"}, + }, + "required": ["query"], + }, + "create_document": { + "method": "POST", + "path": "/threads/new-document", + "description": "Create a new document thread", + "parameters": { + "content": {"type": "str", "location": "body", "description": "HTML content of the document"}, + "title": {"type": "Optional[str]", "location": "body", "description": "Document title"}, + "format": {"type": "Optional[str]", "location": "body", "description": "Content format ('html' or 'markdown')"}, + "member_ids": {"type": "Optional[list[str]]", "location": "body", "description": "List of member IDs to add"}, + "type": {"type": "Optional[str]", "location": "body", "description": "Thread type (document, spreadsheet)"}, + }, + "required": ["content"], + }, + "edit_document": { + "method": "POST", + "path": "/threads/edit-document", + "description": "Edit an existing document thread", + "parameters": { + "thread_id": {"type": "str", "location": "body", "description": "The thread ID to edit"}, + "content": {"type": "Optional[str]", "location": "body", "description": "New HTML content"}, + "format": {"type": "Optional[str]", "location": "body", "description": "Content format ('html' or 'markdown')"}, + "location": {"type": "Optional[int]", "location": "body", "description": "Insert location (0=beginning, 1=end, 2=after_section, 3=before_section, 4=replace_section, 5=delete_section)"}, + "section_id": {"type": "Optional[str]", "location": "body", "description": "Section ID for location-based edits"}, + }, + "required": ["thread_id"], + }, + "add_thread_members": { + "method": "POST", + "path": "/threads/add-members", + "description": "Add members to a thread", + "parameters": { + "thread_id": {"type": "str", "location": "body", "description": "The thread ID"}, + "member_ids": {"type": "list[str]", "location": "body", "description": "List of user IDs to add as members"}, + }, + "required": ["thread_id", "member_ids"], + }, + "remove_thread_members": { + "method": "POST", + "path": "/threads/remove-members", + "description": "Remove members from a thread", + "parameters": { + "thread_id": {"type": "str", "location": "body", "description": "The thread ID"}, + "member_ids": {"type": "list[str]", "location": "body", "description": "List of user IDs to remove"}, + }, + "required": ["thread_id", "member_ids"], + }, + "move_thread": { + "method": "POST", + "path": "/threads/move", + "description": "Move a thread to a different folder", + "parameters": { + "thread_id": {"type": "str", "location": "body", "description": "The thread ID to move"}, + "folder_id": {"type": "str", "location": "body", "description": "Destination folder ID"}, + }, + "required": ["thread_id", "folder_id"], + }, + "delete_thread": { + "method": "POST", + "path": "/threads/delete", + "description": "Delete (trash) a thread", + "parameters": { + "thread_id": {"type": "str", "location": "body", "description": "The thread ID to delete"}, + }, + "required": ["thread_id"], + }, + + # ================================================================================ + # MESSAGES (Thread Comments) + # ================================================================================ + "get_thread_messages": { + "method": "GET", + "path": "/messages/{thread_id}", + "description": "Get messages (comments) for a thread", + "parameters": { + "thread_id": {"type": "str", "location": "path", "description": "The thread ID"}, + "count": {"type": "Optional[int]", "location": "query", "description": "Number of messages to return"}, + "max_created_usec": {"type": "Optional[int]", "location": "query", "description": "Max created time in microseconds (for pagination)"}, + }, + "required": ["thread_id"], + }, + "create_message": { + "method": "POST", + "path": "/messages/new", + "description": "Create a new message (comment) on a thread", + "parameters": { + "thread_id": {"type": "str", "location": "body", "description": "The thread ID to comment on"}, + "content": {"type": "str", "location": "body", "description": "Message content (can contain HTML)"}, + "frame": {"type": "Optional[str]", "location": "body", "description": "Frame type (bubble, card, line)"}, + "section_id": {"type": "Optional[str]", "location": "body", "description": "Section ID to attach comment to"}, + "annotation_id": {"type": "Optional[str]", "location": "body", "description": "Annotation ID for inline comments"}, + }, + "required": ["thread_id", "content"], + }, + + # ================================================================================ + # FOLDERS + # ================================================================================ + "get_folder": { + "method": "GET", + "path": "/folders/{folder_id}", + "description": "Get a specific folder by ID", + "parameters": { + "folder_id": {"type": "str", "location": "path", "description": "The folder ID"}, + }, + "required": ["folder_id"], + }, + "get_folders": { + "method": "GET", + "path": "/folders/{folder_ids}", + "description": "Get multiple folders by IDs (comma-separated)", + "parameters": { + "folder_ids": {"type": "str", "location": "path", "description": "Comma-separated folder IDs"}, + }, + "required": ["folder_ids"], + }, + "create_folder": { + "method": "POST", + "path": "/folders/new", + "description": "Create a new folder", + "parameters": { + "title": {"type": "str", "location": "body", "description": "Folder title"}, + "parent_id": {"type": "Optional[str]", "location": "body", "description": "Parent folder ID"}, + "color": {"type": "Optional[str]", "location": "body", "description": "Folder color (manila, red, orange, green, blue)"}, + "member_ids": {"type": "Optional[list[str]]", "location": "body", "description": "List of member IDs to add"}, + }, + "required": ["title"], + }, + "update_folder": { + "method": "POST", + "path": "/folders/update", + "description": "Update a folder", + "parameters": { + "folder_id": {"type": "str", "location": "body", "description": "The folder ID to update"}, + "title": {"type": "Optional[str]", "location": "body", "description": "New folder title"}, + "color": {"type": "Optional[str]", "location": "body", "description": "New folder color"}, + }, + "required": ["folder_id"], + }, + "add_folder_members": { + "method": "POST", + "path": "/folders/add-members", + "description": "Add members to a folder", + "parameters": { + "folder_id": {"type": "str", "location": "body", "description": "The folder ID"}, + "member_ids": {"type": "list[str]", "location": "body", "description": "List of user IDs to add"}, + }, + "required": ["folder_id", "member_ids"], + }, + "remove_folder_members": { + "method": "POST", + "path": "/folders/remove-members", + "description": "Remove members from a folder", + "parameters": { + "folder_id": {"type": "str", "location": "body", "description": "The folder ID"}, + "member_ids": {"type": "list[str]", "location": "body", "description": "List of user IDs to remove"}, + }, + "required": ["folder_id", "member_ids"], + }, + "delete_folder": { + "method": "POST", + "path": "/folders/delete", + "description": "Delete (trash) a folder", + "parameters": { + "folder_id": {"type": "str", "location": "body", "description": "The folder ID to delete"}, + }, + "required": ["folder_id"], + }, +} + + +class QuipDataSourceGenerator: + """Generator for comprehensive Quip REST API datasource class.""" + + def __init__(self): + self.generated_methods: List[Dict[str, str]] = [] + + def _sanitize_parameter_name(self, name: str) -> str: + """Sanitize parameter names to be valid Python identifiers.""" + sanitized = name.replace("-", "_").replace(".", "_").replace("/", "_") + if sanitized and not (sanitized[0].isalpha() or sanitized[0] == "_"): + sanitized = f"param_{sanitized}" + return sanitized + + def _build_query_params(self, endpoint_info: Dict) -> List[str]: + """Build query parameter handling code.""" + lines = [" query_params: dict[str, Any] = {}"] + + for param_name, param_info in endpoint_info["parameters"].items(): + if param_info["location"] == "query": + sanitized_name = self._sanitize_parameter_name(param_name) + + if "Optional[bool]" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}'] = str({sanitized_name}).lower()", + ]) + elif "Optional[int]" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}'] = str({sanitized_name})", + ]) + elif param_name in endpoint_info["required"]: + lines.append(f" query_params['{param_name}'] = {sanitized_name}") + else: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{param_name}'] = {sanitized_name}", + ]) + + return lines + + def _build_path_formatting(self, path: str, endpoint_info: Dict) -> str: + """Build URL path with parameter substitution.""" + path_params = [ + name + for name, info in endpoint_info["parameters"].items() + if info["location"] == "path" + ] + + if path_params: + format_dict = ", ".join( + f"{param}={self._sanitize_parameter_name(param)}" + for param in path_params + ) + return f' url = self.base_url + "{path}".format({format_dict})' + else: + return f' url = self.base_url + "{path}"' + + def _build_request_body(self, endpoint_info: Dict) -> List[str]: + """Build request body handling.""" + body_params = { + name: info + for name, info in endpoint_info["parameters"].items() + if info["location"] == "body" + } + + if not body_params: + return [] + + lines = [" body: dict[str, Any] = {}"] + + for param_name, param_info in body_params.items(): + sanitized_name = self._sanitize_parameter_name(param_name) + + if param_name in endpoint_info["required"]: + lines.append(f" body['{param_name}'] = {sanitized_name}") + else: + lines.extend([ + f" if {sanitized_name} is not None:", + f" body['{param_name}'] = {sanitized_name}", + ]) + + return lines + + @staticmethod + def _modernize_type(type_str: str) -> str: + """Convert typing-style annotations to modern Python 3.10+ syntax.""" + if type_str.startswith("Optional[") and type_str.endswith("]"): + inner = type_str[len("Optional["):-1] + inner = QuipDataSourceGenerator._modernize_type(inner) + return f"{inner} | None" + return type_str + + def _generate_method_signature(self, method_name: str, endpoint_info: Dict) -> str: + """Generate method signature with explicit parameters.""" + params = ["self"] + + # Collect required params + required_params: List[str] = [] + for param_name in endpoint_info["required"]: + if param_name in endpoint_info["parameters"]: + param_info = endpoint_info["parameters"][param_name] + sanitized_name = self._sanitize_parameter_name(param_name) + modern_type = self._modernize_type(param_info["type"]) + required_params.append(f"{sanitized_name}: {modern_type}") + + # Collect optional parameters + optional_params: List[str] = [] + for param_name, param_info in endpoint_info["parameters"].items(): + if param_name not in endpoint_info["required"]: + sanitized_name = self._sanitize_parameter_name(param_name) + modern_type = self._modernize_type(param_info["type"]) + if "| None" not in modern_type: + modern_type = f"{modern_type} | None" + optional_params.append(f"{sanitized_name}: {modern_type} = None") + + params.extend(required_params) + if optional_params: + params.append("*") + params.extend(optional_params) + + signature_params = ",\n ".join(params) + return f" async def {method_name}(\n {signature_params}\n ) -> QuipResponse:" + + def _generate_method_docstring(self, endpoint_info: Dict) -> List[str]: + """Generate method docstring.""" + lines = [f' """{endpoint_info["description"]}', ""] + + if endpoint_info["parameters"]: + lines.append(" Args:") + for param_name, param_info in endpoint_info["parameters"].items(): + sanitized_name = self._sanitize_parameter_name(param_name) + lines.append( + f" {sanitized_name}: {param_info['description']}" + ) + lines.append("") + + lines.extend([ + " Returns:", + " QuipResponse with operation result", + ' """', + ]) + + return lines + + def _generate_method(self, method_name: str, endpoint_info: Dict) -> str: + """Generate a complete method for an API endpoint.""" + lines = [] + + # Method signature + lines.append(self._generate_method_signature(method_name, endpoint_info)) + + # Docstring + lines.extend(self._generate_method_docstring(endpoint_info)) + + # Query parameters + has_query = any( + info["location"] == "query" + for info in endpoint_info["parameters"].values() + ) + if has_query: + query_lines = self._build_query_params(endpoint_info) + lines.extend(query_lines) + lines.append("") + + # URL construction + lines.append(self._build_path_formatting(endpoint_info["path"], endpoint_info)) + + # Request body + body_lines = self._build_request_body(endpoint_info) + if body_lines: + lines.append("") + lines.extend(body_lines) + + # Request construction and execution + lines.append("") + lines.append(" try:") + lines.append(" request = HTTPRequest(") + lines.append(f' method="{endpoint_info["method"]}",') + lines.append(" url=url,") + lines.append(' headers={"Content-Type": "application/json"},') + if has_query: + lines.append(" query=query_params,") + if body_lines: + lines.append(" body=body,") + lines.append(" )") + lines.extend([ + " response = await self.http.execute(request) # type: ignore[reportUnknownMemberType]", + " response_data = response.json() if response.text() else None", + " return QuipResponse(", + " success=response.status < HTTP_ERROR_THRESHOLD,", + " data=response_data,", + f' message="Successfully executed {method_name}" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {{response.status}}"', + " )", + " except Exception as e:", + f' return QuipResponse(success=False, error=str(e), message="Failed to execute {method_name}")', + ]) + + self.generated_methods.append({ + "name": method_name, + "endpoint": endpoint_info["path"], + "method": endpoint_info["method"], + "description": endpoint_info["description"], + }) + + return "\n".join(lines) + + def generate_quip_datasource(self) -> str: + """Generate the complete Quip datasource class.""" + + class_lines = [ + '"""', + "Quip REST API DataSource - Auto-generated API wrapper", + "", + "Generated from Quip Automation API documentation.", + "Uses HTTP client for direct REST API interactions.", + "All methods have explicit parameter signatures.", + '"""', + "", + "from __future__ import annotations", + "", + "from typing import Any", + "", + "from app.sources.client.http.http_request import HTTPRequest", + "from app.sources.client.quip.quip import QuipClient, QuipResponse", + "", + "# HTTP status code constant", + "HTTP_ERROR_THRESHOLD = 400", + "", + "", + "class QuipDataSource:", + ' """Quip REST API DataSource', + "", + " Provides async wrapper methods for Quip Automation API operations:", + " - Users (current user, get user, contacts)", + " - Threads/Documents (get, create, edit, search, recent)", + " - Messages/Comments (get, create)", + " - Folders (get, create, update, members)", + "", + " The base URL is https://platform.quip.com/1.", + "", + " All methods return QuipResponse objects.", + ' """', + "", + " def __init__(self, client: QuipClient) -> None:", + ' """Initialize with QuipClient.', + "", + " Args:", + " client: QuipClient instance with configured authentication", + ' """', + " self._client = client", + " self.http = client.get_client()", + " try:", + " self.base_url = self.http.get_base_url().rstrip('/')", + " except AttributeError as exc:", + " raise ValueError('HTTP client does not have get_base_url method') from exc", + "", + " def get_data_source(self) -> 'QuipDataSource':", + ' """Return the data source instance."""', + " return self", + "", + " def get_client(self) -> QuipClient:", + ' """Return the underlying QuipClient."""', + " return self._client", + "", + ] + + # Generate all API methods + for method_name, endpoint_info in QUIP_API_ENDPOINTS.items(): + class_lines.append(self._generate_method(method_name, endpoint_info)) + class_lines.append("") + + return "\n".join(class_lines) + + def save_to_file(self, filename: Optional[str] = None) -> None: + """Generate and save the Quip datasource to a file.""" + if filename is None: + filename = "quip.py" + + script_dir = Path(__file__).parent if __file__ else Path(".") + quip_dir = script_dir.parent / "app" / "sources" / "external" / "quip" + quip_dir.mkdir(parents=True, exist_ok=True) + + full_path = quip_dir / filename + + class_code = self.generate_quip_datasource() + full_path.write_text(class_code, encoding="utf-8") + + print(f"Generated Quip data source with {len(self.generated_methods)} methods") + print(f"Saved to: {full_path}") + + # Print summary by resource + resource_categories = { + "Users": 0, + "Threads/Documents": 0, + "Messages": 0, + "Folders": 0, + } + + for method in self.generated_methods: + name = method["name"] + if "user" in name or "contact" in name: + resource_categories["Users"] += 1 + elif "thread" in name or "document" in name: + resource_categories["Threads/Documents"] += 1 + elif "message" in name: + resource_categories["Messages"] += 1 + elif "folder" in name: + resource_categories["Folders"] += 1 + + print(f"\nMethods by Resource:") + for category, count in resource_categories.items(): + if count > 0: + print(f" - {category}: {count}") + + +def main(): + """Main function for Quip data source generator.""" + import argparse + + parser = argparse.ArgumentParser( + description="Generate Quip REST API data source" + ) + parser.add_argument("--filename", "-f", help="Output filename (optional)") + + args = parser.parse_args() + + try: + generator = QuipDataSourceGenerator() + generator.save_to_file(args.filename) + return 0 + except Exception as e: + print(f"Failed to generate Quip data source: {e}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/python/code-generator/slab.py b/backend/python/code-generator/slab.py new file mode 100644 index 000000000..215aef241 --- /dev/null +++ b/backend/python/code-generator/slab.py @@ -0,0 +1,269 @@ +# ruff: noqa +""" +Slab GraphQL Data Source Generator +Generates wrapper methods for Slab GraphQL operations. +Creates a comprehensive Slab data source with query and mutation operations. + +Slab uses a GraphQL API exclusively. +API Docs: https://slab.com/api/ + +Usage: + python code-generator/slab.py + python code-generator/slab.py --filename slab.py +""" + +import sys +from pathlib import Path +from typing import Dict, List, Optional + +from app.sources.client.slab.graphql_op import SlabGraphQLOperations + + +class SlabDataSourceGenerator: + """Generate Slab data source methods from GraphQL operations.""" + + def __init__(self): + """Initialize the Slab data source generator.""" + self.generated_methods = [] + self.operations = SlabGraphQLOperations.get_all_operations() + + # Define comprehensive Slab operations + self.comprehensive_operations = self._define_comprehensive_operations() + + def _define_comprehensive_operations(self) -> Dict: + """Define the full set of Slab GraphQL operations with parameters.""" + return { + "queries": { + "organization": { + "description": "Get organization information", + "parameters": {}, + "example_usage": "await slab_datasource.organization()", + }, + "users": { + "description": "List all users in the organization", + "parameters": {}, + "example_usage": "await slab_datasource.users()", + }, + "posts": { + "description": "List posts with optional status filter", + "parameters": { + "status": { + "type": "Optional[str]", + "required": False, + "description": "Post status filter (e.g. PUBLISHED)", + }, + }, + "example_usage": 'await slab_datasource.posts(status="PUBLISHED")', + }, + "post": { + "description": "Get a single post by ID", + "parameters": { + "id": {"type": "str", "required": True, "description": "Post ID"}, + }, + "example_usage": 'await slab_datasource.post(id="post-id")', + }, + "topics": { + "description": "List all topics", + "parameters": {}, + "example_usage": "await slab_datasource.topics()", + }, + "topic": { + "description": "Get a single topic by ID with its posts", + "parameters": { + "id": {"type": "str", "required": True, "description": "Topic ID"}, + }, + "example_usage": 'await slab_datasource.topic(id="topic-id")', + }, + "searchPosts": { + "description": "Search posts by query string", + "parameters": { + "query": { + "type": "str", + "required": True, + "description": "Search query string", + }, + }, + "example_usage": 'await slab_datasource.searchPosts(query="search term")', + }, + }, + "mutations": { + "syncPost": { + "description": "Create or update a post via sync", + "parameters": { + "input": { + "type": "Dict[str, Any]", + "required": True, + "description": "Sync post input object", + }, + }, + "example_usage": 'await slab_datasource.syncPost(input={"title": "..."})', + }, + }, + } + + def _generate_method( + self, + operation_name: str, + operation_type: str, + operation_info: Dict, + ) -> str: + """Generate a single data source method.""" + params = operation_info.get("parameters", {}) + description = operation_info.get("description", "") + + # Build method signature + sig_parts = ["self"] + for param_name, param_info in params.items(): + ptype = param_info.get("type", "str") + if param_info.get("required", False): + sig_parts.append(f"{param_name}: {ptype}") + else: + sig_parts.append(f"{param_name}: {ptype} = None") + + signature = ",\n ".join(sig_parts) + + # Build variables dict + var_lines = [] + for param_name, param_info in params.items(): + if param_info.get("required", False): + var_lines.append(f' variables["{param_name}"] = {param_name}') + else: + var_lines.append(f" if {param_name} is not None:") + var_lines.append(f' variables["{param_name}"] = {param_name}') + + variables_block = "\n".join(var_lines) if var_lines else "" + + # Build docstring args + args_doc = "" + if params: + args_doc = "\n\n Args:" + for param_name, param_info in params.items(): + args_doc += f"\n {param_name}: {param_info.get('description', '')}" + + method = f""" async def {operation_name}( + {signature} + ) -> GraphQLResponse: + \"\"\"{description}{args_doc} + \"\"\" + query = SlabGraphQLOperations.get_operation_with_fragments("{operation_type}", "{operation_name}") + variables: Dict[str, Any] = {{}} +{variables_block} + + try: + response = await self._slab_client.get_client().execute( + query=query, variables=variables, operation_name="{operation_name}" + ) + return response + except Exception as e: + return GraphQLResponse(success=False, message=f"Failed to execute {operation_type} {operation_name}: {{str(e)}}") +""" + self.generated_methods.append({ + "name": operation_name, + "type": operation_type, + "description": description, + }) + return method + + def generate_slab_datasource(self) -> str: + """Generate the complete Slab datasource class.""" + lines = [ + 'from typing import Any, Dict, Optional', + '', + 'from app.sources.client.graphql.response import GraphQLResponse', + 'from app.sources.client.slab.graphql_op import SlabGraphQLOperations', + 'from app.sources.client.slab.slab import (', + ' SlabClient,', + ')', + '', + '', + 'class SlabDataSource:', + ' """', + ' Slab GraphQL API client wrapper', + ' Auto-generated wrapper for Slab GraphQL operations.', + ' This class provides unified access to all Slab GraphQL operations while', + ' maintaining proper typing and error handling.', + '', + ' Coverage:', + ' - Organization info', + ' - Users listing', + ' - Posts (list, get, search)', + ' - Topics (list, get)', + ' - Mutations (syncPost)', + ' """', + '', + ' def __init__(self, slab_client: SlabClient) -> None:', + ' """', + ' Initialize the Slab GraphQL data source.', + ' Args:', + ' slab_client (SlabClient): Slab client instance', + ' """', + ' self._slab_client = slab_client', + '', + ' # =============================================================================', + ' # QUERY OPERATIONS', + ' # =============================================================================', + '', + ] + + # Generate query methods + for op_name, op_info in self.comprehensive_operations["queries"].items(): + lines.append(self._generate_method(op_name, "query", op_info)) + + lines.append(' # =============================================================================') + lines.append(' # MUTATION OPERATIONS') + lines.append(' # =============================================================================') + lines.append('') + + # Generate mutation methods + for op_name, op_info in self.comprehensive_operations["mutations"].items(): + lines.append(self._generate_method(op_name, "mutation", op_info)) + + return "\n".join(lines) + + def save_to_file(self, filename: Optional[str] = None) -> None: + """Generate and save the Slab datasource to a file.""" + if filename is None: + filename = "slab.py" + + script_dir = Path(__file__).parent if __file__ else Path(".") + slab_dir = script_dir.parent / "app" / "sources" / "external" / "slab" + slab_dir.mkdir(parents=True, exist_ok=True) + + full_path = slab_dir / filename + + class_code = self.generate_slab_datasource() + full_path.write_text(class_code, encoding="utf-8") + + print(f"Generated Slab data source with {len(self.generated_methods)} methods") + print(f"Saved to: {full_path}") + + # Print summary + queries = sum(1 for m in self.generated_methods if m["type"] == "query") + mutations = sum(1 for m in self.generated_methods if m["type"] == "mutation") + print(f"\nMethods by type:") + print(f" - Queries: {queries}") + print(f" - Mutations: {mutations}") + + +def main(): + """Main function for Slab data source generator.""" + import argparse + + parser = argparse.ArgumentParser( + description="Generate Slab GraphQL data source" + ) + parser.add_argument("--filename", "-f", help="Output filename (optional)") + + args = parser.parse_args() + + try: + generator = SlabDataSourceGenerator() + generator.save_to_file(args.filename) + return 0 + except Exception as e: + print(f"Failed to generate Slab data source: {e}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/python/code-generator/smartsheet.py b/backend/python/code-generator/smartsheet.py new file mode 100644 index 000000000..867298828 --- /dev/null +++ b/backend/python/code-generator/smartsheet.py @@ -0,0 +1,370 @@ +#!/usr/bin/env python3 +# ruff: noqa +from __future__ import annotations + +""" +Smartsheet (smartsheet-python-sdk) -- Code Generator (strict, no `Any`, no `None` passthrough) + +Emits a `SmartsheetDataSource` with explicit, typed methods mapped to *real* smartsheet-python-sdk APIs. +- No `Any` in signatures or implementation. +- Never forwards None to the SDK (filters optionals). +- Accepts either a raw `smartsheet.Smartsheet` instance or any client exposing `.get_sdk() -> smartsheet.Smartsheet`. + +Doc alignment (examples): +- Sheets: ss.Sheets.list_sheets/get_sheet/create_sheet/update_sheet/delete_sheet. [sheets] +- Rows: ss.Sheets.add_rows/update_rows/delete_rows. [rows] +- Columns: ss.Sheets.get_columns/get_column/add_columns/update_column. [columns] +- Workspaces: ss.Workspaces.list_workspaces/get_workspace. [workspaces] +- Folders: ss.Folders.list_folders/get_folder. [folders] +- Reports: ss.Reports.list_reports/get_report. [reports] +- Users: ss.Users.get_current_user/list_users. [users] +- Search: ss.Search.search. [search] +- Home: ss.Home.list_all_contents. [home] +- Discussions: ss.Discussions.get_all_discussions. [discussions] +- Attachments: ss.Attachments.list_all_attachments. [attachments] + +References (for maintainers): +- SDK: https://github.com/smartsheet/smartsheet-python-sdk +- API: https://smartsheet.redoc.ly/ +""" + +import argparse +import textwrap +from pathlib import Path +from typing import List, Tuple + +# ----------------------------- +# Configuration knobs (CLI-set) +# ----------------------------- + +DEFAULT_RESPONSE_IMPORT = "from app.sources.client.smartsheet.smartsheet import SmartsheetResponse" +DEFAULT_CLASS_NAME = "SmartsheetDataSource" +DEFAULT_OUT = "app/sources/external/smartsheet/smartsheet.py" + + +HEADER = '''\ +# ruff: noqa +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false, reportAttributeAccessIssue=false, reportCallIssue=false +from __future__ import annotations + +import smartsheet # type: ignore[reportMissingTypeStubs] +from typing import Union, cast + +{response_import} + +class {class_name}: + """ + Strict, typed wrapper over smartsheet-python-sdk for common Smartsheet business operations. + + Accepts either a smartsheet `Smartsheet` instance *or* any object with `.get_sdk() -> smartsheet.Smartsheet`. + """ + + def __init__(self, client_or_sdk: Union[object, "smartsheet.Smartsheet"]) -> None: # type: ignore[reportUnknownMemberType] + # Support a raw SDK or a wrapper that exposes `.get_sdk()` + if hasattr(client_or_sdk, "get_sdk"): + sdk_obj = getattr(client_or_sdk, "get_sdk")() + self._sdk: smartsheet.Smartsheet = cast("smartsheet.Smartsheet", sdk_obj) # type: ignore[reportUnknownMemberType] + else: + self._sdk = cast("smartsheet.Smartsheet", client_or_sdk) # type: ignore[reportUnknownMemberType] + + # ---- helpers ---- + @staticmethod + def _params(**kwargs: object) -> dict[str, object]: + # Filter out Nones to avoid overriding SDK defaults + out: dict[str, object] = {} + for k, v in kwargs.items(): + if v is None: + continue + # Skip empty containers that Smartsheet rejects in some endpoints + if isinstance(v, (list, dict)) and len(v) == 0: + continue + out[k] = v + return out +''' + +FOOTER = """ +""" + +# Each tuple: (signature, body, short_doc) +METHODS: List[Tuple[str, str, str]] = [] + +# ---------- Users ---------- +METHODS += [ + ( + "get_current_user(self) -> SmartsheetResponse", + " result = self._sdk.Users.get_current_user()\n" + " return SmartsheetResponse(success=True, data=result)", + "Get the current authenticated user. [users]", + ), + ( + "list_users(self, *, include_all: bool = True) -> SmartsheetResponse", + " result = self._sdk.Users.list_users(include_all=include_all)\n" + " return SmartsheetResponse(success=True, data=result)", + "List all users in the organization. [users]", + ), +] + +# ---------- Sheets ---------- +METHODS += [ + ( + "list_sheets(self, *, page_size: int = 100, page: int = 1, include_all: bool = False, modified_since: Union[str, None] = None) -> SmartsheetResponse", + " params = self._params(page_size=page_size, page=page, include_all=include_all, modified_since=modified_since)\n" + " result = self._sdk.Sheets.list_sheets(**params)\n" + " return SmartsheetResponse(success=True, data=result)", + "List all sheets the user has access to. [sheets]", + ), + ( + "get_sheet(self, sheet_id: int, *, page_size: int = 100, page: int = 1) -> SmartsheetResponse", + " result = self._sdk.Sheets.get_sheet(sheet_id, page_size=page_size, page=page)\n" + " return SmartsheetResponse(success=True, data=result)", + "Get a specific sheet by ID. [sheets]", + ), + ( + "create_sheet(self, sheet_obj: object) -> SmartsheetResponse", + " result = self._sdk.Home.create_sheet(sheet_obj)\n" + " return SmartsheetResponse(success=True, data=result)", + "Create a new sheet at Home level. Pass a smartsheet.models.Sheet object. [sheets]", + ), + ( + "create_sheet_in_folder(self, folder_id: int, sheet_obj: object) -> SmartsheetResponse", + " result = self._sdk.Folders.create_sheet_in_folder(folder_id, sheet_obj)\n" + " return SmartsheetResponse(success=True, data=result)", + "Create a new sheet in a specific folder. [sheets]", + ), + ( + "create_sheet_in_workspace(self, workspace_id: int, sheet_obj: object) -> SmartsheetResponse", + " result = self._sdk.Workspaces.create_sheet_in_workspace(workspace_id, sheet_obj)\n" + " return SmartsheetResponse(success=True, data=result)", + "Create a new sheet in a specific workspace. [sheets]", + ), + ( + "update_sheet(self, sheet_id: int, sheet_obj: object) -> SmartsheetResponse", + " result = self._sdk.Sheets.update_sheet(sheet_id, sheet_obj)\n" + " return SmartsheetResponse(success=True, data=result)", + "Update a sheet (e.g. rename). Pass a smartsheet.models.Sheet object. [sheets]", + ), + ( + "delete_sheet(self, sheet_id: int) -> SmartsheetResponse", + " result = self._sdk.Sheets.delete_sheet(sheet_id)\n" + " return SmartsheetResponse(success=True, data=result)", + "Delete a sheet by ID. [sheets]", + ), +] + +# ---------- Rows ---------- +METHODS += [ + ( + "add_rows(self, sheet_id: int, row_objects: list[object]) -> SmartsheetResponse", + " result = self._sdk.Sheets.add_rows(sheet_id, row_objects)\n" + " return SmartsheetResponse(success=True, data=result)", + "Add rows to a sheet. Pass a list of smartsheet.models.Row objects. [rows]", + ), + ( + "update_rows(self, sheet_id: int, row_objects: list[object]) -> SmartsheetResponse", + " result = self._sdk.Sheets.update_rows(sheet_id, row_objects)\n" + " return SmartsheetResponse(success=True, data=result)", + "Update rows in a sheet. Pass a list of smartsheet.models.Row objects. [rows]", + ), + ( + "delete_rows(self, sheet_id: int, row_ids: list[int]) -> SmartsheetResponse", + " result = self._sdk.Sheets.delete_rows(sheet_id, row_ids)\n" + " return SmartsheetResponse(success=True, data=result)", + "Delete rows from a sheet by row IDs. [rows]", + ), +] + +# ---------- Columns ---------- +METHODS += [ + ( + "list_columns(self, sheet_id: int, *, include_all: bool = True) -> SmartsheetResponse", + " result = self._sdk.Sheets.get_columns(sheet_id, include_all=include_all)\n" + " return SmartsheetResponse(success=True, data=result)", + "List all columns in a sheet. [columns]", + ), + ( + "get_column(self, sheet_id: int, column_id: int) -> SmartsheetResponse", + " result = self._sdk.Sheets.get_column(sheet_id, column_id)\n" + " return SmartsheetResponse(success=True, data=result)", + "Get a specific column in a sheet. [columns]", + ), + ( + "add_columns(self, sheet_id: int, column_objects: list[object]) -> SmartsheetResponse", + " result = self._sdk.Sheets.add_columns(sheet_id, column_objects)\n" + " return SmartsheetResponse(success=True, data=result)", + "Add columns to a sheet. Pass a list of smartsheet.models.Column objects. [columns]", + ), + ( + "update_column(self, sheet_id: int, column_id: int, column_obj: object) -> SmartsheetResponse", + " result = self._sdk.Sheets.update_column(sheet_id, column_id, column_obj)\n" + " return SmartsheetResponse(success=True, data=result)", + "Update a column in a sheet. Pass a smartsheet.models.Column object. [columns]", + ), +] + +# ---------- Workspaces ---------- +METHODS += [ + ( + "list_workspaces(self) -> SmartsheetResponse", + " result = self._sdk.Workspaces.list_workspaces()\n" + " return SmartsheetResponse(success=True, data=result)", + "List all workspaces. [workspaces]", + ), + ( + "get_workspace(self, workspace_id: int) -> SmartsheetResponse", + " result = self._sdk.Workspaces.get_workspace(workspace_id)\n" + " return SmartsheetResponse(success=True, data=result)", + "Get a specific workspace by ID. [workspaces]", + ), +] + +# ---------- Folders ---------- +METHODS += [ + ( + "list_folders(self, *, include_all: bool = True) -> SmartsheetResponse", + " result = self._sdk.Home.list_folders(include_all=include_all)\n" + " return SmartsheetResponse(success=True, data=result)", + "List all top-level folders in the user's Home. [folders]", + ), + ( + "get_folder(self, folder_id: int) -> SmartsheetResponse", + " result = self._sdk.Folders.get_folder(folder_id)\n" + " return SmartsheetResponse(success=True, data=result)", + "Get a specific folder by ID. [folders]", + ), + ( + "list_workspace_folders(self, workspace_id: int) -> SmartsheetResponse", + " result = self._sdk.Workspaces.list_folders(workspace_id)\n" + " return SmartsheetResponse(success=True, data=result)", + "List all folders in a workspace. [folders]", + ), +] + +# ---------- Reports ---------- +METHODS += [ + ( + "list_reports(self, *, page_size: int = 100, page: int = 1, modified_since: Union[str, None] = None) -> SmartsheetResponse", + " params = self._params(page_size=page_size, page=page, modified_since=modified_since)\n" + " result = self._sdk.Reports.list_reports(**params)\n" + " return SmartsheetResponse(success=True, data=result)", + "List all reports the user has access to. [reports]", + ), + ( + "get_report(self, report_id: int, *, page_size: int = 100, page: int = 1) -> SmartsheetResponse", + " result = self._sdk.Reports.get_report(report_id, page_size=page_size, page=page)\n" + " return SmartsheetResponse(success=True, data=result)", + "Get a specific report by ID. [reports]", + ), +] + +# ---------- Search ---------- +METHODS += [ + ( + "search(self, query: str) -> SmartsheetResponse", + " result = self._sdk.Search.search(query)\n" + " return SmartsheetResponse(success=True, data=result)", + "Search for sheets, reports, rows, etc. [search]", + ), + ( + "search_sheet(self, sheet_id: int, query: str) -> SmartsheetResponse", + " result = self._sdk.Search.search_sheet(sheet_id, query)\n" + " return SmartsheetResponse(success=True, data=result)", + "Search within a specific sheet. [search]", + ), +] + +# ---------- Home ---------- +METHODS += [ + ( + "get_home(self) -> SmartsheetResponse", + " result = self._sdk.Home.list_all_contents()\n" + " return SmartsheetResponse(success=True, data=result)", + "Get the user's Home (top-level sheets, folders, workspaces, etc.). [home]", + ), +] + +# ---------- Discussions ---------- +METHODS += [ + ( + "list_sheet_discussions(self, sheet_id: int, *, include_all: bool = True) -> SmartsheetResponse", + " result = self._sdk.Discussions.get_all_discussions(sheet_id, include_all=include_all)\n" + " return SmartsheetResponse(success=True, data=result)", + "List all discussions on a sheet. [discussions]", + ), +] + +# ---------- Attachments ---------- +METHODS += [ + ( + "list_sheet_attachments(self, sheet_id: int, *, include_all: bool = True) -> SmartsheetResponse", + " result = self._sdk.Attachments.list_all_attachments(sheet_id, include_all=include_all)\n" + " return SmartsheetResponse(success=True, data=result)", + "List all attachments on a sheet. [attachments]", + ), +] + + +# ------------------------- +# Code emission utilities +# ------------------------- + + +def _emit_method(sig: str, body: str, doc: str) -> str: + normalized_body = textwrap.indent(textwrap.dedent(body), " ") + return f' def {sig}:\n """{doc}"""\n{normalized_body}\n' + + +def build_class( + response_import: str = DEFAULT_RESPONSE_IMPORT, class_name: str = DEFAULT_CLASS_NAME +) -> str: + parts: List[str] = [] + header = HEADER.replace("{response_import}", response_import).replace( + "{class_name}", class_name + ) + parts.append(header) + for sig, body, doc in METHODS: + parts.append(_emit_method(sig, body, doc)) + parts.append(FOOTER) + return "".join(parts) + + +def write_output(path: str, code: str) -> None: + out_path = Path(path) + out_path.parent.mkdir(parents=True, exist_ok=True) + with open(out_path, "w", encoding="utf-8") as f: + f.write(code) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Generate SmartsheetDataSource (smartsheet-python-sdk)." + ) + parser.add_argument( + "--out", default=DEFAULT_OUT, help="Output path for the generated data source." + ) + parser.add_argument( + "--response-import", + default=DEFAULT_RESPONSE_IMPORT, + help="Import line to bring in SmartsheetResponse.", + ) + parser.add_argument( + "--class-name", + default=DEFAULT_CLASS_NAME, + help="Name of the generated datasource class.", + ) + parser.add_argument( + "--print", + dest="do_print", + action="store_true", + help="Also print generated code to stdout.", + ) + args = parser.parse_args() + + code = build_class(response_import=args.response_import, class_name=args.class_name) + write_output(args.out, code) + print(f"Generated SmartsheetDataSource with {len(METHODS)} methods -> {args.out}") + if args.do_print: + print(code) + + +if __name__ == "__main__": + main() diff --git a/backend/python/code-generator/splunk.py b/backend/python/code-generator/splunk.py new file mode 100644 index 000000000..9505feedd --- /dev/null +++ b/backend/python/code-generator/splunk.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +# ruff: noqa +from __future__ import annotations + +""" +Splunk (splunk-sdk) -- Code Generator (strict, no `Any`, no `None` passthrough) + +Emits a `SplunkDataSource` with explicit, typed methods mapped to *real* splunk-sdk APIs. +- No `Any` in signatures or implementation. +- Never forwards None to the SDK (filters optionals). +- Accepts either a raw `splunklib.client.Service` instance or any client exposing `.get_sdk() -> Service`. + +SDK references: +- Search: service.jobs.create(query) +- Saved searches: service.saved_searches +- Indexes: service.indexes +- Apps: service.apps +- Users: service.users +- Jobs: service.jobs +- Inputs: service.inputs +- Server info: service.info +""" + +import argparse +import textwrap +from typing import Dict, List, Optional, Tuple + +# ----------------------------- +# Configuration knobs (CLI-set) +# ----------------------------- + +DEFAULT_RESPONSE_IMPORT = "from app.sources.client.splunk.splunk import SplunkResponse" +DEFAULT_CLASS_NAME = "SplunkDataSource" +DEFAULT_OUT = "splunk_data_source.py" + + +HEADER = '''\ +# ruff: noqa +from __future__ import annotations + +import splunklib.client as splunk_client # type: ignore[import-untyped] +import splunklib.results as splunk_results # type: ignore[import-untyped] +from typing import Dict, List, Optional, Union, cast + +{response_import} + +class {class_name}: + """ + Strict, typed wrapper over splunk-sdk for common Splunk operations. + + Accepts either a splunklib `Service` instance *or* any object with `.get_sdk() -> Service`. + """ + + def __init__(self, client_or_sdk: Union[splunk_client.Service, object]) -> None: + super().__init__() + # Support a raw SDK or a wrapper that exposes `.get_sdk()` + if hasattr(client_or_sdk, "get_sdk"): + sdk_obj = getattr(client_or_sdk, "get_sdk")() + self._sdk: splunk_client.Service = cast(splunk_client.Service, sdk_obj) + else: + self._sdk = cast(splunk_client.Service, client_or_sdk) + + # ---- helpers ---- + @staticmethod + def _params(**kwargs: object) -> Dict[str, object]: + # Filter out Nones to avoid overriding SDK defaults + out: Dict[str, object] = {} + for k, v in kwargs.items(): + if v is None: + continue + if isinstance(v, (list, dict)) and len(v) == 0: + continue + out[k] = v + return out +''' + +FOOTER = """ +""" + +# Each tuple: (signature, body, short_doc) +METHODS: List[Tuple[str, str, str]] = [] + +# ---------- Server Info ---------- +METHODS += [ + ( + "get_server_info(self) -> SplunkResponse", + " info = self._sdk.info\n" + " return SplunkResponse(success=True, data=info)", + "Get Splunk server information.", + ), +] + +# ---------- Search ---------- +METHODS += [ + ( + "search(self, query: str, earliest_time: Optional[str] = None, latest_time: Optional[str] = None, max_count: Optional[int] = None, exec_mode: Optional[str] = None) -> SplunkResponse", + " params = self._params(earliest_time=earliest_time, latest_time=latest_time, max_count=max_count, exec_mode=exec_mode)\n" + " job = self._sdk.jobs.create(query, **params)\n" + " while not job.is_done():\n" + " import time\n" + " time.sleep(0.5)\n" + " rr = splunk_results.JSONResultsReader(job.results(output_mode='json'))\n" + " results = [result for result in rr if isinstance(result, dict)]\n" + " return SplunkResponse(success=True, data=results)", + "Run a search query and return results.", + ), +] + +# ---------- Saved Searches ---------- +METHODS += [ + ( + "list_saved_searches(self) -> SplunkResponse", + " items = list(self._sdk.saved_searches)\n" + " return SplunkResponse(success=True, data=items)", + "List all saved searches.", + ), + ( + "get_saved_search(self, name: str) -> SplunkResponse", + " ss = self._sdk.saved_searches[name]\n" + " return SplunkResponse(success=True, data=ss)", + "Get a saved search by name.", + ), +] + +# ---------- Indexes ---------- +METHODS += [ + ( + "list_indexes(self) -> SplunkResponse", + " items = list(self._sdk.indexes)\n" + " return SplunkResponse(success=True, data=items)", + "List all indexes.", + ), + ( + "get_index(self, name: str) -> SplunkResponse", + " idx = self._sdk.indexes[name]\n" + " return SplunkResponse(success=True, data=idx)", + "Get an index by name.", + ), +] + +# ---------- Apps ---------- +METHODS += [ + ( + "list_apps(self) -> SplunkResponse", + " items = list(self._sdk.apps)\n" + " return SplunkResponse(success=True, data=items)", + "List all installed apps.", + ), + ( + "get_app(self, name: str) -> SplunkResponse", + " app = self._sdk.apps[name]\n" + " return SplunkResponse(success=True, data=app)", + "Get an app by name.", + ), +] + +# ---------- Users ---------- +METHODS += [ + ( + "list_users(self) -> SplunkResponse", + " items = list(self._sdk.users)\n" + " return SplunkResponse(success=True, data=items)", + "List all users.", + ), +] + +# ---------- Jobs ---------- +METHODS += [ + ( + "list_jobs(self) -> SplunkResponse", + " items = list(self._sdk.jobs)\n" + " return SplunkResponse(success=True, data=items)", + "List all search jobs.", + ), + ( + "get_job(self, sid: str) -> SplunkResponse", + " job = self._sdk.jobs[sid]\n" + " return SplunkResponse(success=True, data=job)", + "Get a search job by SID.", + ), +] + +# ---------- Inputs ---------- +METHODS += [ + ( + "list_inputs(self) -> SplunkResponse", + " items = list(self._sdk.inputs)\n" + " return SplunkResponse(success=True, data=items)", + "List all data inputs.", + ), +] + +# ------------------------- +# Code emission utilities +# ------------------------- + + +def _emit_method(sig: str, body: str, doc: str) -> str: + normalized_body = textwrap.indent(textwrap.dedent(body), " ") + return f' def {sig}:\n """{doc}"""\n{normalized_body}\n' + + +def build_class( + response_import: str = DEFAULT_RESPONSE_IMPORT, class_name: str = DEFAULT_CLASS_NAME +) -> str: + parts: List[str] = [] + header = HEADER.replace("{response_import}", response_import).replace( + "{class_name}", class_name + ) + parts.append(header) + for sig, body, doc in METHODS: + parts.append(_emit_method(sig, body, doc)) + parts.append(FOOTER) + return "".join(parts) + + +def write_output(path: str, code: str) -> None: + with open(path, "w", encoding="utf-8") as f: + f.write(code) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Generate SplunkDataSource (splunk-sdk)." + ) + parser.add_argument( + "--out", default=DEFAULT_OUT, help="Output path for the generated data source." + ) + parser.add_argument( + "--response-import", + default=DEFAULT_RESPONSE_IMPORT, + help="Import line to bring in SplunkResponse.", + ) + parser.add_argument( + "--class-name", + default=DEFAULT_CLASS_NAME, + help="Name of the generated datasource class.", + ) + parser.add_argument( + "--print", + dest="do_print", + action="store_true", + help="Also print generated code to stdout.", + ) + args = parser.parse_args() + + code = build_class(response_import=args.response_import, class_name=args.class_name) + write_output(args.out, code) + if args.do_print: + print(code) + + +if __name__ == "__main__": + main() diff --git a/backend/python/code-generator/tableau.py b/backend/python/code-generator/tableau.py new file mode 100644 index 000000000..84516c61a --- /dev/null +++ b/backend/python/code-generator/tableau.py @@ -0,0 +1,325 @@ +#!/usr/bin/env python3 +# ruff: noqa +from __future__ import annotations + +""" +Tableau (tableauserverclient) -- Code Generator + +Emits a `TableauDataSource` with typed methods mapped to *real* TSC SDK APIs. +Follows the GitLab code-generator pattern: METHODS tuple, HEADER/FOOTER, _emit_method. + +SDK Reference: https://tableau.github.io/server-client-python/docs/ + +Note: Most `.get()` calls return `(items_list, PaginationItem)` tuples. +Single-item fetches like `.get_by_id()` return the item directly. +""" + +import argparse +import textwrap +from typing import List, Tuple + +# ----------------------------- +# Configuration knobs (CLI-set) +# ----------------------------- + +DEFAULT_RESPONSE_IMPORT = ( + "from app.sources.client.tableau.tableau import TableauResponse" +) +DEFAULT_CLASS_NAME = "TableauDataSource" +DEFAULT_OUT = "app/sources/external/tableau/tableau.py" + + +HEADER = '''\ +# ruff: noqa +# pyright: reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false, reportMissingImports=false, reportUnusedVariable=false +from __future__ import annotations + +import tableauserverclient as TSC +from typing import Any, Dict, List, Union, cast + +{response_import} + + +class {class_name}: + """ + Typed wrapper over tableauserverclient for common Tableau business operations. + + Accepts either a TSC.Server instance or any client exposing `.get_sdk() -> TSC.Server`. + + SDK Reference: https://tableau.github.io/server-client-python/docs/ + """ + + def __init__(self, client_or_sdk: Union[TSC.Server, object]) -> None: + if hasattr(client_or_sdk, "get_sdk"): + sdk_obj = getattr(client_or_sdk, "get_sdk")() + self._sdk: TSC.Server = cast(TSC.Server, sdk_obj) + else: + self._sdk = cast(TSC.Server, client_or_sdk) + + @staticmethod + def _to_dict(item: object) -> Dict[str, Any]: + """Convert a TSC resource item to a dictionary representation.""" + if hasattr(item, "__dict__"): + return {k: v for k, v in item.__dict__.items() if not k.startswith("_")} + return {"value": str(item)} + + @staticmethod + def _to_dict_list(items: object) -> List[Dict[str, Any]]: + """Convert a list of TSC resource items to a list of dictionaries.""" + result: List[Dict[str, Any]] = [] + if hasattr(items, "__iter__"): + for item in items: # type: ignore[union-attr] + if hasattr(item, "__dict__"): + result.append({k: v for k, v in item.__dict__.items() if not k.startswith("_")}) + else: + result.append({"value": str(item)}) + return result +''' + +FOOTER = """ +""" + +# Each tuple: (signature, body, short_doc) +METHODS: List[Tuple[str, str, str]] = [] + +# ---------- Workbooks ---------- +METHODS += [ + ( + "list_workbooks(self) -> TableauResponse", + " items, pagination = self._sdk.workbooks.get()\n" + " return TableauResponse(success=True, data=self._to_dict_list(items))", + "List all workbooks on the site. [workbooks]", + ), + ( + "get_workbook(self, workbook_id: str) -> TableauResponse", + " item = self._sdk.workbooks.get_by_id(workbook_id)\n" + " return TableauResponse(success=True, data=self._to_dict(item))", + "Get a single workbook by ID. [workbooks]", + ), + ( + "populate_workbook_views(self, workbook_id: str) -> TableauResponse", + " workbook = self._sdk.workbooks.get_by_id(workbook_id)\n" + " self._sdk.workbooks.populate_views(workbook)\n" + " return TableauResponse(success=True, data=self._to_dict_list(workbook.views))", + "Populate and return the views for a workbook. [workbooks]", + ), + ( + "populate_workbook_connections(self, workbook_id: str) -> TableauResponse", + " workbook = self._sdk.workbooks.get_by_id(workbook_id)\n" + " self._sdk.workbooks.populate_connections(workbook)\n" + " return TableauResponse(success=True, data=self._to_dict_list(workbook.connections))", + "Populate and return the connections for a workbook. [workbooks]", + ), + ( + "delete_workbook(self, workbook_id: str) -> TableauResponse", + " self._sdk.workbooks.delete(workbook_id)\n" + " return TableauResponse(success=True, data=True)", + "Delete a workbook by ID. [workbooks]", + ), +] + +# ---------- Views ---------- +METHODS += [ + ( + "list_views(self) -> TableauResponse", + " items, pagination = self._sdk.views.get()\n" + " return TableauResponse(success=True, data=self._to_dict_list(items))", + "List all views on the site. [views]", + ), + ( + "get_view(self, view_id: str) -> TableauResponse", + " item = self._sdk.views.get_by_id(view_id)\n" + " return TableauResponse(success=True, data=self._to_dict(item))", + "Get a single view by ID. [views]", + ), +] + +# ---------- Data Sources ---------- +METHODS += [ + ( + "list_datasources(self) -> TableauResponse", + " items, pagination = self._sdk.datasources.get()\n" + " return TableauResponse(success=True, data=self._to_dict_list(items))", + "List all published data sources on the site. [datasources]", + ), + ( + "get_datasource(self, datasource_id: str) -> TableauResponse", + " item = self._sdk.datasources.get_by_id(datasource_id)\n" + " return TableauResponse(success=True, data=self._to_dict(item))", + "Get a single data source by ID. [datasources]", + ), + ( + "delete_datasource(self, datasource_id: str) -> TableauResponse", + " self._sdk.datasources.delete(datasource_id)\n" + " return TableauResponse(success=True, data=True)", + "Delete a data source by ID. [datasources]", + ), +] + +# ---------- Projects ---------- +METHODS += [ + ( + "list_projects(self) -> TableauResponse", + " items, pagination = self._sdk.projects.get()\n" + " return TableauResponse(success=True, data=self._to_dict_list(items))", + "List all projects on the site. [projects]", + ), +] + +# ---------- Users ---------- +METHODS += [ + ( + "list_users(self) -> TableauResponse", + " items, pagination = self._sdk.users.get()\n" + " return TableauResponse(success=True, data=self._to_dict_list(items))", + "List all users on the site. [users]", + ), + ( + "get_user(self, user_id: str) -> TableauResponse", + " item = self._sdk.users.get_by_id(user_id)\n" + " return TableauResponse(success=True, data=self._to_dict(item))", + "Get a single user by ID. [users]", + ), +] + +# ---------- Groups ---------- +METHODS += [ + ( + "list_groups(self) -> TableauResponse", + " items, pagination = self._sdk.groups.get()\n" + " return TableauResponse(success=True, data=self._to_dict_list(items))", + "List all groups on the site. [groups]", + ), + ( + "get_group(self, group_id: str) -> TableauResponse", + " item = self._sdk.groups.get_by_id(group_id)\n" + " return TableauResponse(success=True, data=self._to_dict(item))", + "Get a single group by ID. [groups]", + ), +] + +# ---------- Schedules ---------- +METHODS += [ + ( + "list_schedules(self) -> TableauResponse", + " items, pagination = self._sdk.schedules.get()\n" + " return TableauResponse(success=True, data=self._to_dict_list(items))", + "List all schedules on the server. [schedules]", + ), +] + +# ---------- Jobs ---------- +METHODS += [ + ( + "list_jobs(self) -> TableauResponse", + " items, pagination = self._sdk.jobs.get()\n" + " return TableauResponse(success=True, data=self._to_dict_list(items))", + "List all jobs on the site. [jobs]", + ), + ( + "get_job(self, job_id: str) -> TableauResponse", + " item = self._sdk.jobs.get_by_id(job_id)\n" + " return TableauResponse(success=True, data=self._to_dict(item))", + "Get a single job by ID. [jobs]", + ), +] + +# ---------- Flows ---------- +METHODS += [ + ( + "list_flows(self) -> TableauResponse", + " items, pagination = self._sdk.flows.get()\n" + " return TableauResponse(success=True, data=self._to_dict_list(items))", + "List all flows on the site. [flows]", + ), + ( + "get_flow(self, flow_id: str) -> TableauResponse", + " item = self._sdk.flows.get_by_id(flow_id)\n" + " return TableauResponse(success=True, data=self._to_dict(item))", + "Get a single flow by ID. [flows]", + ), +] + +# ---------- Auth ---------- +METHODS += [ + ( + "sign_out(self) -> TableauResponse", + " self._sdk.auth.sign_out()\n" + " return TableauResponse(success=True, data=True, message='Signed out successfully')", + "Sign out and invalidate the current auth session. [auth]", + ), +] + + +# ------------------------- +# Code emission utilities +# ------------------------- + + +def _emit_method(sig: str, body: str, doc: str) -> str: + normalized_body = textwrap.indent(textwrap.dedent(body), " ") + return f' def {sig}:\n """{doc}"""\n{normalized_body}\n' + + +def build_class( + response_import: str = DEFAULT_RESPONSE_IMPORT, + class_name: str = DEFAULT_CLASS_NAME, +) -> str: + parts: List[str] = [] + header = HEADER.replace("{response_import}", response_import).replace( + "{class_name}", class_name + ) + parts.append(header) + for sig, body, doc in METHODS: + parts.append(_emit_method(sig, body, doc)) + parts.append(FOOTER) + return "".join(parts) + + +def write_output(path: str, code: str) -> None: + from pathlib import Path + + out_path = Path(path) + out_path.parent.mkdir(parents=True, exist_ok=True) + with open(out_path, "w", encoding="utf-8") as f: + f.write(code) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Generate TableauDataSource (tableauserverclient)." + ) + parser.add_argument( + "--out", + default=DEFAULT_OUT, + help="Output path for the generated data source.", + ) + parser.add_argument( + "--response-import", + default=DEFAULT_RESPONSE_IMPORT, + help="Import line to bring in TableauResponse.", + ) + parser.add_argument( + "--class-name", + default=DEFAULT_CLASS_NAME, + help="Name of the generated datasource class.", + ) + parser.add_argument( + "--print", + dest="do_print", + action="store_true", + help="Also print generated code to stdout.", + ) + args = parser.parse_args() + + code = build_class( + response_import=args.response_import, class_name=args.class_name + ) + write_output(args.out, code) + print(f"Generated {DEFAULT_CLASS_NAME} with {len(METHODS)} methods -> {args.out}") + if args.do_print: + print(code) + + +if __name__ == "__main__": + main() diff --git a/backend/python/code-generator/wordpress.py b/backend/python/code-generator/wordpress.py new file mode 100644 index 000000000..7dcc214cc --- /dev/null +++ b/backend/python/code-generator/wordpress.py @@ -0,0 +1,985 @@ +# ruff: noqa +""" +WordPress REST API Code Generator + +Generates WordPressDataSource class covering WordPress REST API v2: +- Posts CRUD +- Pages CRUD +- Categories and Tags +- Comments +- Users +- Media +- Post Types, Statuses, Taxonomies +- Search + +The generated DataSource accepts a WordPressClient and uses the client's +configured base URL to construct API endpoints. Methods are generated +with explicit parameter signatures and no **kwargs usage. +""" + +import sys +from pathlib import Path +from typing import Dict, List, Optional + +# ================================================================================ +# WordPress REST API Endpoints +# +# Each endpoint defines: +# method: HTTP verb +# path: URL path (appended to base_url which already includes /wp/v2 or /wp-json/wp/v2) +# description: Human-readable description +# parameters: Dict of param_name -> {type, location (path/query/body), description} +# required: List of required parameter names +# ================================================================================ + +WORDPRESS_API_ENDPOINTS = { + # ================================================================================ + # POSTS + # ================================================================================ + "list_posts": { + "method": "GET", + "path": "/posts", + "description": "List all posts", + "parameters": { + "page": {"type": "Optional[int]", "location": "query", "description": "Current page of the collection (default 1)"}, + "per_page": {"type": "Optional[int]", "location": "query", "description": "Maximum number of items per page (default 10, max 100)"}, + "search": {"type": "Optional[str]", "location": "query", "description": "Limit results to those matching a search string"}, + "after": {"type": "Optional[str]", "location": "query", "description": "Limit to posts published after a given ISO8601 date"}, + "before": {"type": "Optional[str]", "location": "query", "description": "Limit to posts published before a given ISO8601 date"}, + "author": {"type": "Optional[str]", "location": "query", "description": "Limit to posts by one or more author IDs (comma-separated)"}, + "categories": {"type": "Optional[str]", "location": "query", "description": "Limit to posts in specific category IDs (comma-separated)"}, + "tags": {"type": "Optional[str]", "location": "query", "description": "Limit to posts with specific tag IDs (comma-separated)"}, + "status": {"type": "Optional[str]", "location": "query", "description": "Limit to posts with a specific status (publish, draft, pending, etc.)"}, + "orderby": {"type": "Optional[str]", "location": "query", "description": "Sort by attribute (date, relevance, id, include, title, slug)"}, + "order": {"type": "Optional[str]", "location": "query", "description": "Sort order (asc or desc)"}, + }, + "required": [], + }, + "get_post": { + "method": "GET", + "path": "/posts/{post_id}", + "description": "Get a specific post by ID", + "parameters": { + "post_id": {"type": "str", "location": "path", "description": "The post ID"}, + }, + "required": ["post_id"], + }, + "create_post": { + "method": "POST", + "path": "/posts", + "description": "Create a new post", + "parameters": { + "title": {"type": "str", "location": "body", "description": "The title for the post"}, + "content": {"type": "Optional[str]", "location": "body", "description": "The content for the post"}, + "status": {"type": "Optional[str]", "location": "body", "description": "Post status (publish, draft, pending, private)"}, + "excerpt": {"type": "Optional[str]", "location": "body", "description": "The excerpt for the post"}, + "author": {"type": "Optional[int]", "location": "body", "description": "The ID of the author"}, + "categories": {"type": "Optional[list[int]]", "location": "body", "description": "Category IDs for the post"}, + "tags": {"type": "Optional[list[int]]", "location": "body", "description": "Tag IDs for the post"}, + "format": {"type": "Optional[str]", "location": "body", "description": "Post format (standard, aside, chat, gallery, link, image, quote, status, video, audio)"}, + "slug": {"type": "Optional[str]", "location": "body", "description": "Alphanumeric identifier for the post"}, + "comment_status": {"type": "Optional[str]", "location": "body", "description": "Whether comments are open (open or closed)"}, + "ping_status": {"type": "Optional[str]", "location": "body", "description": "Whether pings are accepted (open or closed)"}, + "featured_media": {"type": "Optional[int]", "location": "body", "description": "The ID of the featured media"}, + }, + "required": ["title"], + }, + "update_post": { + "method": "PUT", + "path": "/posts/{post_id}", + "description": "Update an existing post", + "parameters": { + "post_id": {"type": "str", "location": "path", "description": "The post ID"}, + "title": {"type": "Optional[str]", "location": "body", "description": "The title for the post"}, + "content": {"type": "Optional[str]", "location": "body", "description": "The content for the post"}, + "status": {"type": "Optional[str]", "location": "body", "description": "Post status (publish, draft, pending, private)"}, + "excerpt": {"type": "Optional[str]", "location": "body", "description": "The excerpt for the post"}, + "author": {"type": "Optional[int]", "location": "body", "description": "The ID of the author"}, + "categories": {"type": "Optional[list[int]]", "location": "body", "description": "Category IDs for the post"}, + "tags": {"type": "Optional[list[int]]", "location": "body", "description": "Tag IDs for the post"}, + "slug": {"type": "Optional[str]", "location": "body", "description": "Alphanumeric identifier for the post"}, + "comment_status": {"type": "Optional[str]", "location": "body", "description": "Whether comments are open (open or closed)"}, + "featured_media": {"type": "Optional[int]", "location": "body", "description": "The ID of the featured media"}, + }, + "required": ["post_id"], + }, + "delete_post": { + "method": "DELETE", + "path": "/posts/{post_id}", + "description": "Delete a post", + "parameters": { + "post_id": {"type": "str", "location": "path", "description": "The post ID"}, + }, + "required": ["post_id"], + }, + + # ================================================================================ + # PAGES + # ================================================================================ + "list_pages": { + "method": "GET", + "path": "/pages", + "description": "List all pages", + "parameters": { + "page": {"type": "Optional[int]", "location": "query", "description": "Current page of the collection (default 1)"}, + "per_page": {"type": "Optional[int]", "location": "query", "description": "Maximum number of items per page (default 10, max 100)"}, + "search": {"type": "Optional[str]", "location": "query", "description": "Limit results to those matching a search string"}, + "after": {"type": "Optional[str]", "location": "query", "description": "Limit to pages published after a given ISO8601 date"}, + "before": {"type": "Optional[str]", "location": "query", "description": "Limit to pages published before a given ISO8601 date"}, + "author": {"type": "Optional[str]", "location": "query", "description": "Limit to pages by one or more author IDs (comma-separated)"}, + "status": {"type": "Optional[str]", "location": "query", "description": "Limit to pages with a specific status"}, + "orderby": {"type": "Optional[str]", "location": "query", "description": "Sort by attribute (date, relevance, id, include, title, slug, menu_order)"}, + "order": {"type": "Optional[str]", "location": "query", "description": "Sort order (asc or desc)"}, + }, + "required": [], + }, + "get_page": { + "method": "GET", + "path": "/pages/{page_id}", + "description": "Get a specific page by ID", + "parameters": { + "page_id": {"type": "str", "location": "path", "description": "The page ID"}, + }, + "required": ["page_id"], + }, + "create_page": { + "method": "POST", + "path": "/pages", + "description": "Create a new page", + "parameters": { + "title": {"type": "str", "location": "body", "description": "The title for the page"}, + "content": {"type": "Optional[str]", "location": "body", "description": "The content for the page"}, + "status": {"type": "Optional[str]", "location": "body", "description": "Page status (publish, draft, pending, private)"}, + "excerpt": {"type": "Optional[str]", "location": "body", "description": "The excerpt for the page"}, + "author": {"type": "Optional[int]", "location": "body", "description": "The ID of the author"}, + "parent": {"type": "Optional[int]", "location": "body", "description": "Parent page ID"}, + "menu_order": {"type": "Optional[int]", "location": "body", "description": "Page order in menu"}, + "slug": {"type": "Optional[str]", "location": "body", "description": "Alphanumeric identifier for the page"}, + "comment_status": {"type": "Optional[str]", "location": "body", "description": "Whether comments are open (open or closed)"}, + "featured_media": {"type": "Optional[int]", "location": "body", "description": "The ID of the featured media"}, + }, + "required": ["title"], + }, + "update_page": { + "method": "PUT", + "path": "/pages/{page_id}", + "description": "Update an existing page", + "parameters": { + "page_id": {"type": "str", "location": "path", "description": "The page ID"}, + "title": {"type": "Optional[str]", "location": "body", "description": "The title for the page"}, + "content": {"type": "Optional[str]", "location": "body", "description": "The content for the page"}, + "status": {"type": "Optional[str]", "location": "body", "description": "Page status (publish, draft, pending, private)"}, + "excerpt": {"type": "Optional[str]", "location": "body", "description": "The excerpt for the page"}, + "author": {"type": "Optional[int]", "location": "body", "description": "The ID of the author"}, + "parent": {"type": "Optional[int]", "location": "body", "description": "Parent page ID"}, + "menu_order": {"type": "Optional[int]", "location": "body", "description": "Page order in menu"}, + "slug": {"type": "Optional[str]", "location": "body", "description": "Alphanumeric identifier for the page"}, + "featured_media": {"type": "Optional[int]", "location": "body", "description": "The ID of the featured media"}, + }, + "required": ["page_id"], + }, + "delete_page": { + "method": "DELETE", + "path": "/pages/{page_id}", + "description": "Delete a page", + "parameters": { + "page_id": {"type": "str", "location": "path", "description": "The page ID"}, + }, + "required": ["page_id"], + }, + + # ================================================================================ + # CATEGORIES + # ================================================================================ + "list_categories": { + "method": "GET", + "path": "/categories", + "description": "List all categories", + "parameters": { + "page": {"type": "Optional[int]", "location": "query", "description": "Current page of the collection (default 1)"}, + "per_page": {"type": "Optional[int]", "location": "query", "description": "Maximum number of items per page (default 10, max 100)"}, + "search": {"type": "Optional[str]", "location": "query", "description": "Limit results to those matching a search string"}, + "parent": {"type": "Optional[int]", "location": "query", "description": "Limit to categories with a specific parent ID"}, + "orderby": {"type": "Optional[str]", "location": "query", "description": "Sort by attribute (id, include, name, slug, count, description)"}, + "order": {"type": "Optional[str]", "location": "query", "description": "Sort order (asc or desc)"}, + }, + "required": [], + }, + "get_category": { + "method": "GET", + "path": "/categories/{category_id}", + "description": "Get a specific category by ID", + "parameters": { + "category_id": {"type": "str", "location": "path", "description": "The category ID"}, + }, + "required": ["category_id"], + }, + "create_category": { + "method": "POST", + "path": "/categories", + "description": "Create a new category", + "parameters": { + "name": {"type": "str", "location": "body", "description": "The name of the category"}, + "description": {"type": "Optional[str]", "location": "body", "description": "Category description"}, + "slug": {"type": "Optional[str]", "location": "body", "description": "Alphanumeric identifier for the category"}, + "parent": {"type": "Optional[int]", "location": "body", "description": "Parent category ID"}, + }, + "required": ["name"], + }, + "update_category": { + "method": "PUT", + "path": "/categories/{category_id}", + "description": "Update a category", + "parameters": { + "category_id": {"type": "str", "location": "path", "description": "The category ID"}, + "name": {"type": "Optional[str]", "location": "body", "description": "The name of the category"}, + "description": {"type": "Optional[str]", "location": "body", "description": "Category description"}, + "slug": {"type": "Optional[str]", "location": "body", "description": "Alphanumeric identifier for the category"}, + "parent": {"type": "Optional[int]", "location": "body", "description": "Parent category ID"}, + }, + "required": ["category_id"], + }, + "delete_category": { + "method": "DELETE", + "path": "/categories/{category_id}", + "description": "Delete a category", + "parameters": { + "category_id": {"type": "str", "location": "path", "description": "The category ID"}, + }, + "required": ["category_id"], + }, + + # ================================================================================ + # TAGS + # ================================================================================ + "list_tags": { + "method": "GET", + "path": "/tags", + "description": "List all tags", + "parameters": { + "page": {"type": "Optional[int]", "location": "query", "description": "Current page of the collection (default 1)"}, + "per_page": {"type": "Optional[int]", "location": "query", "description": "Maximum number of items per page (default 10, max 100)"}, + "search": {"type": "Optional[str]", "location": "query", "description": "Limit results to those matching a search string"}, + "orderby": {"type": "Optional[str]", "location": "query", "description": "Sort by attribute (id, include, name, slug, count, description)"}, + "order": {"type": "Optional[str]", "location": "query", "description": "Sort order (asc or desc)"}, + }, + "required": [], + }, + "get_tag": { + "method": "GET", + "path": "/tags/{tag_id}", + "description": "Get a specific tag by ID", + "parameters": { + "tag_id": {"type": "str", "location": "path", "description": "The tag ID"}, + }, + "required": ["tag_id"], + }, + "create_tag": { + "method": "POST", + "path": "/tags", + "description": "Create a new tag", + "parameters": { + "name": {"type": "str", "location": "body", "description": "The name of the tag"}, + "description": {"type": "Optional[str]", "location": "body", "description": "Tag description"}, + "slug": {"type": "Optional[str]", "location": "body", "description": "Alphanumeric identifier for the tag"}, + }, + "required": ["name"], + }, + "update_tag": { + "method": "PUT", + "path": "/tags/{tag_id}", + "description": "Update a tag", + "parameters": { + "tag_id": {"type": "str", "location": "path", "description": "The tag ID"}, + "name": {"type": "Optional[str]", "location": "body", "description": "The name of the tag"}, + "description": {"type": "Optional[str]", "location": "body", "description": "Tag description"}, + "slug": {"type": "Optional[str]", "location": "body", "description": "Alphanumeric identifier for the tag"}, + }, + "required": ["tag_id"], + }, + "delete_tag": { + "method": "DELETE", + "path": "/tags/{tag_id}", + "description": "Delete a tag", + "parameters": { + "tag_id": {"type": "str", "location": "path", "description": "The tag ID"}, + }, + "required": ["tag_id"], + }, + + # ================================================================================ + # COMMENTS + # ================================================================================ + "list_comments": { + "method": "GET", + "path": "/comments", + "description": "List all comments", + "parameters": { + "page": {"type": "Optional[int]", "location": "query", "description": "Current page of the collection (default 1)"}, + "per_page": {"type": "Optional[int]", "location": "query", "description": "Maximum number of items per page (default 10, max 100)"}, + "search": {"type": "Optional[str]", "location": "query", "description": "Limit results to those matching a search string"}, + "after": {"type": "Optional[str]", "location": "query", "description": "Limit to comments published after a given ISO8601 date"}, + "before": {"type": "Optional[str]", "location": "query", "description": "Limit to comments published before a given ISO8601 date"}, + "post": {"type": "Optional[int]", "location": "query", "description": "Limit to comments for a specific post ID"}, + "author": {"type": "Optional[str]", "location": "query", "description": "Limit to comments by a specific author ID"}, + "status": {"type": "Optional[str]", "location": "query", "description": "Limit to comments with a specific status (approve, hold, spam, trash)"}, + "orderby": {"type": "Optional[str]", "location": "query", "description": "Sort by attribute (date, date_gmt, id, include, post, parent, type)"}, + "order": {"type": "Optional[str]", "location": "query", "description": "Sort order (asc or desc)"}, + }, + "required": [], + }, + "get_comment": { + "method": "GET", + "path": "/comments/{comment_id}", + "description": "Get a specific comment by ID", + "parameters": { + "comment_id": {"type": "str", "location": "path", "description": "The comment ID"}, + }, + "required": ["comment_id"], + }, + "create_comment": { + "method": "POST", + "path": "/comments", + "description": "Create a new comment", + "parameters": { + "post": {"type": "int", "location": "body", "description": "The ID of the post the comment is for"}, + "content": {"type": "str", "location": "body", "description": "The content of the comment"}, + "author": {"type": "Optional[int]", "location": "body", "description": "The ID of the comment author"}, + "author_name": {"type": "Optional[str]", "location": "body", "description": "Display name of the comment author"}, + "author_email": {"type": "Optional[str]", "location": "body", "description": "Email of the comment author"}, + "author_url": {"type": "Optional[str]", "location": "body", "description": "URL of the comment author"}, + "parent": {"type": "Optional[int]", "location": "body", "description": "Parent comment ID for threaded comments"}, + "status": {"type": "Optional[str]", "location": "body", "description": "Comment status (approve, hold, spam, trash)"}, + }, + "required": ["post", "content"], + }, + "update_comment": { + "method": "PUT", + "path": "/comments/{comment_id}", + "description": "Update a comment", + "parameters": { + "comment_id": {"type": "str", "location": "path", "description": "The comment ID"}, + "content": {"type": "Optional[str]", "location": "body", "description": "The content of the comment"}, + "status": {"type": "Optional[str]", "location": "body", "description": "Comment status (approve, hold, spam, trash)"}, + "author": {"type": "Optional[int]", "location": "body", "description": "The ID of the comment author"}, + }, + "required": ["comment_id"], + }, + "delete_comment": { + "method": "DELETE", + "path": "/comments/{comment_id}", + "description": "Delete a comment", + "parameters": { + "comment_id": {"type": "str", "location": "path", "description": "The comment ID"}, + }, + "required": ["comment_id"], + }, + + # ================================================================================ + # USERS + # ================================================================================ + "list_users": { + "method": "GET", + "path": "/users", + "description": "List all users", + "parameters": { + "page": {"type": "Optional[int]", "location": "query", "description": "Current page of the collection (default 1)"}, + "per_page": {"type": "Optional[int]", "location": "query", "description": "Maximum number of items per page (default 10, max 100)"}, + "search": {"type": "Optional[str]", "location": "query", "description": "Limit results to those matching a search string"}, + "roles": {"type": "Optional[str]", "location": "query", "description": "Limit to users with specific roles (comma-separated)"}, + "orderby": {"type": "Optional[str]", "location": "query", "description": "Sort by attribute (id, include, name, registered_date, slug, email, url)"}, + "order": {"type": "Optional[str]", "location": "query", "description": "Sort order (asc or desc)"}, + }, + "required": [], + }, + "get_user": { + "method": "GET", + "path": "/users/{user_id}", + "description": "Get a specific user by ID", + "parameters": { + "user_id": {"type": "str", "location": "path", "description": "The user ID"}, + }, + "required": ["user_id"], + }, + "get_current_user": { + "method": "GET", + "path": "/users/me", + "description": "Get the current authenticated user", + "parameters": {}, + "required": [], + }, + + # ================================================================================ + # MEDIA + # ================================================================================ + "list_media": { + "method": "GET", + "path": "/media", + "description": "List all media items", + "parameters": { + "page": {"type": "Optional[int]", "location": "query", "description": "Current page of the collection (default 1)"}, + "per_page": {"type": "Optional[int]", "location": "query", "description": "Maximum number of items per page (default 10, max 100)"}, + "search": {"type": "Optional[str]", "location": "query", "description": "Limit results to those matching a search string"}, + "after": {"type": "Optional[str]", "location": "query", "description": "Limit to media uploaded after a given ISO8601 date"}, + "before": {"type": "Optional[str]", "location": "query", "description": "Limit to media uploaded before a given ISO8601 date"}, + "media_type": {"type": "Optional[str]", "location": "query", "description": "Limit to a specific media type (image, video, text, application, audio)"}, + "mime_type": {"type": "Optional[str]", "location": "query", "description": "Limit to a specific MIME type"}, + "orderby": {"type": "Optional[str]", "location": "query", "description": "Sort by attribute (date, relevance, id, include, title, slug)"}, + "order": {"type": "Optional[str]", "location": "query", "description": "Sort order (asc or desc)"}, + }, + "required": [], + }, + "get_media_item": { + "method": "GET", + "path": "/media/{media_id}", + "description": "Get a specific media item by ID", + "parameters": { + "media_id": {"type": "str", "location": "path", "description": "The media item ID"}, + }, + "required": ["media_id"], + }, + "delete_media_item": { + "method": "DELETE", + "path": "/media/{media_id}", + "description": "Delete a media item", + "parameters": { + "media_id": {"type": "str", "location": "path", "description": "The media item ID"}, + }, + "required": ["media_id"], + }, + + # ================================================================================ + # POST TYPES + # ================================================================================ + "list_post_types": { + "method": "GET", + "path": "/types", + "description": "List all post types", + "parameters": {}, + "required": [], + }, + "get_post_type": { + "method": "GET", + "path": "/types/{type_slug}", + "description": "Get a specific post type by slug", + "parameters": { + "type_slug": {"type": "str", "location": "path", "description": "The post type slug (e.g., post, page)"}, + }, + "required": ["type_slug"], + }, + + # ================================================================================ + # POST STATUSES + # ================================================================================ + "list_post_statuses": { + "method": "GET", + "path": "/statuses", + "description": "List all post statuses", + "parameters": {}, + "required": [], + }, + "get_post_status": { + "method": "GET", + "path": "/statuses/{status_slug}", + "description": "Get a specific post status by slug", + "parameters": { + "status_slug": {"type": "str", "location": "path", "description": "The status slug (e.g., publish, draft, pending)"}, + }, + "required": ["status_slug"], + }, + + # ================================================================================ + # TAXONOMIES + # ================================================================================ + "list_taxonomies": { + "method": "GET", + "path": "/taxonomies", + "description": "List all taxonomies", + "parameters": {}, + "required": [], + }, + "get_taxonomy": { + "method": "GET", + "path": "/taxonomies/{taxonomy_slug}", + "description": "Get a specific taxonomy by slug", + "parameters": { + "taxonomy_slug": {"type": "str", "location": "path", "description": "The taxonomy slug (e.g., category, post_tag)"}, + }, + "required": ["taxonomy_slug"], + }, + + # ================================================================================ + # SEARCH + # ================================================================================ + "search_content": { + "method": "GET", + "path": "/search", + "description": "Search site content across multiple types", + "parameters": { + "search": {"type": "str", "location": "query", "description": "The search term (required)"}, + "type_": {"type": "Optional[str]", "location": "query", "description": "Limit to a specific object type (post, term, post-format)"}, + "subtype": {"type": "Optional[str]", "location": "query", "description": "Limit to specific subtypes (post, page, category, tag, or any)"}, + "per_page": {"type": "Optional[int]", "location": "query", "description": "Maximum number of items per page (default 10, max 100)"}, + "page": {"type": "Optional[int]", "location": "query", "description": "Current page of the collection (default 1)"}, + }, + "required": ["search"], + }, +} + + +class WordPressDataSourceGenerator: + """Generator for comprehensive WordPress REST API datasource class. + + Generates methods for WordPress REST API v2 endpoints. + The generated DataSource class accepts a WordPressClient whose base URL + setting determines the API target. + """ + + def __init__(self): + self.generated_methods: List[Dict[str, str]] = [] + + # Python builtins that must be avoided as parameter names + _PYTHON_BUILTINS = frozenset({ + "format", "type", "input", "id", "hash", "range", "list", + "dict", "set", "map", "filter", "open", "print", "next", "object", + "property", "super", "abs", "all", "any", "bin", "bool", "bytes", + "callable", "chr", "complex", "dir", "divmod", "enumerate", "eval", + "exec", "float", "frozenset", "getattr", "globals", "hasattr", + "help", "hex", "int", "isinstance", "issubclass", "iter", "len", + "locals", "max", "memoryview", "min", "oct", "ord", "pow", "repr", + "reversed", "round", "setattr", "slice", "sorted", "str", "sum", + "tuple", "vars", "zip", + }) + + def _sanitize_parameter_name(self, name: str) -> str: + """Sanitize parameter names to be valid Python identifiers.""" + sanitized = name.replace("-", "_").replace(".", "_").replace("/", "_") + if sanitized and not (sanitized[0].isalpha() or sanitized[0] == "_"): + sanitized = f"param_{sanitized}" + # Avoid shadowing Python builtins + if sanitized in self._PYTHON_BUILTINS: + sanitized = f"{sanitized}_" + return sanitized + + def _build_query_params(self, endpoint_info: Dict) -> List[str]: + """Build query parameter handling code.""" + lines = [" query_params: dict[str, Any] = {}"] + required = endpoint_info.get("required", []) + + for param_name, param_info in endpoint_info["parameters"].items(): + if param_info["location"] == "query": + sanitized_name = self._sanitize_parameter_name(param_name) + # Map the sanitized Python name back to the actual API query key + # For 'type_' parameter, the API key should be 'type' + api_key = param_name.rstrip("_") + is_required = param_name in required + + if is_required: + # Required query params are always added unconditionally + if "bool" in param_info["type"]: + lines.append( + f" query_params['{api_key}'] = str({sanitized_name}).lower()" + ) + elif "int" in param_info["type"]: + lines.append( + f" query_params['{api_key}'] = str({sanitized_name})" + ) + else: + lines.append( + f" query_params['{api_key}'] = {sanitized_name}" + ) + elif "Optional[bool]" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{api_key}'] = str({sanitized_name}).lower()", + ]) + elif "Optional[int]" in param_info["type"]: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{api_key}'] = str({sanitized_name})", + ]) + else: + lines.extend([ + f" if {sanitized_name} is not None:", + f" query_params['{api_key}'] = {sanitized_name}", + ]) + + return lines + + def _build_path_formatting(self, path: str, endpoint_info: Dict) -> str: + """Build URL path with parameter substitution.""" + path_params = [ + name + for name, info in endpoint_info["parameters"].items() + if info["location"] == "path" + ] + + if path_params: + format_dict = ", ".join( + f"{param}={self._sanitize_parameter_name(param)}" + for param in path_params + ) + return f' url = self.base_url + "{path}".format({format_dict})' + else: + return f' url = self.base_url + "{path}"' + + def _build_request_body(self, endpoint_info: Dict) -> List[str]: + """Build request body handling.""" + body_params = { + name: info + for name, info in endpoint_info["parameters"].items() + if info["location"] == "body" + } + + if not body_params: + return [] + + lines = [" body: dict[str, Any] = {}"] + + for param_name, param_info in body_params.items(): + sanitized_name = self._sanitize_parameter_name(param_name) + + if param_name in endpoint_info["required"]: + lines.append(f" body['{param_name}'] = {sanitized_name}") + else: + lines.extend([ + f" if {sanitized_name} is not None:", + f" body['{param_name}'] = {sanitized_name}", + ]) + + return lines + + @staticmethod + def _modernize_type(type_str: str) -> str: + """Convert typing-style annotations to modern Python 3.10+ syntax. + + Optional[str] -> str | None, Dict[str, Any] -> dict[str, Any], + List[str] -> list[str], etc. + """ + if type_str.startswith("Optional[") and type_str.endswith("]"): + inner = type_str[len("Optional["):-1] + inner = WordPressDataSourceGenerator._modernize_type(inner) + return f"{inner} | None" + if type_str.startswith("Dict["): + inner = type_str[len("Dict["):-1] + parts = WordPressDataSourceGenerator._split_type_args(inner) + modernized = ", ".join( + WordPressDataSourceGenerator._modernize_type(p.strip()) for p in parts + ) + return f"dict[{modernized}]" + if type_str == "Dict": + return "dict" + if type_str.startswith("List["): + inner = type_str[len("List["):-1] + parts = WordPressDataSourceGenerator._split_type_args(inner) + modernized = ", ".join( + WordPressDataSourceGenerator._modernize_type(p.strip()) for p in parts + ) + return f"list[{modernized}]" + if type_str == "List": + return "list" + return type_str + + @staticmethod + def _split_type_args(s: str) -> List[str]: + """Split type arguments respecting nested brackets.""" + parts = [] + depth = 0 + current = "" + for ch in s: + if ch == "[": + depth += 1 + elif ch == "]": + depth -= 1 + if ch == "," and depth == 0: + parts.append(current.strip()) + current = "" + else: + current += ch + if current.strip(): + parts.append(current.strip()) + return parts + + def _generate_method_signature(self, method_name: str, endpoint_info: Dict) -> str: + """Generate method signature with explicit parameters.""" + params = ["self"] + has_any_bool = False + + # Collect required params, split into non-bool and bool groups + required_non_bool: List[str] = [] + required_bool: List[str] = [] + for param_name in endpoint_info["required"]: + if param_name in endpoint_info["parameters"]: + param_info = endpoint_info["parameters"][param_name] + sanitized_name = self._sanitize_parameter_name(param_name) + modern_type = self._modernize_type(param_info["type"]) + param_str = f"{sanitized_name}: {modern_type}" + if "bool" in param_info.get("type", ""): + required_bool.append(param_str) + has_any_bool = True + else: + required_non_bool.append(param_str) + + # Collect optional parameters + optional_params: List[str] = [] + for param_name, param_info in endpoint_info["parameters"].items(): + if param_name not in endpoint_info["required"]: + sanitized_name = self._sanitize_parameter_name(param_name) + modern_type = self._modernize_type(param_info["type"]) + if "| None" not in modern_type: + modern_type = f"{modern_type} | None" + optional_params.append(f"{sanitized_name}: {modern_type} = None") + if "bool" in param_info.get("type", ""): + has_any_bool = True + + # Build signature: non-bool required first, then * if needed, then bool required + optional + params.extend(required_non_bool) + if has_any_bool and (required_bool or optional_params): + params.append("*") + params.extend(required_bool) + params.extend(optional_params) + + signature_params = ",\n ".join(params) + return f" async def {method_name}(\n {signature_params}\n ) -> WordPressResponse:" + + def _generate_method_docstring(self, endpoint_info: Dict) -> List[str]: + """Generate method docstring.""" + lines = [f' """{endpoint_info["description"]}', ""] + + if endpoint_info["parameters"]: + lines.append(" Args:") + for param_name, param_info in endpoint_info["parameters"].items(): + sanitized_name = self._sanitize_parameter_name(param_name) + lines.append( + f" {sanitized_name}: {param_info['description']}" + ) + lines.append("") + + lines.extend([ + " Returns:", + " WordPressResponse with operation result", + ' """', + ]) + + return lines + + def _generate_method(self, method_name: str, endpoint_info: Dict) -> str: + """Generate a complete method for an API endpoint.""" + lines = [] + + # Method signature + lines.append(self._generate_method_signature(method_name, endpoint_info)) + + # Docstring + lines.extend(self._generate_method_docstring(endpoint_info)) + + # Query parameters + has_query = any( + info["location"] == "query" + for info in endpoint_info["parameters"].values() + ) + if has_query: + query_lines = self._build_query_params(endpoint_info) + lines.extend(query_lines) + lines.append("") + + # URL construction + lines.append(self._build_path_formatting(endpoint_info["path"], endpoint_info)) + + # Request body + body_lines = self._build_request_body(endpoint_info) + if body_lines: + lines.append("") + lines.extend(body_lines) + + # Request construction and execution + lines.append("") + lines.append(" try:") + lines.append(" request = HTTPRequest(") + lines.append(f' method="{endpoint_info["method"]}",') + lines.append(" url=url,") + lines.append(' headers={"Content-Type": "application/json"},') + if has_query: + lines.append(" query=query_params,") + if body_lines: + lines.append(" body=body,") + lines.append(" )") + lines.extend([ + " response = await self.http.execute(request) # type: ignore[reportUnknownMemberType]", + " response_data = response.json() if response.text() else None", + " return WordPressResponse(", + " success=response.status < HTTP_ERROR_THRESHOLD,", + " data=response_data,", + f' message="Successfully executed {method_name}" if response.status < HTTP_ERROR_THRESHOLD else f"Failed with status {{response.status}}"', + " )", + " except Exception as e:", + f' return WordPressResponse(success=False, error=str(e), message="Failed to execute {method_name}")', + ]) + + self.generated_methods.append({ + "name": method_name, + "endpoint": endpoint_info["path"], + "method": endpoint_info["method"], + "description": endpoint_info["description"], + }) + + return "\n".join(lines) + + def generate_wordpress_datasource(self) -> str: + """Generate the complete WordPress datasource class.""" + + class_lines = [ + '"""', + "WordPress REST API DataSource - Auto-generated API wrapper", + "", + "Generated from WordPress REST API v2 documentation.", + "Uses HTTP client for direct REST API interactions.", + "All methods have explicit parameter signatures.", + '"""', + "", + "from __future__ import annotations", + "", + "from typing import Any", + "", + "from app.sources.client.http.http_request import HTTPRequest", + "from app.sources.client.wordpress.wordpress import WordPressClient, WordPressResponse", + "", + "# HTTP status code constant", + "HTTP_ERROR_THRESHOLD = 400", + "", + "", + "class WordPressDataSource:", + ' """WordPress REST API DataSource', + "", + " Provides async wrapper methods for WordPress REST API v2 operations:", + " - Posts CRUD", + " - Pages CRUD", + " - Categories and Tags", + " - Comments", + " - Users", + " - Media", + " - Post Types, Statuses, Taxonomies", + " - Search", + "", + " The base URL is determined by the WordPressClient's configured", + " authentication method (WordPress.com OAuth or self-hosted).", + "", + " All methods return WordPressResponse objects.", + ' """', + "", + " def __init__(self, client: WordPressClient) -> None:", + ' """Initialize with WordPressClient.', + "", + " Args:", + " client: WordPressClient instance with configured authentication", + ' """', + " self._client = client", + " self.http = client.get_client()", + " try:", + " self.base_url = self.http.get_base_url().rstrip('/')", + " except AttributeError as exc:", + " raise ValueError('HTTP client does not have get_base_url method') from exc", + "", + " def get_data_source(self) -> 'WordPressDataSource':", + ' """Return the data source instance."""', + " return self", + "", + " def get_client(self) -> WordPressClient:", + ' """Return the underlying WordPressClient."""', + " return self._client", + "", + ] + + # Generate all API methods + for method_name, endpoint_info in WORDPRESS_API_ENDPOINTS.items(): + class_lines.append(self._generate_method(method_name, endpoint_info)) + class_lines.append("") + + return "\n".join(class_lines) + + def save_to_file(self, filename: Optional[str] = None) -> None: + """Generate and save the WordPress datasource to a file.""" + if filename is None: + filename = "wordpress.py" + + script_dir = Path(__file__).parent if __file__ else Path(".") + wordpress_dir = script_dir.parent / "app" / "sources" / "external" / "wordpress" + wordpress_dir.mkdir(parents=True, exist_ok=True) + + full_path = wordpress_dir / filename + + class_code = self.generate_wordpress_datasource() + + full_path.write_text(class_code, encoding="utf-8") + + print(f"Generated WordPress data source with {len(self.generated_methods)} methods") + print(f"Saved to: {full_path}") + + # Print resource summary + resource_categories = { + "Post": 0, + "Page": 0, + "Category": 0, + "Tag": 0, + "Comment": 0, + "User": 0, + "Media": 0, + "Post Type": 0, + "Post Status": 0, + "Taxonomy": 0, + "Search": 0, + } + + for method in self.generated_methods: + name = method["name"] + if "post" in name and "type" not in name and "status" not in name: + resource_categories["Post"] += 1 + elif "page" in name: + resource_categories["Page"] += 1 + elif "categor" in name: + resource_categories["Category"] += 1 + elif "tag" in name: + resource_categories["Tag"] += 1 + elif "comment" in name: + resource_categories["Comment"] += 1 + elif "user" in name: + resource_categories["User"] += 1 + elif "media" in name: + resource_categories["Media"] += 1 + elif "type" in name: + resource_categories["Post Type"] += 1 + elif "status" in name: + resource_categories["Post Status"] += 1 + elif "taxonom" in name: + resource_categories["Taxonomy"] += 1 + elif "search" in name: + resource_categories["Search"] += 1 + + print(f"\nMethods by Resource:") + for category, count in resource_categories.items(): + if count > 0: + print(f" - {category}: {count}") + + +def main(): + """Main function for WordPress data source generator.""" + import argparse + + parser = argparse.ArgumentParser( + description="Generate WordPress REST API data source" + ) + parser.add_argument("--filename", "-f", help="Output filename (optional)") + + args = parser.parse_args() + + try: + generator = WordPressDataSourceGenerator() + generator.save_to_file(args.filename) + return 0 + except Exception as e: + print(f"Failed to generate WordPress data source: {e}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/python/pyproject.toml b/backend/python/pyproject.toml index d9f453c99..6c7207a46 100644 --- a/backend/python/pyproject.toml +++ b/backend/python/pyproject.toml @@ -154,6 +154,7 @@ ignore = [ "app/sources/external/**/example*.py" = ["ANN001", "ANN002", "ANN003", "ANN201", "ANN202", "ANN401", "T201", "T203"] "app/**/migrations/*.py" = ["ANN001", "ANN002", "ANN003", "ANN201", "ANN202", "ANN401"] "tests/**/*.py" = ["ANN001", "ANN002", "ANN003", "FBT001", "FBT002", "T201", "T203"] +"app/sources/client/**/[!i]*.py" = ["ANN401"] # SDK client wrappers use Any for untyped third-party SDKs [project] @@ -227,6 +228,7 @@ dependencies = [ "linkedin-api-client>=0.3.0", "mail-parser-reply==1.36", "markdown==3.8", + "miro-api>=2.2.0", "mmh3==4.1.0", "monday-api-python-sdk>=0.1.0", "msgspec==0.20.0", @@ -264,6 +266,7 @@ dependencies = [ "ruff==0.11.9", "sentence-transformers==3.4.1", "slack-sdk==3.27.0", + "smartsheet-python-sdk==3.7.2", "spacy<=3.7.5", "tenacity==8.5.0", "thrift>=0.16.0",