"""GraphQL Connector.""" import re from logging import Logger import httpx from owsclient import M2MTokenManager, OwsClient from content_utils.connectors.system_user import SystemUser from content_utils.constants.graphql import ADD_PRODUCT_MUTATION from content_utils.constants.graphql import BASE_PRODUCT_QUERY from content_utils.constants.graphql import COMPLETE_PRODUCT_QUERY from content_utils.constants.graphql import GENRE_QUERY from content_utils.constants.graphql import INDEXABLE_PRODUCT_QUERY from content_utils.constants.graphql import META_LANGUAGE_QUERY from content_utils.constants.graphql import REVIEW_QUEUE_ITEM_QUERY from content_utils.constants.product import ERROR_CORRECTIONS_SUBMITTED_STATUS from content_utils.constants.product import PRODUCT_CONFIGURATION_DIGITAL_AUDIO from content_utils.exceptions import GatewayTimeoutException from content_utils.exceptions import GraphQLError from content_utils.exceptions import InvalidProductException from content_utils.exceptions import NoProductDataException from content_utils.exceptions import NoReviewQueueItemDataException from content_utils.exceptions import SoundRecordingsException from content_utils.utils.product_metadata import get_base_product from content_utils.utils.product_metadata import get_complete_product_document from content_utils.utils.product_metadata import get_indexable_document from content_utils.utils.product_metadata import format_product_corrections class LambdaGraphQLConnector: """GraphQL Connector. Built using OwsClient Attributes: graphql_service_name: Name of the GraphQL service. ows_client: OwsClient instance to interact with GraphQL service. user: SystemUser instance representing the user context. headers: Headers to include in GraphQL requests. validation_context: Context for validation, either 'PRE_SUBMISSION' or 'POST_SUBMISSION'. logger: Logger instance for logging. """ graphql_service_name: str ows_client: OwsClient user: SystemUser headers: dict | None = None validation_context: str = 'PRE_SUBMISSION' correlation_id: str | None = None logger: Logger def __init__( self, application_name, environment, logger, graphql_service_name: str = 'graphql-router', m2m_token_manager: M2MTokenManager = None, user: SystemUser = None, correlation_id: str | None = None, raise_on_error=False ): """Graphql connector. Args: application_name: Name of the application using the connector. environment: Environment in which the connector operates. logger: Logger instance for logging. graphql_service_name: Name of the GraphQL service. m2m_token_manager: M2MTokenManager instance for authentication. user: SystemUser instance representing the user context. correlation_id: Correlation ID for tracking requests. raise_on_error: Whether to raise exceptions on GraphQL errors. """ self.graphql_service_name = graphql_service_name self.ows_client = OwsClient( service_name=application_name, environment=environment, m2m_token_manager=m2m_token_manager ) self.user = user if user is not None else SystemUser() self.correlation_id = correlation_id self.headers = {'Orchard-Roles': self.user.roles} if self.user.profile_type == 'ContentProfile': self.validation_context = 'POST_SUBMISSION' self.raise_on_error = raise_on_error self.logger = logger def execute(self, query, params): """Execute graphql query.""" response = self.ows_client.graphql_query( service_name=self.graphql_service_name, query=query, variables=params, identity_id=self.user.identity_id, profile_id=self.user.profile_id, profile_type=self.user.profile_type, correlation_id=self.correlation_id, headers=self.headers, timeout=httpx.Timeout(60) ) self.logger.debug(response) response_data = response.json() errors = response_data.get('errors') if self.raise_on_error and errors: raise GraphQLError(errors) return response_data def get_language_for_code(self, code): """Fetch the meta language name that matches the code.""" languages = self.execute(META_LANGUAGE_QUERY, {}) for language in languages.get('data').get('metaLanguages'): if language['code'] == code: return language['name'] return '' def get_indexable_product(self, row_data): """Fetch formatted product for indexing by review_queue entry.""" product_id = str(row_data.get('product_id')) review_queue_id = row_data.get('review_queue_id') self.logger.debug( 'getting validation with {} context'.format(self.validation_context)) product = self._fetch_product_data(INDEXABLE_PRODUCT_QUERY, { 'productId': product_id, 'validationContext': self.validation_context, 'showOnDemand': True }) review_queue_item = self.fetch_review_queue_item_data(review_queue_id) indexable_product = get_indexable_document(row_data, product, review_queue_item) self.logger.debug(indexable_product) corrections = product.get('releaseCorrection') if corrections is not None and \ corrections.get('status') == ERROR_CORRECTIONS_SUBMITTED_STATUS: indexable_corrections = format_product_corrections( corrections.get('items')) self.logger.debug(indexable_corrections) # genre name is not provided in corrections if 'genre_id' in indexable_corrections: genre_data = self.get_genre_by_id( indexable_corrections['genre_id']) if genre_data: indexable_corrections['genre_name'] = genre_data.get('name') # language name is not provided in corrections if 'metadata_language_code' in indexable_corrections: name = self.get_language_for_code( indexable_corrections['metadata_language_code']) indexable_corrections['metadata_language_name'] = name indexable_product.update(indexable_corrections) return indexable_product def get_base_product(self, row_data): """Get basic product data for evaluating queue inclusion.""" product = self._fetch_product_data(BASE_PRODUCT_QUERY, { 'productId': str(row_data.get('product_id')), 'showOnDemand': True }) return get_base_product(row_data, product) def get_complete_product(self, product_id): """Get complete product data including all metadata.""" product = self._fetch_product_data(COMPLETE_PRODUCT_QUERY, { 'productId': str(product_id), 'validationContext': self.validation_context }) return get_complete_product_document(product) def _fetch_product_data(self, query, args): """Fetch product data.""" try: result = self.execute(query, args) except GraphQLError as e: self.parse_graphql_errors_and_raise_exception(e) raise e product = result.get('data').get('product') if not product: raise NoProductDataException( 'Invalid product: no data found ' f'for product {args.get("productId")}') product_configuration = product.get('productConfiguration') if product_configuration != PRODUCT_CONFIGURATION_DIGITAL_AUDIO: raise InvalidProductException( f'Invalid product configuration "{product_configuration}" ' f'for product {product.get("product_id")}') return product def fetch_review_queue_item_data(self, review_queue_id): """Fetch review queue item data.""" try: result = self.execute( REVIEW_QUEUE_ITEM_QUERY, {'reviewQueueId': review_queue_id} ) review_queue_item = result.get('data').get('reviewQueueItem') except GraphQLError as e: if self.is_only_review_queue_item_not_found_error(e): review_queue_item = None else: raise e if not review_queue_item: raise NoReviewQueueItemDataException( 'Invalid review queue item: no data found ' f'for review queue item {review_queue_id}') return review_queue_item def parse_graphql_errors_and_raise_exception(self, graphql_error): """Parse graphql errors and raise exception.""" if ( len(graphql_error.args) and len(graphql_error.args[0]) and isinstance( graphql_error.args[0][0], dict) ): msg = graphql_error.args[0][0].get('message') status = graphql_error.args[0][0].get('status') if msg == 'Failed to query ows-sound-recordings for track validations.': raise SoundRecordingsException(msg) if status == 504: raise GatewayTimeoutException(msg) else: pattern = re.compile(r'^Product configuration .+ is unsupported') if pattern.match(msg) is not None: raise InvalidProductException(msg) return def is_only_review_queue_item_not_found_error(self, graphql_error): """Validate if graphql only raised because of a missing review queue item.""" if not graphql_error.args: return False if len(graphql_error.args) != 1: return False if len(graphql_error.args[0]) != 1: return False if not isinstance(graphql_error.args[0][0], dict): return False return graphql_error.args[0][0].get('message') == \ 'Cannot return null for non-nullable field Query.reviewQueueItem.' def execute_add_to_queue_mutation(self, product_id, identity_id=None, submission_datetime=None): """Add a product to the review queue.""" create_result = self.execute( ADD_PRODUCT_MUTATION, { 'productId': product_id, 'identityId': identity_id, 'submissionDatetime': submission_datetime } ) errors = create_result.get('errors') if errors: for err in errors: # If the error is that the product exists, # raise an invalid product err if isinstance(err, dict) and 'extensions' in err: body = err.get('extensions')\ .get('response', {})\ .get('body', {}) # Check that the body is an object with a message if body and 'message' in body: if body.get('message') == f'Product {product_id} already exists in queue': raise InvalidProductException(body.get('message')) raise GraphQLError(errors) return create_result.get('data') def get_genre_by_id(self, genre_id): """Get genre name.""" try: result = self.execute( GENRE_QUERY, {'genreId': genre_id} ) genre_items = result.get('data').get('genres') genre = genre_items[0] if len(genre_items) >= 1 else None except GraphQLError as e: raise e return genre