diff --git a/src/sap_cloud_sdk/outputmanagement/__init__.py b/src/sap_cloud_sdk/outputmanagement/__init__.py new file mode 100644 index 0000000..812b9ff --- /dev/null +++ b/src/sap_cloud_sdk/outputmanagement/__init__.py @@ -0,0 +1,73 @@ +"""SAP Ariba Output Management Service SDK for Python.""" + +from .client import ( + OutputManagementServiceClient, + OutputManagementServiceDefaultClient, +) +from .client_provider import ( + OutputManagementServiceClientProvider, + OutputManagementServiceClientProviderBuilder, +) +from .models.output_request import OutputRequest, OutputRequestBuilder +from .models.output_response import ( + OutputResponse, + OutputRequestStatusResponse, + DocumentResponse, +) +from .models.email_configuration import EmailConfiguration +from .models.attachment_config import AttachmentConfig +from .models.output_management_info import OutputManagementInfo +from .models.output_request_data import OutputRequestData +from .models.direct_share_configuration import DirectShareConfiguration +from .models.form_configuration import FormConfiguration +from .clients.email_client import EmailClient +from .config.destination_credential_config import DestinationCredentialConfig +from .constants import FileFormat, Channel, Status +from .exceptions import ( + OutputManagementException, + AuthenticationException, + ValidationException, + NetworkException, + DestinationNotFoundException, + DestinationAccessException, +) + +__version__ = "1.0.0" + +__all__ = [ + # Client classes + "OutputManagementServiceClient", + "OutputManagementServiceDefaultClient", + "OutputManagementServiceClientProvider", + "OutputManagementServiceClientProviderBuilder", + "EmailClient", + # Models + "OutputRequest", + "OutputRequestBuilder", + "OutputResponse", + "OutputRequestStatusResponse", + "DocumentResponse", + "EmailConfiguration", + "AttachmentConfig", + "OutputManagementInfo", + "OutputRequestData", + "DirectShareConfiguration", + "FormConfiguration", + # Configuration + "DestinationCredentialConfig", + # Constants/Enums + "FileFormat", + "Channel", + "Status", + # Exceptions + "OutputManagementException", + "AuthenticationException", + "ValidationException", + "NetworkException", + "DestinationNotFoundException", + "DestinationAccessException", +] + + + + diff --git a/src/sap_cloud_sdk/outputmanagement/client.py b/src/sap_cloud_sdk/outputmanagement/client.py new file mode 100644 index 0000000..be03d0c --- /dev/null +++ b/src/sap_cloud_sdk/outputmanagement/client.py @@ -0,0 +1,79 @@ +"""Main client classes.""" + +import logging +import requests +from abc import ABC, abstractmethod + +from .clients.output_requests_client import OutputRequestsClient +from .clients.output_requests_client_impl import OutputRequestsClientImpl + +logger = logging.getLogger(__name__) + + +class OutputManagementServiceClient(ABC): + """Abstract base class for Output Management Service client.""" + + @abstractmethod + def get_output_requests_client(self) -> OutputRequestsClient: + """Get output requests client. + + Returns: + Output requests client + """ + pass + + + @abstractmethod + def close(self) -> None: + """Close the client and release resources.""" + pass + + +class OutputManagementServiceDefaultClient(OutputManagementServiceClient): + """Default implementation of Output Management Service client.""" + + def __init__( + self, + base_url: str, + destination: any = None, + ): + """Initialize client. + + Args: + base_url: Base URL of the service + destination: Optional Cloud SDK destination object for making requests + """ + self._base_url = base_url.rstrip("/") + self._destination = destination + + # Create a simple requests session + self._session = requests.Session() + + # Initialize output requests client + self._output_requests_client = OutputRequestsClientImpl( + self._session, + self._base_url, + self._destination, + ) + + logger.info(f"Initialized Output Management Service client for {base_url}") + + def get_output_requests_client(self) -> OutputRequestsClient: + """Get output requests client.""" + return self._output_requests_client + + + def close(self) -> None: + """Close the client and release resources.""" + self._session.close() + logger.info("Output Management Service client closed") + + def __enter__(self): + """Context manager entry.""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Context manager exit.""" + self.close() + + diff --git a/src/sap_cloud_sdk/outputmanagement/client_provider.py b/src/sap_cloud_sdk/outputmanagement/client_provider.py new file mode 100644 index 0000000..ddccaab --- /dev/null +++ b/src/sap_cloud_sdk/outputmanagement/client_provider.py @@ -0,0 +1,92 @@ +"""Client provider and builder.""" + +import logging + +from .client import ( + OutputManagementServiceClient, + OutputManagementServiceDefaultClient, +) +from .config.destination_credential_config import DestinationCredentialConfig +from .exceptions import ValidationException + +logger = logging.getLogger(__name__) + + +class OutputManagementServiceClientProvider: + """Provider for Output Management Service client.""" + + def __init__(self, client: OutputManagementServiceClient): + """Initialize provider. + + Args: + client: Output Management Service client + """ + self._client = client + + def get_client(self) -> OutputManagementServiceClient: + """Get the client instance. + + Returns: + Output Management Service client + """ + return self._client + + +class OutputManagementServiceClientProviderBuilder: + """Builder for Output Management Service client provider.""" + + def __init__(self): + """Initialize builder.""" + self._destination_credential_config: DestinationCredentialConfig = None + + def with_destination_credentials( + self, config: DestinationCredentialConfig + ) -> "OutputManagementServiceClientProviderBuilder": + """Configure with destination credentials. + + Args: + config: Destination credential configuration + + Returns: + Builder instance + """ + self._destination_credential_config = config + return self + + def build(self) -> OutputManagementServiceClientProvider: + """Build the client provider. + + Returns: + Client provider + + Raises: + ValidationException: If configuration is invalid + """ + if not self._destination_credential_config: + raise ValidationException( + "Destination credentials must be configured", + error_code="MISSING_CONFIGURATION", + ) + + # For destination credentials, use SAP Cloud SDK + logger.info("Using destination credential configuration") + + # Get the destination object - it handles authentication automatically + http_destination = self._destination_credential_config.get_destination() + + # Get the base URL from destinatiozxn + base_url = self._destination_credential_config.get_base_url() + logger.info(f"Retrieved destination base URL: {base_url}") + + # Build client with destination object + # The destination object handles auth automatically + client = OutputManagementServiceDefaultClient( + base_url=base_url, + destination=http_destination, + ) + + logger.info("Built Output Management Service client provider") + + return OutputManagementServiceClientProvider(client) + + diff --git a/src/sap_cloud_sdk/outputmanagement/clients/__init__.py b/src/sap_cloud_sdk/outputmanagement/clients/__init__.py new file mode 100644 index 0000000..b254eaf --- /dev/null +++ b/src/sap_cloud_sdk/outputmanagement/clients/__init__.py @@ -0,0 +1,11 @@ +"""Client implementations.""" + +from .output_requests_client import OutputRequestsClient +from .output_requests_client_impl import OutputRequestsClientImpl +from .email_client import EmailClient + +__all__ = [ + "OutputRequestsClient", + "OutputRequestsClientImpl", + "EmailClient", +] \ No newline at end of file diff --git a/src/sap_cloud_sdk/outputmanagement/clients/email_client.py b/src/sap_cloud_sdk/outputmanagement/clients/email_client.py new file mode 100644 index 0000000..291bb1f --- /dev/null +++ b/src/sap_cloud_sdk/outputmanagement/clients/email_client.py @@ -0,0 +1,240 @@ +"""Email client for simplified email sending via SAP Ariba Output Service.""" + +from typing import Optional, List, Dict, Any +from ..models.output_request import OutputRequest +from ..models.output_request_data import OutputRequestData +from ..models.output_management_info import OutputManagementInfo +from ..models.email_configuration import EmailConfiguration +from ..models.pre_generated_attachment import PreGeneratedAttachment +from ..models.output_response import OutputResponse, ErrorResponse +from ..config.destination_credential_config import DestinationCredentialConfig +from ..constants import Channel +from ..utils.request_validator import RequestValidator + + +class EmailClient: + """ + Simplified client for sending emails through SAP Ariba Output Service. + + This client handles all the complexity internally - users only need to provide + minimal information: template key, recipients, business document, and destination. + """ + + def create_output_request( + self, + notification_template_key: str, + to: List[str], + business_document: Dict[str, Any], + cc: Optional[List[str]] = None, + template_language: str = "en", + pre_generated_attachments: Optional[List[str]] = None + ) -> OutputRequest: + """ + Create an OutputRequest object from the provided parameters. + + This method handles all the complexity of building the CloudEvents structure, + extracting document metadata, and configuring email settings. + + Args: + notification_template_key: ANS template identifier + to: List of recipient email addresses + business_document: The business document as a dictionary + cc: Optional list of CC email addresses + template_language: ISO language code for email template + pre_generated_attachments: Optional list of pre-generated attachment URLs + + Returns: + OutputRequest: Fully constructed output request ready to send + """ + # Extract document type and ID from business document + # Assuming the first key in business_document is the document type + doc_type_key = next(iter(business_document.keys())) + doc_content = business_document[doc_type_key] + + # Try to extract document ID from common field names + doc_id = None + for id_field in ['id', 'orderId', 'invoiceNumber', 'documentId', 'number']: + if id_field in doc_content: + doc_id = str(doc_content[id_field]) + break + + # If no ID found, use template key as fallback + if not doc_id: + doc_id = f"{notification_template_key}-{id(business_document)}" + + # Generate business document type from the key + business_document_type = f"com.sap.{doc_type_key.lower()}" + + # Convert pre_generated_attachments strings to PreGeneratedAttachment objects + attachment_objects = None + if pre_generated_attachments: + attachment_objects = [ + PreGeneratedAttachment(url=url) for url in pre_generated_attachments + ] + + # Build email configuration + email_config = EmailConfiguration( + email_notification_template_key=notification_template_key, + email_template_language=template_language, + to=to, + cc=cc, + pre_generated_attachments=attachment_objects + ) + + # Build output management info + output_mgmt = OutputManagementInfo( + business_document_type=business_document_type, + business_document_id=doc_id, + is_priority=False, + channels=[Channel.INTERNAL_EMAIL], + email_configuration=email_config + ) + + # Build request data (OutputManagement + BusinessDocument) + data = OutputRequestData( + output_management=output_mgmt, + business_document=business_document + ) + + # Build output request (CloudEvents structure) + # Source format must be /region/application/tenant per CloudEvents spec + output_request = OutputRequest( + source=f"/region/sap/{doc_type_key}", + type=f"{business_document_type}.notification.created.v1", + data=data + ) + + return output_request + + def send_email( + self, + notification_template_key: str, + to: List[str], + business_document: Dict[str, Any], + destination_name: str, + cc: Optional[List[str]] = None, + template_language: str = "en", + access_strategy: str = "PROVIDER_ONLY", + pre_generated_attachments: Optional[List[str]] = None + ) -> OutputResponse: + """ + Send an email using the SAP Ariba Output Service. + + This method builds the complete OutputRequest structure internally. + All CloudEvents metadata and document types are auto-generated. + + Args: + notification_template_key: ANS template identifier (e.g., "PO_APPROVAL_NOTIFICATION") + to: List of recipient email addresses + business_document: The business document as a dictionary + destination_name: Name of the destination for authentication and endpoint + cc: Optional list of CC email addresses + template_language: ISO language code for email template (default: "en") + access_strategy: Destination access strategy - "PROVIDER_ONLY" or "SUBSCRIBER_ONLY" (default: "PROVIDER_ONLY") + pre_generated_attachments: Optional list of pre-generated attachment URLs + + Returns: + OutputResponse: Response from the output service + + Raises: + ValueError: If required parameters are invalid + Exception: If the email sending fails + + Example: + ```python + from sap_cloud_sdk.outputmanagement.clients.email_client import EmailClient + + client = EmailClient() + + # Just provide essentials - that's it! + response = client.send_email( + notification_template_key="PO_APPROVAL_NOTIFICATION", + to=["finance@company.com"], + business_document={ + "PurchaseOrder": { + "orderId": "PO-12345", + "vendor": "ACME Corp", + "total": 1500.00 + } + }, + destination_name="ARIBA_OUTPUT_SERVICE" + ) + + if response.error: + print(f"Failed: {response.error}") + else: + print(f"Success: {response.output_request_id}") + ``` + """ + # Validate input parameters using RequestValidator + validation_error = RequestValidator.validate_email_parameters( + notification_template_key=notification_template_key, + to=to, + business_document=business_document, + template_language=template_language, + cc=cc + ) + if validation_error: + return OutputResponse( + output_request_id=None, + error=ErrorResponse( + message=validation_error, + code="INVALID_REQUEST" + ) + ) + + # Validate destination_name + if not destination_name or not destination_name.strip(): + return OutputResponse( + output_request_id=None, + error=ErrorResponse( + message="destination_name cannot be null or empty", + code="INVALID_REQUEST" + ) + ) + + try: + # Import here to avoid circular import at module initialization + from ..client_provider import OutputManagementServiceClientProviderBuilder + + # Create the output request using the extracted method + output_request = self.create_output_request( + notification_template_key=notification_template_key, + to=to, + business_document=business_document, + cc=cc, + template_language=template_language, + pre_generated_attachments=pre_generated_attachments + ) + + # Validate the output request using RequestValidator + validation_error = RequestValidator.validate(output_request) + if validation_error: + return OutputResponse( + output_request_id=None, + error=ErrorResponse( + message=validation_error, + code="INVALID_REQUEST" + ) + ) + + # Create destination config with access strategy + destination_config = DestinationCredentialConfig( + destination_name=destination_name, + access_strategy=access_strategy + ) + + # Build the client provider using the existing builder + provider_builder = OutputManagementServiceClientProviderBuilder() + provider_builder.with_destination_credentials(destination_config) + + # Build the provider and get the client + provider = provider_builder.build() + oms_client = provider.get_client() + + # Get the output requests client and send the request + output_requests_client = oms_client.get_output_requests_client() + return output_requests_client.send_output_request(output_request) + + except Exception as e: + raise Exception(f"Failed to send email via destination '{destination_name}': {str(e)}") from e \ No newline at end of file diff --git a/src/sap_cloud_sdk/outputmanagement/clients/output_requests_client.py b/src/sap_cloud_sdk/outputmanagement/clients/output_requests_client.py new file mode 100644 index 0000000..3d2eb08 --- /dev/null +++ b/src/sap_cloud_sdk/outputmanagement/clients/output_requests_client.py @@ -0,0 +1,133 @@ +"""Output requests client interface.""" + +import logging +from abc import ABC, abstractmethod + +from ..models.output_request import OutputRequest +from ..models.output_response import ( + OutputResponse, + JobStatusResponse, + DocumentResponse, +) + +logger = logging.getLogger(__name__) + + +class OutputRequestsClient(ABC): + """ + Interface for managing output requests in the Output Management service. + + This interface defines operations for: + - Submitting output requests for document generation and delivery + - Checking the status of submitted requests + - Retrieving generated documents + + Usage Example: + from sap_cloud_sdk.outputmanagement import OutputManagementServiceClient + + client = OutputManagementServiceClient.from_destination("DEST") + requests_client = client.get_output_requests_client() + + # Submit request + request = OutputRequest( + source="https://...", + type="com.sap.procurement.po.created", + business_document_type="com.sap.procurement.PurchaseOrder", + business_document_id="PO-123" + ) + + response = requests_client.send_output_request(request) + if response.has_errors(): + print(f"Errors: {response.errors}") + else: + request_id = response.output_request_id + print(f"Request ID: {request_id}") + + # Check status + status = requests_client.get_output_request_status(request_id) + if not status.errors: + print(f"Status: {status.created_at}") + + # Get document + document_response = requests_client.get_document("DIRECT_SHARE", request_id) + if not document_response.errors: + document = document_response.document_content + print(f"Document size: {len(document)}") + """ + + @abstractmethod + def send_output_request(self, output_request: OutputRequest) -> OutputResponse: + """ + Submits an output request to the Output Management service. + + This method sends a complete output request to trigger document generation and delivery. + The request is processed asynchronously, and this method returns immediately with an + OutputResponse containing the request ID or error information. + + Response Handling: + - HTTP 202 (Accepted) - Request successfully submitted, returns OutputResponse with request ID + - HTTP 4xx - Client error, returns OutputResponse with error details + - HTTP 5xx - Server error, returns OutputResponse with error details + - Validation Error - Returns OutputResponse with validation error message + + Note: This method does not raise exceptions. Check the response's has_errors() method + or errors list to determine if the operation was successful. + + Args: + output_request: The output request to submit + + Returns: + OutputResponse containing the request ID if successful, or error details if failed + """ + pass + + @abstractmethod + def get_output_request_status(self, request_id: str) -> JobStatusResponse: + """ + Retrieves the status of a previously submitted output request. + + Use this method to check the processing status of an output request after submission. + The response contains detailed information about the request processing state. + + Common Status Values: + - PENDING - Request is queued for processing + - PROCESSING - Document generation in progress + - COMPLETED - Document successfully generated and delivered + - FAILED - Processing failed (check error details) + + Note: This method does not raise exceptions. Check the response's errors field + to determine if the operation failed. + + Args: + request_id: The ID of the request to check + + Returns: + JobStatusResponse containing request details if successful, or error details if failed + """ + pass + + @abstractmethod + def get_document(self, channel: str, output_request_id: str) -> DocumentResponse: + """ + Retrieves a generated document from the Output Management service. + + This method downloads the binary content of a document that was generated + as part of an output request. The document must be available (request status = COMPLETED) + before it can be retrieved. + + Supported Channels: + - DIRECT_SHARE - Documents stored for direct download + - EMAIL - Attachments from email deliveries (if accessible) + - PRINT - Print-ready documents + + Note: This method does not raise exceptions. Check the response's errors field + to determine if the operation failed. + + Args: + channel: The delivery channel (e.g., "DIRECT_SHARE") + output_request_id: The output request ID + + Returns: + DocumentResponse containing the document content if successful, or error details if failed + """ + pass \ No newline at end of file diff --git a/src/sap_cloud_sdk/outputmanagement/clients/output_requests_client_impl.py b/src/sap_cloud_sdk/outputmanagement/clients/output_requests_client_impl.py new file mode 100644 index 0000000..1c596eb --- /dev/null +++ b/src/sap_cloud_sdk/outputmanagement/clients/output_requests_client_impl.py @@ -0,0 +1,551 @@ +"""Implementation of output requests client.""" + +import logging +from typing import Dict, Optional +import os +import json +import uuid +import requests + +from .output_requests_client import OutputRequestsClient +from ..models.output_request import OutputRequest +from ..models.output_response import ( + OutputResponse, + JobStatusResponse, + OutputRequestStatusResponse, + DocumentResponse, +) +from ..constants import Constants +from ..utils.request_validator import RequestValidator + +logger = logging.getLogger(__name__) + + +class OutputRequestsClientImpl(OutputRequestsClient): + """ + Implementation of OutputRequestsClient for managing output requests. + + This implementation provides HTTP-based communication with the Output Management service + for submitting, tracking, and retrieving output requests and generated documents. + """ + + def __init__( + self, + http_session: requests.Session, + base_url: str, + destination: any = None, + ): + """ + Constructs a new OutputRequestsClientImpl. + + Args: + http_session: The requests Session for making HTTP requests + base_url: The base URL of the Output Management service + destination: Optional Cloud SDK destination object for making authenticated requests + """ + self._http_session = http_session + self._base_url = base_url.rstrip("/") + self._destination = destination + + # Get sender-provider-subaccount-id from environment variable + self._sender_provider_subaccount_id = os.getenv("APPFND_CONHOS_SUBACCOUNTID") + if self._sender_provider_subaccount_id: + logger.info(f"Loaded SENDER_PROVIDER_SUBACCOUNT_ID: {self._sender_provider_subaccount_id}") + else: + logger.debug("SENDER_PROVIDER_SUBACCOUNT_ID environment variable not set") + + def send_output_request(self, output_request: OutputRequest) -> OutputResponse: + """Submits an output request to the Output Management service.""" + logger.info("Sending output request") + + if output_request is None: + logger.error("OutputRequest cannot be None") + return self._create_output_error_response( + "INVALID_REQUEST", + "OutputRequest cannot be None" + ) + + # Validate the output request + validation_error = RequestValidator.validate(output_request) + if validation_error: + logger.error(f"Validation failed: {validation_error}") + return self._create_output_error_response( + "INVALID_REQUEST", + validation_error + ) + + endpoint = f"{self._base_url}{Constants.API_OUTPUT_CONTROL}outputRequest" + logger.debug(f"Endpoint: {endpoint}") + + headers = self._get_headers() + headers[Constants.HEADER_CONTENT_TYPE] = Constants.CONTENT_TYPE_JSON + headers[Constants.HEADER_ACCEPT] = Constants.CONTENT_TYPE_JSON + + # Add sender-provider-subaccount-id header if available + if self._sender_provider_subaccount_id: + headers[Constants.HEADER_SENDER_PROVIDER_SUBACCOUNT_ID] = self._sender_provider_subaccount_id + logger.debug(f"Added sender-provider-subaccount-id header") + + try: + request_body = output_request.model_dump(by_alias=True, exclude_none=True) + logger.info(f"Request body: {request_body}") + + response = self._execute_request('POST', endpoint, json=request_body, headers=headers) + status_code = response.status_code + + logger.debug(f"Response status: {status_code}") + + if status_code == 202: + response_data = response.json() + request_id = response_data.get("requestId") + logger.info(f"Request submitted successfully with ID: {request_id}") + return OutputResponse(output_request_id=request_id, errors=None) + + # Handle error responses + response_body = response.text + if self._is_retryable(status_code): + logger.error(f"Retryable error with status: {status_code}, body: {response_body}") + else: + logger.error(f"Non-retryable error with status: {status_code}, body: {response_body}") + + error_type = self._map_status_code_to_error(status_code) + if error_type: + return self._create_output_error_response(error_type, status_code) + else: + logger.warning(f"Unhandled status code: {status_code}. Using original status code and message.") + return self._create_output_error_response(status_code, response_body) + + except Exception as e: + logger.error(f"Exception occurred: {e}", exc_info=True) + return self._create_output_error_response( + "OUTPUT_REQUEST_FAILED", + f"Failed to send output request: {str(e)}" + ) + + def get_output_request_status(self, request_id: str) -> JobStatusResponse: + """Retrieves the status of a previously submitted output request.""" + logger.info(f"Getting status for request: {request_id}") + + validation_error = RequestValidator.validate_job_status_request(request_id) + if validation_error: + logger.error(f"Validation failed for output request status: {validation_error}") + return self._create_status_error_response( + "INVALID_REQUEST", + validation_error + ) + + endpoint = f"{self._base_url}{Constants.API_OUTPUT_CONTROL}outputRequest/{request_id}" + logger.debug(f"Endpoint: {endpoint}") + + headers = self._get_headers() + headers[Constants.HEADER_ACCEPT] = Constants.CONTENT_TYPE_JSON + + # Add sender-provider-subaccount-id header if available + if self._sender_provider_subaccount_id: + headers[Constants.HEADER_SENDER_PROVIDER_SUBACCOUNT_ID] = self._sender_provider_subaccount_id + logger.debug(f"Added sender-provider-subaccount-id header for get status") + + try: + response = self._execute_request('GET', endpoint, headers=headers) + status_code = response.status_code + + logger.debug(f"Response status: {status_code}") + + if status_code == 200: + response_data = response.json() + status_response = OutputRequestStatusResponse(**response_data) + logger.info("Status retrieved successfully") + return JobStatusResponse(status=status_response, errors=None) + + # Handle error responses + response_body = response.text + if self._is_retryable(status_code): + logger.error(f"Retryable error with status: {status_code}, body: {response_body}") + else: + logger.error(f"Non-retryable error with status: {status_code}, body: {response_body}") + + error_type = self._map_status_code_to_error(status_code) + if error_type: + return self._create_status_error_response(error_type, status_code) + else: + logger.warning(f"Unhandled status code: {status_code}. Using original status code and message.") + return self._create_status_error_response(status_code, response_body) + + except Exception as e: + logger.error(f"Exception occurred: {e}", exc_info=True) + return self._create_status_error_response( + f"Failed to get output request status: {str(e)}" + ) + + def get_document(self, channel: str, output_request_id: str) -> DocumentResponse: + """Retrieves a generated document from the Output Management service.""" + logger.info(f"Getting document for request: {output_request_id}, channel: {channel}") + + validation_error = RequestValidator.validate_get_document_request(channel, output_request_id) + if validation_error: + logger.error(f"Validation failed for get document: {validation_error}") + return self._create_document_error_response( + "INVALID_REQUEST", + validation_error + ) + + endpoint = f"{self._base_url}{Constants.API_OUTPUT_CONTROL}document/{channel}" + logger.debug(f"Endpoint: {endpoint}") + + headers = self._get_headers() + headers[Constants.HEADER_CONTENT_TYPE] = Constants.CONTENT_TYPE_JSON + headers[Constants.HEADER_ACCEPT] = Constants.CONTENT_TYPE_PDF + + # Add sender-provider-subaccount-id header if available + if self._sender_provider_subaccount_id: + headers[Constants.HEADER_SENDER_PROVIDER_SUBACCOUNT_ID] = self._sender_provider_subaccount_id + logger.debug(f"Added sender-provider-subaccount-id header for get document") + + try: + response = self._execute_request('POST', endpoint, data=output_request_id, headers=headers) + status_code = response.status_code + + logger.debug(f"Response status: {status_code}") + + if status_code == 200: + document = response.content + logger.info(f"Document retrieved successfully, size: {len(document)} bytes") + return DocumentResponse(document_content=document, errors=None) + + # Handle error responses + response_body = response.text + if self._is_retryable(status_code): + logger.error(f"Retryable error with status: {status_code}, body: {response_body}") + else: + logger.error(f"Non-retryable error with status: {status_code}, body: {response_body}") + + error_type = self._map_status_code_to_error(status_code) + if error_type: + return self._create_document_error_response(error_type, status_code) + else: + logger.warning(f"Unhandled status code: {status_code}. Using original status code and message.") + return self._create_document_error_response(status_code, response_body) + + except Exception as e: + logger.error(f"Exception occurred: {e}", exc_info=True) + return self._create_document_error_response( + f"Failed to get document: {str(e)}" + ) + + def _fetch_oauth_token_from_destination(self) -> Optional[str]: + """Fetch OAuth token using destination's OAuth configuration with mTLS. + + Uses SAP Cloud SDK to retrieve certificates from the Destination Service. + + Returns: + OAuth access token or None if fetch fails + """ + if not self._destination or not hasattr(self._destination, 'properties'): + return None + + props = self._destination.properties + if not isinstance(props, dict): + return None + + # Log destination properties for debugging + logger.debug(f"Destination properties keys: {list(props.keys())}") + + # Extract OAuth configuration from destination properties + token_url = props.get('tokenServiceURL') + client_id = props.get('client_id') or props.get('clientId') or props.get('tokenService.body.client_id') + grant_type = props.get('tokenService.body.grant_type', 'client_credentials') + app_tid = props.get('tokenService.body.app_tid') + + # Certificate name to lookup in Destination Service + # The certificate must be uploaded to Destination Service first using: + # certificate_client.create_certificate(Certificate(name="my-cert.p12", content=base64_content, type="PKCS12")) + cert_name = props.get('tokenService.KeyStoreLocation') + cert_password = props.get('tokenService.KeyStorePassword') + + if not token_url or not client_id: + logger.error(f"Missing OAuth config: tokenServiceURL={token_url}, clientId={client_id}") + return None + + if not cert_name: + logger.error("✗ No certificate name in destination properties (tokenService.certificate)") + logger.error("✗ Please upload your keystore to Destination Service and reference it") + logger.error("✗ Example: certificate_client.create_certificate(Certificate(name='my-cert.p12', content=base64_content, type='PKCS12'))") + return None + + # Track temp files for cleanup + temp_files_created = False + cert_file = None + key_file = None + + try: + # Build OAuth token request + token_data = { + 'grant_type': grant_type, + 'client_id': client_id + } + if app_tid: + token_data['app_tid'] = app_tid + + logger.info(f"Fetching OAuth token from {token_url} using mTLS") + logger.info(f"✓ Using certificate from Destination Service: {cert_name}") + + # Get certificate from Cloud SDK Destination Service + try: + from sap_cloud_sdk.destination import create_certificate_client, AccessStrategy + import tempfile + import base64 + from cryptography.hazmat.primitives.serialization import pkcs12, Encoding, PrivateFormat, NoEncryption + + certificate_client = create_certificate_client(instance="ariba-sourcing-event-instance") + cert = certificate_client.get_subaccount_certificate(cert_name, access_strategy=AccessStrategy.PROVIDER_ONLY) + + # Check if certificate was found + if cert is None: + logger.error(f"✗ Certificate '{cert_name}' not found in Destination Service") + logger.error("✗ Please ensure the certificate is uploaded to Destination Service") + logger.error("✗ Example: certificate_client.create_certificate(Certificate(name='my-cert.p12', content=base64_content, type='PKCS12'))") + return None + + logger.info(f"✓ Retrieved certificate '{cert.name}' (type: {cert.type})") + + # Decode base64 content + cert_binary = base64.b64decode(cert.content) + logger.debug(f"✓ Decoded certificate content ({len(cert_binary)} bytes)") + + # Parse certificate - try PKCS12 format first (most common for mTLS) + password = cert_password.encode('utf-8') if cert_password else None + + try: + private_key, certificate, additional_certs = pkcs12.load_key_and_certificates( + cert_binary, + password + ) + + if not (certificate and private_key): + logger.error("✗ No certificate or key found in PKCS12") + return None + + logger.info("✓ Successfully parsed certificate and extracted keys") + + # Write certificate to temp file (include chain) + cert_fd, cert_file = tempfile.mkstemp(suffix='.pem') + with os.fdopen(cert_fd, 'wb') as f: + f.write(certificate.public_bytes(Encoding.PEM)) + if additional_certs: + for c in additional_certs: + f.write(c.public_bytes(Encoding.PEM)) + + # Write private key to temp file + key_fd, key_file = tempfile.mkstemp(suffix='.key') + with os.fdopen(key_fd, 'wb') as f: + f.write(private_key.private_bytes( + encoding=Encoding.PEM, + format=PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=NoEncryption() + )) + + temp_files_created = True + + except Exception as e: + logger.error(f"✗ Failed to parse certificate: {e}") + logger.error("✗ Certificate must be in PKCS12 format (.p12/.pfx) containing both certificate and private key") + return None + + except ImportError as e: + logger.error("✗ sap-cloud-sdk or cryptography library not installed") + logger.error("✗ Install with: pip install sap-cloud-sdk cryptography") + logger.error(f"✗ ImportError details: {e}") + return None + except Exception as e: + logger.error(f"✗ Failed to retrieve/process certificate '{cert_name}': {e}", exc_info=True) + return None + + # Make token request with mTLS + if not(cert_file and key_file): + logger.error("✗ No client certificates available") + return None + + request_kwargs = { + 'data': token_data, + 'headers': {'Content-Type': 'application/x-www-form-urlencoded'}, + 'timeout': 30, + 'verify': True, + 'cert': (cert_file, key_file) + } + + logger.info("✓ Configuring mTLS with certificate files") + logger.debug(f" Cert file: {cert_file}") + logger.debug(f" Key file: {key_file}") + + response = requests.post(token_url, **request_kwargs) + + # Clean up temp files + if temp_files_created: + try: + os.unlink(cert_file) + if key_file != cert_file: + os.unlink(key_file) + logger.debug("✓ Cleaned up temporary certificate files") + except Exception as e: + logger.warning(f"⚠ Failed to cleanup temp files: {e}") + + # Handle response + if response.status_code == 200: + token_response = response.json() + access_token = token_response.get('access_token') + if access_token: + logger.info(f"✓ Successfully fetched OAuth token (length: {len(access_token)})") + return access_token + else: + logger.error(f"✗ No access_token in response: {list(token_response.keys())}") + else: + # Parse OAuth error response + try: + error_response = response.json() + error_type = error_response.get('error', 'unknown') + error_desc = error_response.get('error_description', 'No description') + logger.error(f"✗ Token fetch failed with status {response.status_code}") + logger.error(f"✗ OAuth error: {error_type} - {error_desc}") + except: + logger.error(f"✗ Token fetch failed with status {response.status_code}: {response.text}") + + logger.error("✗ mTLS authentication failed - check certificates and credentials") + + except Exception as e: + logger.error(f"✗ Exception fetching OAuth token: {e}", exc_info=True) + # Clean up temp files even on exception + if temp_files_created: + try: + if cert_file: + os.unlink(cert_file) + if key_file and key_file != cert_file: + os.unlink(key_file) + except: + pass + + return None + + def _execute_request(self, method: str, endpoint: str, **kwargs): + """Execute HTTP request using destination if available, otherwise use session. + + Args: + method: HTTP method (GET, POST, etc.) + endpoint: Full endpoint URL + **kwargs: Additional arguments to pass to the request + + Returns: + Response object + """ + # Always use regular session - authentication is handled in _get_headers + return self._http_session.request(method, endpoint, **kwargs) + + def _get_headers(self) -> Dict[str, str]: + """Get request headers with authentication.""" + headers = {} + + # Add trace parent header for distributed tracing + headers[Constants.HEADER_TRACE_PARENT] = self._generate_trace_id() + + # If using destination, get auth token from it + if self._destination: + logger.debug(f"Using destination for authentication. Destination type: {type(self._destination)}") + + # Try to fetch OAuth token using destination's OAuth configuration + token = self._fetch_oauth_token_from_destination() + if token: + headers[Constants.AUTHORIZATION] = f"{Constants.BEARER} {token}" + logger.info("✓ Authorization header added to request") + else: + logger.error("✗ Failed to fetch OAuth token from destination") + logger.error("✗ NO Authorization header - request will fail") + else: + logger.error("✗ No destination available for authentication") + + return headers + + @staticmethod + def _generate_trace_id() -> str: + """ + Generate traceparent header in W3C Trace Context format. + + Format: version-trace-id-parent-id-trace-flags + - version: 2 hex digits (00) + - trace-id: 32 hex digits (16 bytes) + - parent-id: 16 hex digits (8 bytes) + - trace-flags: 2 hex digits (01 = sampled) + + Returns: + Traceparent header value in format: 00-{trace_id}-{parent_id}-01 + """ + trace_id = uuid.uuid4().hex # 32 hex chars + parent_id = uuid.uuid4().hex[:16] # 16 hex chars + return f"00-{trace_id}-{parent_id}-01" + + @staticmethod + def _is_retryable(status_code: int) -> bool: + """ + Checks if the HTTP status code represents a retryable error. + + Args: + status_code: The HTTP status code + + Returns: + True if the status code is 5xx (server error) or 429 (Too Many Requests) + """ + return status_code >= 500 or status_code == 429 + + @staticmethod + def _map_status_code_to_error(status_code: int) -> Optional[str]: + """ + Maps HTTP error status codes to appropriate error types. + + Note: This method returns None for unhandled status codes. + + Args: + status_code: The HTTP error status code + + Returns: + The corresponding error type, or None if not mapped + """ + error_mapping = { + # Client errors (4xx) + 400: "INVALID_REQUEST", + 401: "AUTHENTICATION_FAILED", + 403: "FORBIDDEN", + 404: "RESOURCE_NOT_FOUND", + 409: "CONFLICT", + 429: "INVALID_REQUEST", # Too Many Requests + + # Server errors (5xx) + 500: "INTERNAL_SERVER_ERROR", + 502: "INTERNAL_SERVER_ERROR", # Bad Gateway + 503: "SERVICE_UNAVAILABLE", + 504: "GATEWAY_TIMEOUT", + } + return error_mapping.get(status_code) + + @staticmethod + def _create_output_error_response(error_type, message) -> OutputResponse: + """Create an OutputResponse with error information.""" + return OutputResponse( + output_request_id=None, + errors=[{"type": error_type, "message": str(message)}] + ) + + @staticmethod + def _create_status_error_response(error_type_or_message, status_code=None) -> JobStatusResponse: + """Create a JobStatusResponse with error information.""" + if status_code: + error_msg = {"type": error_type_or_message, "status_code": status_code} + else: + error_msg = {"message": error_type_or_message} + return JobStatusResponse(status=None, errors=[error_msg]) + + @staticmethod + def _create_document_error_response(error_type_or_message, status_code=None) -> DocumentResponse: + """Create a DocumentResponse with error information.""" + if status_code: + error_msg = {"type": error_type_or_message, "status_code": status_code} + else: + error_msg = {"message": error_type_or_message} + return DocumentResponse(document_content=None, errors=[error_msg]) \ No newline at end of file diff --git a/src/sap_cloud_sdk/outputmanagement/config/__init__.py b/src/sap_cloud_sdk/outputmanagement/config/__init__.py new file mode 100644 index 0000000..a0f06e6 --- /dev/null +++ b/src/sap_cloud_sdk/outputmanagement/config/__init__.py @@ -0,0 +1,5 @@ +"""Configuration classes for the SDK.""" + +from .destination_credential_config import DestinationCredentialConfig + +__all__ = ["DestinationCredentialConfig"] diff --git a/src/sap_cloud_sdk/outputmanagement/config/destination_credential_config.py b/src/sap_cloud_sdk/outputmanagement/config/destination_credential_config.py new file mode 100644 index 0000000..c24b65b --- /dev/null +++ b/src/sap_cloud_sdk/outputmanagement/config/destination_credential_config.py @@ -0,0 +1,104 @@ +"""Destination credential configuration for Output Management Service.""" +from typing import Optional +from pydantic import BaseModel, Field, field_validator +import logging +logger = logging.getLogger(__name__) +class DestinationCredentialConfig(BaseModel): + """Configuration for accessing Output Management Service via SAP BTP Destination. + This class provides a simple configuration wrapper for destination-based access. + Uses relative imports since this module is part of sap_cloud_sdk. + Attributes: + destination_name: Name of the destination in SAP BTP Destination Service + access_strategy: Optional access strategy - "PROVIDER_ONLY" or "SUBSCRIBER_ONLY" + Example: + ```python + from sap_cloud_sdk.outputmanagement import OutputManagementServiceClientProvider + from sap_cloud_sdk.outputmanagement.config import DestinationCredentialConfig + # Create config + config = DestinationCredentialConfig( + destination_name="OUTPUT_MANAGEMENT_DEST", + access_strategy="PROVIDER_ONLY" + ) + # Create client using destination + client = OutputManagementServiceClientProvider.create_from_destination(config) + ``` + """ + destination_name: str = Field( + ..., + description="Name of the destination in SAP BTP Destination Service" + ) + access_strategy: Optional[str] = Field( + default=None, + description="Access strategy: 'PROVIDER_ONLY' or 'SUBSCRIBER_ONLY' (optional)" + ) + @field_validator("destination_name") + @classmethod + def validate_destination_name(cls, v: str) -> str: + """Validate destination name is not empty.""" + if not v or not v.strip(): + raise ValueError("Destination name cannot be empty") + return v.strip() + @field_validator("access_strategy") + @classmethod + def validate_access_strategy(cls, v: Optional[str]) -> Optional[str]: + """Validate access strategy if provided.""" + if v is not None: + v = v.strip().upper() + if v not in ["PROVIDER_ONLY", "SUBSCRIBER_ONLY"]: + raise ValueError( + f"Invalid access_strategy: {v}. " + "Must be 'PROVIDER_ONLY' or 'SUBSCRIBER_ONLY'" + ) + return v + class Config: + """Pydantic configuration.""" + frozen = True + str_strip_whitespace = True + def get_destination(self): + """Retrieve the destination from SAP BTP Destination Service. + Uses relative import to access sap_cloud_sdk.destination module. + Returns: + Destination object with URL, authentication, and properties + Raises: + Exception: If destination retrieval fails + """ + from ...destination import create_client, AccessStrategy + logger.info(f"Retrieving destination '{self.destination_name}'") + client = create_client(instance="ariba-sourcing-event-instance") + if self.access_strategy: + if self.access_strategy == "PROVIDER_ONLY": + strategy = AccessStrategy.PROVIDER_ONLY + else: + strategy = AccessStrategy.SUBSCRIBER_ONLY + destination = client.get_subaccount_destination( + name=self.destination_name, + access_strategy=strategy + ) + if destination is None: + raise ValueError( + f"Destination '{self.destination_name}' not found " + f"with access strategy '{self.access_strategy}'" + ) + logger.info(f"Retrieved destination with {self.access_strategy} strategy") + else: + destination = client.get_instance_destination(name=self.destination_name) + if destination is None: + destination = client.get_subaccount_destination(name=self.destination_name) + if destination is None: + raise ValueError(f"Destination '{self.destination_name}' not found") + logger.info(f"Retrieved destination '{self.destination_name}'") + return destination + def get_base_url(self) -> str: + """Get the base URL from the destination.""" + destination = self.get_destination() + if hasattr(destination, 'url'): + url = destination.url + elif hasattr(destination, 'get_url'): + url = destination.get_url() + elif hasattr(destination, 'get_uri'): + url = destination.get_uri() + else: + raise ValueError(f"Cannot extract URL from destination '{self.destination_name}'") + if not url: + raise ValueError(f"Destination '{self.destination_name}' does not have a URL") + return url.rstrip('/') diff --git a/src/sap_cloud_sdk/outputmanagement/constants.py b/src/sap_cloud_sdk/outputmanagement/constants.py new file mode 100644 index 0000000..f53cc88 --- /dev/null +++ b/src/sap_cloud_sdk/outputmanagement/constants.py @@ -0,0 +1,50 @@ +"""Constants for the Output Management SDK.""" + +from enum import Enum + + +class Constants: + """SDK constants.""" + + # API Endpoints + API_OUTPUT_CONTROL = "/api/output-control-api/v1/" + + # Headers + CONTENT_TYPE = "Content-Type" + APPLICATION_JSON = "application/json" + AUTHORIZATION = "Authorization" + BEARER = "Bearer" + HEADER_CONTENT_TYPE = "Content-Type" + HEADER_ACCEPT = "Accept" + HEADER_SENDER_PROVIDER_SUBACCOUNT_ID = "sender-provider-subaccount-id" + HEADER_TRACE_PARENT = "traceparent" + CONTENT_TYPE_JSON = "application/json" + CONTENT_TYPE_PDF = "application/pdf" + + +class Status(Enum): + """Output request status.""" + + PENDING = "PENDING" + IN_PROGRESS = "IN_PROGRESS" + COMPLETED = "COMPLETED" + FAILED = "FAILED" + CANCELLED = "CANCELLED" + + +class FileFormat(Enum): + """Supported file formats.""" + + PDF = "PDF" + DOCX = "DOCX" + HTML = "HTML" + XML = "XML" + + +class Channel(Enum): + """Output channels.""" + + EMAIL = "EMAIL" + INTERNAL_EMAIL = "INTERNAL_EMAIL" + DIRECT_SHARE = "DIRECT_SHARE" + FORM = "FORM" diff --git a/src/sap_cloud_sdk/outputmanagement/exceptions.py b/src/sap_cloud_sdk/outputmanagement/exceptions.py new file mode 100644 index 0000000..f7f874d --- /dev/null +++ b/src/sap_cloud_sdk/outputmanagement/exceptions.py @@ -0,0 +1,69 @@ +"""Exception classes for the Output Management SDK.""" + +from typing import Optional, Dict, Any + + +class OutputManagementException(Exception): + """Base exception for Output Management SDK.""" + + def __init__( + self, + message: str, + error_code: Optional[str] = None, + status_code: Optional[int] = None, + details: Optional[Dict[str, Any]] = None, + ): + """Initialize exception. + + Args: + message: Error message + error_code: Error code + status_code: HTTP status code + details: Additional error details + """ + super().__init__(message) + self.message = message + self.error_code = error_code + self.status_code = status_code + self.details = details or {} + + def __str__(self) -> str: + """Return string representation.""" + parts = [self.message] + if self.error_code: + parts.append(f"Error Code: {self.error_code}") + if self.status_code: + parts.append(f"Status Code: {self.status_code}") + return " | ".join(parts) + + + +class AuthenticationException(OutputManagementException): + """Exception for authentication failures.""" + + pass + + +class ValidationException(OutputManagementException): + """Exception for validation failures.""" + + pass + + +class NetworkException(OutputManagementException): + """Exception for network-related errors.""" + + pass + + +class DestinationNotFoundException(OutputManagementException): + """Exception for destination not found errors.""" + + pass + + +class DestinationAccessException(OutputManagementException): + """Exception for destination access errors.""" + + pass + diff --git a/src/sap_cloud_sdk/outputmanagement/models/__init__.py b/src/sap_cloud_sdk/outputmanagement/models/__init__.py new file mode 100644 index 0000000..3812eb1 --- /dev/null +++ b/src/sap_cloud_sdk/outputmanagement/models/__init__.py @@ -0,0 +1,24 @@ +"""Model classes for the SDK.""" + +from .output_request import OutputRequest, OutputRequestBuilder +from .output_request_data import OutputRequestData +from .output_management_info import OutputManagementInfo +from .output_response import OutputResponse +from .email_configuration import EmailConfiguration +from .attachment_config import AttachmentConfig +from .direct_share_configuration import DirectShareConfiguration +from .form_configuration import FormConfiguration +from .pre_generated_attachment import PreGeneratedAttachment + +__all__ = [ + "OutputRequest", + "OutputRequestBuilder", + "OutputRequestData", + "OutputManagementInfo", + "OutputResponse", + "EmailConfiguration", + "AttachmentConfig", + "DirectShareConfiguration", + "FormConfiguration", + "PreGeneratedAttachment", +] diff --git a/src/sap_cloud_sdk/outputmanagement/models/attachment_config.py b/src/sap_cloud_sdk/outputmanagement/models/attachment_config.py new file mode 100644 index 0000000..750b941 --- /dev/null +++ b/src/sap_cloud_sdk/outputmanagement/models/attachment_config.py @@ -0,0 +1,49 @@ +"""Attachment configuration model for email documents.""" + +from typing import Optional +from pydantic import BaseModel, Field + +from .form_configuration import FormConfiguration + + +class AttachmentConfig(BaseModel): + """ + Attachment configuration for email documents. + + This is a helper class used to parse attachment configuration from INTERNAL_EMAIL requests. + It contains form configuration details that will be used to populate FormConfiguration + for document generation. + + If provided in EmailConfiguration, a PDF document will be generated using these form details + and attached to the email. If not provided, no document will be generated. + + Attributes: + form_configuration: Form configuration for PDF generation + + Example: + ```python + from sap_cloud_sdk.outputmanagement.models.attachment_config import AttachmentConfig + from sap_cloud_sdk.outputmanagement.models.form_configuration import FormConfiguration + from sap_cloud_sdk.outputmanagement.constants import FileFormat + + form_config = FormConfiguration( + form_name="PurchaseOrderForm", + form_template_name="PurchaseOrderFormTemplate", + form_language="en", + file_format=FileFormat.PDF + ) + + attachment = AttachmentConfig(form_configuration=form_config) + ``` + """ + + form_configuration: FormConfiguration = Field( + ..., + alias="formConfiguration", + description="Form configuration for PDF generation" + ) + + class Config: + """Pydantic configuration.""" + populate_by_name = True + str_strip_whitespace = True \ No newline at end of file diff --git a/src/sap_cloud_sdk/outputmanagement/models/direct_share_configuration.py b/src/sap_cloud_sdk/outputmanagement/models/direct_share_configuration.py new file mode 100644 index 0000000..b893c2b --- /dev/null +++ b/src/sap_cloud_sdk/outputmanagement/models/direct_share_configuration.py @@ -0,0 +1,29 @@ +"""Direct share configuration model.""" + +from typing import Optional, List +from pydantic import BaseModel, Field + + +class DirectShareConfiguration(BaseModel): + """Direct share channel configuration. + + Attributes: + user_ids: List of user IDs to share with + group_ids: Optional list of group IDs to share with + message: Optional message to include + expiration_days: Optional number of days until expiration + """ + + user_ids: List[str] = Field( + ..., min_length=1, description="User IDs to share with" + ) + group_ids: Optional[List[str]] = Field(None, description="Group IDs to share with") + message: Optional[str] = Field(None, description="Message to include") + expiration_days: Optional[int] = Field( + None, ge=1, description="Days until expiration" + ) + + class Config: + """Pydantic configuration.""" + + str_strip_whitespace = True diff --git a/src/sap_cloud_sdk/outputmanagement/models/email_configuration.py b/src/sap_cloud_sdk/outputmanagement/models/email_configuration.py new file mode 100644 index 0000000..8edd06d --- /dev/null +++ b/src/sap_cloud_sdk/outputmanagement/models/email_configuration.py @@ -0,0 +1,119 @@ +"""Email configuration model for INTERNAL_EMAIL channel.""" + +from typing import Optional, List +from pydantic import BaseModel, Field, field_validator + +from .attachment_config import AttachmentConfig +from .pre_generated_attachment import PreGeneratedAttachment + + +class EmailConfiguration(BaseModel): + """ + Email configuration for INTERNAL_EMAIL channel. + + This class contains all the configuration needed to send emails via the INTERNAL_EMAIL channel, + which skips Business Policy Framework evaluation and uses the configuration provided in the request. + + Usage Modes: + - Mode 1 (Simple Notification): No attachment - fast email notification only + - Mode 2 (With Document): With attachment - email with PDF attachment + + Attributes: + email_notification_template_key: ANS template identifier for email body and subject (required) + email_template_language: ISO language code for the email template (required) + to: List of recipient email addresses (required, minimum 1) + cc: List of CC recipient email addresses (optional) + attachment: Optional attachment configuration for PDF generation (optional) + + Example - Simple Notification: + ```python + from sap_cloud_sdk.outputmanagement.models.email_configuration import EmailConfiguration + + config = EmailConfiguration( + email_notification_template_key="PO_APPROVAL_NOTIFICATION", + email_template_language="en", + to=["finance@company.com", "warehouse@company.com"], + cc=["manager@company.com"] + ) + ``` + + Example - With Document Attachment: + ```python + from sap_cloud_sdk.outputmanagement.models.email_configuration import EmailConfiguration + from sap_cloud_sdk.outputmanagement.models.attachment_config import AttachmentConfig + from sap_cloud_sdk.outputmanagement.models.form_configuration import FormConfiguration + from sap_cloud_sdk.outputmanagement.constants import FileFormat + + form_config = FormConfiguration( + form_name="PurchaseOrderForm", + form_template_name="PurchaseOrderFormTemplate", + form_language="en", + file_format=FileFormat.PDF + ) + + attachment = AttachmentConfig(form_configuration=form_config) + + config = EmailConfiguration( + email_notification_template_key="PO_APPROVED_WITH_DOC", + email_template_language="en", + to=["audit@company.com"], + attachment=attachment + ) + ``` + """ + + email_notification_template_key: str = Field( + ..., + alias="emailNotificationTemplateKey", + min_length=1, + description="ANS template identifier for email body and subject" + ) + + email_template_language: str = Field( + ..., + alias="emailTemplateLanguage", + min_length=1, + description="ISO language code for the email template (e.g., 'en', 'de', 'fr')" + ) + + to: List[str] = Field( + ..., + min_length=1, + description="List of recipient email addresses" + ) + + cc: Optional[List[str]] = Field( + None, + description="List of CC recipient email addresses" + ) + + bcc: Optional[List[str]] = Field( + None, + description="List of BCC recipient email addresses" + ) + + attachment: Optional[AttachmentConfig] = Field( + None, + description="Optional attachment configuration for PDF generation" + ) + + pre_generated_attachments: Optional[List[PreGeneratedAttachment]] = Field( + None, + alias="preGeneratedAttachments", + description="List of pre-generated attachments, each with a url and optional fileFormat" + ) + + @field_validator("to", "cc", "bcc") + @classmethod + def validate_email_list(cls, v: Optional[List[str]]) -> Optional[List[str]]: + """Validate email addresses.""" + if v is not None: + for email in v: + if not email or "@" not in email: + raise ValueError(f"Invalid email address: {email}") + return v + + class Config: + """Pydantic configuration.""" + populate_by_name = True + str_strip_whitespace = True \ No newline at end of file diff --git a/src/sap_cloud_sdk/outputmanagement/models/form_configuration.py b/src/sap_cloud_sdk/outputmanagement/models/form_configuration.py new file mode 100644 index 0000000..d4771ea --- /dev/null +++ b/src/sap_cloud_sdk/outputmanagement/models/form_configuration.py @@ -0,0 +1,25 @@ +"""Form configuration model.""" + +from typing import Optional, Dict, Any +from pydantic import BaseModel, Field + + +class FormConfiguration(BaseModel): + """Form channel configuration. + + Attributes: + form_id: Form identifier + form_data: Optional form data + callback_url: Optional callback URL for form submission + """ + + form_id: str = Field(..., min_length=1, description="Form identifier") + form_data: Optional[Dict[str, Any]] = Field(None, description="Form data") + callback_url: Optional[str] = Field( + None, description="Callback URL for form submission" + ) + + class Config: + """Pydantic configuration.""" + + str_strip_whitespace = True diff --git a/src/sap_cloud_sdk/outputmanagement/models/output_management_info.py b/src/sap_cloud_sdk/outputmanagement/models/output_management_info.py new file mode 100644 index 0000000..5046fa6 --- /dev/null +++ b/src/sap_cloud_sdk/outputmanagement/models/output_management_info.py @@ -0,0 +1,105 @@ +"""Output Management information model.""" + +from typing import Optional, List, Any +from pydantic import BaseModel, Field + +from ..constants import Channel +from .email_configuration import EmailConfiguration +from .direct_share_configuration import DirectShareConfiguration + + +class OutputManagementInfo(BaseModel): + """ + Contains information required by Output Management to decide on how to orchestrate the output. + + This class encapsulates the configuration and metadata needed for output processing, + including business document identification, delivery channels, and channel-specific configurations. + + Attributes: + business_document_type: Type of the business document (required) + business_document_id: ID of the business document (required) + is_priority: Indicates if this is a priority request (optional, default: False) + user_id: User ID who triggered the output request (optional) + channels: List of channels for output delivery (required) + direct_share_configuration: Configuration for direct share channel (optional) + email_configuration: Configuration for internal email channel (optional) + cig_data_center: CIG Data Center information (optional) + + Example: + ```python + from sap_cloud_sdk.outputmanagement.models.output_management_info import OutputManagementInfo + from sap_cloud_sdk.outputmanagement.models.email_configuration import EmailConfiguration + from sap_cloud_sdk.outputmanagement.constants import Channel + + email_config = EmailConfiguration( + email_notification_template_key="PO_NOTIFICATION", + email_template_language="en", + to=["recipient@example.com"] + ) + + output_mgmt = OutputManagementInfo( + business_document_type="com.sap.procurement.PurchaseOrder", + business_document_id="PO-123", + is_priority=False, + user_id="user@sap.com", + channels=[Channel.INTERNAL_EMAIL], + email_configuration=email_config + ) + ``` + """ + + business_document_type: str = Field( + ..., + alias="businessDocumentType", + min_length=1, + description="Type of the business document (e.g., 'com.sap.procurement.PurchaseOrder')" + ) + + business_document_id: str = Field( + ..., + alias="businessDocumentId", + min_length=1, + description="ID of the business document (e.g., 'PO00551100')" + ) + + is_priority: bool = Field( + False, + alias="isPriority", + description="Indicates if this is a priority request" + ) + + user_id: Optional[str] = Field( + None, + alias="userId", + description="User ID who triggered the output request (e.g., 'user@sap.com')" + ) + + channels: List[Channel] = Field( + ..., + min_length=1, + description="List of channels for output delivery" + ) + + direct_share_configuration: Optional[DirectShareConfiguration] = Field( + None, + alias="directShareConfiguration", + description="Configuration for direct share channel" + ) + + email_configuration: Optional[EmailConfiguration] = Field( + None, + alias="emailConfiguration", + description="Configuration for internal email channel" + ) + + cig_data_center: Optional[str] = Field( + None, + alias="cigDataCenter", + description="CIG Data Center information" + ) + + class Config: + """Pydantic configuration.""" + populate_by_name = True + str_strip_whitespace = True + use_enum_values = True \ No newline at end of file diff --git a/src/sap_cloud_sdk/outputmanagement/models/output_request.py b/src/sap_cloud_sdk/outputmanagement/models/output_request.py new file mode 100644 index 0000000..31059c4 --- /dev/null +++ b/src/sap_cloud_sdk/outputmanagement/models/output_request.py @@ -0,0 +1,235 @@ +"""Output request model following CloudEvents 1.0 specification.""" + +from typing import Optional +from datetime import datetime +from pydantic import BaseModel, Field +import uuid + +from .output_request_data import OutputRequestData + + +class OutputRequest(BaseModel): + """ + Represents an Output Management request following the CloudEvents 1.0 specification. + + This is the main request object that encapsulates all information required to trigger + document generation and delivery through the Output Management service. It follows the + CloudEvents specification for event-driven architectures. + + Attributes: + spec_version: CloudEvents specification version (default: "1.0") + id: Unique ID for this event (auto-generated UUID if not provided) + source: Identifies where this event originated from (required) + time: Timestamp when the output request was triggered (auto-generated if not provided) + type: Describes the type of event (required) + data_content_type: Content type of event's data (default: "application/json") + data: Contains OutputManagement and BusinessDocument (required) + xsapsisgwdestapp: SAP system gateway destination application identifier (optional) + xsapsisgwdestappid: SAP system gateway destination application ID (optional) + xsapsisgwbackendid: SAP system gateway backend ID (optional) + + Example: + ```python + from sap_cloud_sdk.outputmanagement.models.output_request import OutputRequest + from sap_cloud_sdk.outputmanagement.models.output_request_data import OutputRequestData + from sap_cloud_sdk.outputmanagement.models.output_management_info import OutputManagementInfo + from sap_cloud_sdk.outputmanagement.models.email_configuration import EmailConfiguration + from sap_cloud_sdk.outputmanagement.constants import Channel + + # Create email configuration + email_config = EmailConfiguration( + email_notification_template_key="PO_NOTIFICATION", + email_template_language="en", + to=["recipient@example.com"] + ) + + # Create output management info + output_mgmt = OutputManagementInfo( + business_document_type="com.sap.procurement.PurchaseOrder", + business_document_id="PO-123", + channels=[Channel.INTERNAL_EMAIL], + email_configuration=email_config + ) + + # Create business document + business_doc = { + "PurchaseOrder": { + "orderId": "PO-123", + "vendor": "ABC Corp" + } + } + + # Create request data + data = OutputRequestData( + output_management=output_mgmt, + business_document=business_doc + ) + + # Create output request + request = OutputRequest( + source="/eu12/sap.procurement/tenant-123", + type="com.sap.procurement.purchaseorder.created", + data=data + ) + ``` + """ + + spec_version: str = Field( + default="1.0", + alias="specversion", + description="CloudEvents specification version (should be '1.0')" + ) + + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Unique ID for this event (UUID). Producers must ensure source + id is unique." + ) + + source: str = Field( + ..., + min_length=1, + description="Identifies where this event originated from (e.g., '/eu12/sap.nexus.px/8d4bb3fa')" + ) + + time: str = Field( + default_factory=lambda: datetime.utcnow().isoformat() + "Z", + description="Timestamp when the output request was triggered (ISO 8601 format)" + ) + + type: str = Field( + ..., + min_length=1, + description="Type of event (e.g., 'sap.nexus.px.purchaseorder.PurchaseOrder.Created.v1')" + ) + + data_content_type: str = Field( + default="application/json", + alias="datacontenttype", + description="Content type of the event's data (must be 'application/json')" + ) + + data: OutputRequestData = Field( + ..., + description="Contains OutputManagement and BusinessDocument nodes" + ) + + xsapsisgwdestapp: Optional[str] = Field( + None, + description="SAP system gateway destination application identifier" + ) + + xsapsisgwdestappid: Optional[str] = Field( + None, + description="SAP system gateway destination application ID" + ) + + xsapsisgwbackendid: Optional[str] = Field( + None, + description="SAP system gateway backend ID" + ) + + class Config: + """Pydantic configuration.""" + populate_by_name = True + str_strip_whitespace = True + + +class OutputRequestBuilder: + """ + Builder for constructing OutputRequest objects. + + This builder provides a fluent API for creating OutputRequest instances with proper validation. + """ + + def __init__(self): + """Initialize builder with default values.""" + self._spec_version: str = "1.0" + self._id: Optional[str] = None + self._source: Optional[str] = None + self._time: Optional[str] = None + self._type: Optional[str] = None + self._data_content_type: str = "application/json" + self._data: Optional[OutputRequestData] = None + self._xsapsisgwdestapp: Optional[str] = None + self._xsapsisgwdestappid: Optional[str] = None + self._xsapsisgwbackendid: Optional[str] = None + + def spec_version(self, spec_version: str) -> "OutputRequestBuilder": + """Set CloudEvents specification version.""" + self._spec_version = spec_version + return self + + def id(self, id: str) -> "OutputRequestBuilder": + """Set event ID.""" + self._id = id + return self + + def source(self, source: str) -> "OutputRequestBuilder": + """Set event source.""" + self._source = source + return self + + def time(self, time: str) -> "OutputRequestBuilder": + """Set event timestamp.""" + self._time = time + return self + + def type(self, type: str) -> "OutputRequestBuilder": + """Set event type.""" + self._type = type + return self + + def data_content_type(self, data_content_type: str) -> "OutputRequestBuilder": + """Set data content type.""" + self._data_content_type = data_content_type + return self + + def data(self, data: OutputRequestData) -> "OutputRequestBuilder": + """Set request data.""" + self._data = data + return self + + def xsapsisgwdestapp(self, xsapsisgwdestapp: str) -> "OutputRequestBuilder": + """Set SAP system gateway destination app.""" + self._xsapsisgwdestapp = xsapsisgwdestapp + return self + + def xsapsisgwdestappid(self, xsapsisgwdestappid: str) -> "OutputRequestBuilder": + """Set SAP system gateway destination app ID.""" + self._xsapsisgwdestappid = xsapsisgwdestappid + return self + + def xsapsisgwbackendid(self, xsapsisgwbackendid: str) -> "OutputRequestBuilder": + """Set SAP system gateway backend ID.""" + self._xsapsisgwbackendid = xsapsisgwbackendid + return self + + def build(self) -> OutputRequest: + """ + Build OutputRequest instance. + + Returns: + OutputRequest instance + + Raises: + ValueError: If required fields are missing + """ + if not self._source: + raise ValueError("source is required") + if not self._type: + raise ValueError("type is required") + if not self._data: + raise ValueError("data is required") + + return OutputRequest( + spec_version=self._spec_version, + id=self._id or str(uuid.uuid4()), + source=self._source, + time=self._time or datetime.utcnow().isoformat() + "Z", + type=self._type, + data_content_type=self._data_content_type, + data=self._data, + xsapsisgwdestapp=self._xsapsisgwdestapp, + xsapsisgwdestappid=self._xsapsisgwdestappid, + xsapsisgwbackendid=self._xsapsisgwbackendid, + ) \ No newline at end of file diff --git a/src/sap_cloud_sdk/outputmanagement/models/output_request_data.py b/src/sap_cloud_sdk/outputmanagement/models/output_request_data.py new file mode 100644 index 0000000..24571ff --- /dev/null +++ b/src/sap_cloud_sdk/outputmanagement/models/output_request_data.py @@ -0,0 +1,84 @@ +"""Output Request Data model.""" + +from typing import Any, Dict +from pydantic import BaseModel, Field + +from .output_management_info import OutputManagementInfo + + +class OutputRequestData(BaseModel): + """ + Container for the data payload of an Output Management request. + + This class serves as the envelope for the actual request data, containing two essential components: + - OutputManagement: Metadata and configuration for output orchestration + - BusinessDocument: The actual business document data to be processed + + The business document is stored as a dictionary to provide maximum flexibility + in handling different document structures and types. + + JSON Structure: + ```json + { + "OutputManagement": { + "businessDocumentType": "com.sap.procurement.PurchaseOrder", + "businessDocumentId": "PO-123", + ... + }, + "BusinessDocument": { + "PurchaseOrder": { + "orderId": "PO-123", + "vendor": "ABC Corp", + ... + } + } + } + ``` + + Attributes: + output_management: Information required by Output Management for orchestration (required) + business_document: The business document as a dictionary/JSON object (required) + + Example: + ```python + from sap_cloud_sdk.outputmanagement.models.output_request_data import OutputRequestData + from sap_cloud_sdk.outputmanagement.models.output_management_info import OutputManagementInfo + from sap_cloud_sdk.outputmanagement.constants import Channel + + output_mgmt = OutputManagementInfo( + business_document_type="com.sap.procurement.PurchaseOrder", + business_document_id="PO-123", + channels=[Channel.INTERNAL_EMAIL], + email_configuration=email_config + ) + + business_doc = { + "PurchaseOrder": { + "orderId": "PO-123", + "vendor": "ABC Corp", + "total": 1500.00 + } + } + + data = OutputRequestData( + output_management=output_mgmt, + business_document=business_doc + ) + ``` + """ + + output_management: OutputManagementInfo = Field( + ..., + alias="OutputManagement", + description="Information required by Output Management to orchestrate the output" + ) + + business_document: Dict[str, Any] = Field( + ..., + alias="BusinessDocument", + description="The business document as a JSON object" + ) + + class Config: + """Pydantic configuration.""" + populate_by_name = True \ No newline at end of file diff --git a/src/sap_cloud_sdk/outputmanagement/models/output_response.py b/src/sap_cloud_sdk/outputmanagement/models/output_response.py new file mode 100644 index 0000000..5393355 --- /dev/null +++ b/src/sap_cloud_sdk/outputmanagement/models/output_response.py @@ -0,0 +1,120 @@ +"""Output response model.""" + +from typing import Optional, Dict, Any, List +from pydantic import BaseModel, Field + + + +class ErrorResponse(BaseModel): + """Error response model.""" + + message: str = Field(..., description="Error message") + code: Optional[str] = Field(None, description="Error code") + details: Optional[Dict[str, Any]] = Field(None, description="Additional error details") + + class Config: + """Pydantic configuration.""" + + str_strip_whitespace = True + +class OutputResponse(BaseModel): + """Output response wrapper. + + Response object for Output Management service operations. + Contains the request identifier or error information. + """ + + output_request_id: Optional[str] = Field( + None, + alias="outputRequestId", + description="The unique identifier for the output request" + ) + error: Optional[ErrorResponse] = Field(None, description="Error encountered during processing") + + class Config: + """Pydantic configuration.""" + + populate_by_name = True + +class OutputRequestChannelResponse(BaseModel): + """Output request channel response.""" + + channel: str = Field(..., description="Channel name") + status: str = Field(..., description="Channel status") + error_message: Optional[str] = Field( + None, + alias="errorMessage", + description="Error message if any" + ) + + class Config: + """Pydantic configuration.""" + + populate_by_name = True + +class OutputRequestStatusResponse(BaseModel): + """Output request status response.""" + + request_id: str = Field(..., alias="requestId", description="Request identifier") + business_document_id: Optional[str] = Field( + None, + alias="businessDocumentId", + description="Business document identifier" + ) + business_document_type: Optional[str] = Field( + None, + alias="businessDocumentType", + description="Business document type" + ) + created_at: str = Field(..., alias="createdAt", description="Creation timestamp") + channels: Optional[List[OutputRequestChannelResponse]] = Field( + None, + description="Channel responses" + ) + error_message: Optional[str] = Field( + None, + alias="errorMessage", + description="Error message" + ) + + class Config: + """Pydantic configuration.""" + + populate_by_name = True + + +class DocumentResponse(BaseModel): + """Document response wrapper. + + Contains either the document content or an error response. + """ + + document_content: Optional[bytes] = Field( + None, + alias="documentContent", + description="Binary document content" + ) + error: Optional[ErrorResponse] = Field(None, description="Error response") + + class Config: + """Pydantic configuration.""" + + populate_by_name = True + +class JobStatusResponse(BaseModel): + """Job status response wrapper. + + Contains either the output request status response or an error response. + """ + + output_request_status_response: Optional[OutputRequestStatusResponse] = Field( + None, + alias="outputRequestStatusResponse", + description="Output request status response" + ) + error: Optional[ErrorResponse] = Field(None, description="Error response") + + class Config: + """Pydantic configuration.""" + + populate_by_name = True diff --git a/src/sap_cloud_sdk/outputmanagement/models/pre_generated_attachment.py b/src/sap_cloud_sdk/outputmanagement/models/pre_generated_attachment.py new file mode 100644 index 0000000..f41ff6d --- /dev/null +++ b/src/sap_cloud_sdk/outputmanagement/models/pre_generated_attachment.py @@ -0,0 +1,44 @@ +"""Pre-generated attachment model for email configuration.""" + +from typing import Optional +from pydantic import BaseModel, Field + + +class PreGeneratedAttachment(BaseModel): + """ + Represents a pre-generated attachment to be included in an email. + + Each attachment has a URL pointing to the pre-generated document and an optional + file format descriptor. + + Attributes: + url: URL of the pre-generated attachment document (required) + file_format: File format of the attachment (e.g., 'PDF', 'DOCX') (optional) + + Example: + ```python + from sap_cloud_sdk.outputmanagement.models.pre_generated_attachment import PreGeneratedAttachment + + attachment = PreGeneratedAttachment( + url="https://storage.example.com/documents/po-12345.pdf", + file_format="PDF" + ) + ``` + """ + + url: str = Field( + ..., + min_length=1, + description="URL of the pre-generated attachment document" + ) + + file_format: Optional[str] = Field( + None, + alias="fileFormat", + description="File format of the attachment (e.g., 'PDF', 'DOCX')" + ) + + class Config: + """Pydantic configuration.""" + populate_by_name = True + str_strip_whitespace = True \ No newline at end of file diff --git a/src/sap_cloud_sdk/outputmanagement/utils/__init__.py b/src/sap_cloud_sdk/outputmanagement/utils/__init__.py new file mode 100644 index 0000000..f20e25e --- /dev/null +++ b/src/sap_cloud_sdk/outputmanagement/utils/__init__.py @@ -0,0 +1,5 @@ +"""Utility functions.""" + +from .request_validator import RequestValidator + +__all__ = ["RequestValidator"] diff --git a/src/sap_cloud_sdk/outputmanagement/utils/request_validator.py b/src/sap_cloud_sdk/outputmanagement/utils/request_validator.py new file mode 100644 index 0000000..8d180f8 --- /dev/null +++ b/src/sap_cloud_sdk/outputmanagement/utils/request_validator.py @@ -0,0 +1,315 @@ +"""Request validation utilities. + +This module provides comprehensive validation for Output Management requests including: +- CloudEvents specification compliance +- Business document validation +- Output management configuration validation +- Channel-specific validation + +Author: SAP SE +Version: 1.0.0 +Since: 1.0.0 +""" + +from typing import Optional, List +from ..models.output_request import OutputRequest +from ..models.email_configuration import EmailConfiguration +from ..models.direct_share_configuration import DirectShareConfiguration +from ..constants import Channel + + +class RequestValidator: + """ + Validator utility class for Output Management requests. + + This class provides comprehensive validation for OutputRequest objects including: + - CloudEvents specification compliance + - Business document validation + - Output management configuration validation + - Channel-specific validation (delegates to specific validators) + """ + + def __init__(self): + """Private constructor to prevent instantiation.""" + raise TypeError("This is a utility class and cannot be instantiated") + + @staticmethod + def validate(output_request: OutputRequest) -> Optional[str]: + """ + Validates an OutputRequest according to CloudEvents specification and business requirements. + + This method performs comprehensive validation including: + - CloudEvents specification compliance (source, id, type format) + - Required business document information + - Output management metadata validation + - Channel configuration validation (delegates to channel-specific validators) + + Args: + output_request: The request to validate + + Returns: + Optional error message if validation fails, None if valid + """ + # CloudEvents spec: validate source + source = output_request.source + if not source or not source.strip(): + return "'source' cannot be null or empty" + + # CloudEvents spec: source format should be /region/application/tenant (3 parts separated by /) + source_parts = [part for part in source.split('/') if part] + if len(source_parts) != 3: + return "'source' does not conform to cloud event spec. Expected format: /region/application/tenant" + + # CloudEvents spec: validate id + if not output_request.id or not output_request.id.strip(): + return "'id' cannot be null or empty" + + # CloudEvents spec: validate type + event_type = output_request.type + if not event_type or not event_type.strip(): + return "'type' cannot be null or empty" + + # CloudEvents spec: type format should have at least 4 parts separated by dots + # Example: sap.nexus.px.purchaseorder.PurchaseOrder.Created.v1 + type_parts = event_type.split('.') + if len(type_parts) < 4: + return "'type' does not conform to cloud event spec. Expected format: domain.application.module.event" + + # Validate data payload + data = output_request.data + if data is None: + return "Request data cannot be null" + + # Validate business document + business_document = data.business_document + if business_document is None or not business_document: + return "Business document cannot be null" + + # Validate output management info + output_management = data.output_management + if output_management is None: + return "Output management related parameters not specified" + + # Validate business document ID + business_document_id = output_management.business_document_id + if not business_document_id or not business_document_id.strip(): + return "Business document id cannot be null or empty" + + # Validate business document type. It is required unless DIRECT_SHARE channel is used + business_document_type = output_management.business_document_type + channels = output_management.channels + + if (not business_document_type or not business_document_type.strip()) and \ + channels and len(channels) > 0 and Channel.DIRECT_SHARE not in channels: + return "Business document type cannot be null or empty" + + # Validate channels if present + if channels is not None and len(channels) > 0: + channel_error = RequestValidator._validate_channels(channels) + if channel_error is not None: + return channel_error + + # Validate direct share configuration if DIRECT_SHARE channel is present + has_direct_share = Channel.DIRECT_SHARE in channels + + if has_direct_share: + direct_share_config = output_management.direct_share_configuration + direct_share_error = DirectShareConfigValidator.validate(direct_share_config) + if direct_share_error is not None: + return direct_share_error + + # Validate internal email configuration if EMAIL or INTERNAL_EMAIL channel is present + has_email = Channel.EMAIL in channels or Channel.INTERNAL_EMAIL in channels + + if has_email: + email_config = output_management.email_configuration + email_error = InternalEmailConfigValidator.validate(email_config) + if email_error is not None: + return email_error + + return None + + @staticmethod + def _validate_channels(channels: List[Channel]) -> Optional[str]: + """ + Validates channel configuration. + + Args: + channels: The list of channels to validate + + Returns: + Optional error message if validation fails, None if valid + """ + if channels is None or len(channels) == 0: + return "At least one channel must be specified" + + for channel in channels: + if channel is None: + return "Channel cannot be null" + + return None + + @staticmethod + def validate_job_status_request(output_request_id: str) -> Optional[str]: + """ + Validates job status request parameters. + + Args: + output_request_id: The output request ID to validate + + Returns: + Optional error message if validation fails, None if valid + """ + if not output_request_id or not output_request_id.strip(): + return "OutputRequestId id cannot be null or empty" + return None + + @staticmethod + def validate_get_document_request(channel: str, output_request_id: str) -> Optional[str]: + """ + Validates get document request parameters. + + Args: + channel: The channel name + output_request_id: The output request ID + + Returns: + Optional error message if validation fails, None if valid + """ + if not channel or not channel.strip(): + return "Channel cannot be null or empty" + + if not output_request_id or not output_request_id.strip(): + return "Output Request ID cannot be null or empty" + + return None + + @staticmethod + def validate_email_parameters( + notification_template_key: str, + to: List[str], + business_document: dict, + template_language: str, + cc: Optional[List[str]] = None + ) -> Optional[str]: + """ + Validates email-specific parameters for EmailClient. + + Args: + notification_template_key: ANS template identifier + to: List of recipient email addresses + business_document: The business document dictionary + template_language: ISO language code for email template + cc: Optional list of CC email addresses + + Returns: + Optional error message if validation fails, None if valid + """ + # Validate notification_template_key + if not notification_template_key or not notification_template_key.strip(): + return "notification_template_key cannot be null or empty" + + # Validate recipients list + if not to or len(to) == 0: + return "At least one recipient is required in email configuration" + + # Validate email addresses in recipients list + for recipient in to: + if not recipient or not recipient.strip(): + return "Email recipient cannot be null or empty" + + # Validate CC recipients if present + if cc: + for recipient in cc: + if not recipient or not recipient.strip(): + return "Email CC recipient cannot be null or empty" + + # Validate business_document + if not business_document or len(business_document) == 0: + return "Business document cannot be null" + + # Validate template_language + if not template_language or not template_language.strip(): + return "email_template_language cannot be null or empty" + + return None + + +class DirectShareConfigValidator: + """ + Validator for Direct Share configuration. + + This class provides validation for DirectShareConfiguration objects. + """ + + def __init__(self): + """Private constructor to prevent instantiation.""" + raise TypeError("This is a utility class and cannot be instantiated") + + @staticmethod + def validate(config: Optional[DirectShareConfiguration]) -> Optional[str]: + """ + Validates a DirectShareConfiguration. + + Args: + config: The configuration to validate + + Returns: + Optional error message if validation fails, None if valid + """ + if config is None: + return "Direct share configuration cannot be null when DIRECT_SHARE channel is specified" + + # Add additional direct share specific validations here as needed + # For now, the basic structure validation is done by Pydantic models + + return None + + +class InternalEmailConfigValidator: + """ + Validator for Internal Email configuration. + + This class provides validation for EmailConfiguration objects. + """ + + def __init__(self): + """Private constructor to prevent instantiation.""" + raise TypeError("This is a utility class and cannot be instantiated") + + @staticmethod + def validate(config: Optional[EmailConfiguration]) -> Optional[str]: + """ + Validates an EmailConfiguration. + + Args: + config: The configuration to validate + + Returns: + Optional error message if validation fails, None if valid + """ + if config is None: + return "Email configuration cannot be null when EMAIL channel is specified" + + # Validate recipients + if not config.to or len(config.to) == 0: + return "At least one recipient is required in email configuration" + + # Validate email addresses in recipients list + for recipient in config.to: + if not recipient or not recipient.strip(): + return "Email recipient cannot be null or empty" + + # Validate CC recipients if present + if config.cc: + for recipient in config.cc: + if not recipient or not recipient.strip(): + return "Email CC recipient cannot be null or empty" + + # Validate BCC recipients if present + if config.bcc: + for recipient in config.bcc: + if not recipient or not recipient.strip(): + return "Email BCC recipient cannot be null or empty" + + return None \ No newline at end of file