Source code for smarter.lib.journal.http

# pylint: disable=W0613
"""Smarter API Manifest Abstract Broker class."""

import logging
from http import HTTPStatus
from typing import Optional, Union

from django.http import HttpRequest, JsonResponse

from smarter.common.api import SmarterApiVersions
from smarter.common.mixins import SmarterHelperMixin
from smarter.common.utils import is_authenticated_request
from smarter.lib import json
from smarter.lib.django import waffle
from smarter.lib.django.http.serializers import (
    HttpAnonymousRequestSerializer,
    HttpAuthenticatedRequestSerializer,
)
from smarter.lib.django.waffle import SmarterWaffleSwitches
from smarter.lib.drf.token_authentication import SmarterAnonymousUser
from smarter.lib.json import SmarterJSONEncoder

from .enum import (
    SCLIResponseMetadata,
    SmarterJournalApiResponseErrorKeys,
    SmarterJournalApiResponseKeys,
    SmarterJournalCliCommands,
    SmarterJournalThings,
)
from .models import SAMJournal

logger = logging.getLogger(__name__)


[docs] class SmarterJournaledJsonResponse(JsonResponse, SmarterHelperMixin): """ An enhanced HTTP response class for the Smarter API that augments standard Django JSON responses with additional manifest structure and metadata. This class is designed to provide a consistent response format for all Smarter API endpoints, embedding contextual information about the request and operation performed. It automatically attaches metadata such as the API version, the entity ("thing") being operated on, and the command executed. When journaling is enabled, it also creates a corresponding journal entry in the database, capturing the request, response, user, and status code for audit and traceability. Smarter-specific parameters include the original Django request object, the noun ("thing") being journaled, the command performed, and the API response data. Standard Django JsonResponse parameters such as `data`, `encoder`, `safe`, and `json_dumps_params` are also supported. Example ------- A typical response data structure produced by this class:: { "api": "v1", "thing": "account", "metadata": { "command": "create" } } When journaling is active, the metadata may also include a unique journal key for the entry. See Also -------- :doc:`model` for the database model used to store journal entries. :class:`django.http.JsonResponse` for inherited response behavior. :func:`smarter.common.utils.hash_factory` for key generation. :mod:`smarter.lib.django.http.serializers` for request serialization. """ # pylint: disable=too-many-arguments
[docs] def __init__( self, request: HttpRequest, data, encoder=SmarterJSONEncoder, safe=True, thing: Optional[Union[SmarterJournalThings, str]] = None, command: Optional[SmarterJournalCliCommands] = None, json_dumps_params=None, **kwargs, ): status = kwargs.get("status", HTTPStatus.OK.value) data[SmarterJournalApiResponseKeys.API] = SmarterApiVersions.V1 data[SmarterJournalApiResponseKeys.THING] = str(thing) data[SmarterJournalApiResponseKeys.METADATA] = { SCLIResponseMetadata.COMMAND: str(command), } def anonymous_serialized_request(request) -> dict: """ handles AttributeError: Got AttributeError when attempting to get a value for field `GET` on serializer `HttpAnonymousRequestSerializer`. """ try: return HttpAnonymousRequestSerializer(request).data except AttributeError: url = self.smarter_build_absolute_uri(request) or "Unknown URL" logger.error( "SmarterJournaledJsonResponse() HttpAnonymousRequestSerializer could not serialize request data for %s", url, ) return {} def authenticated_serialized_request(request) -> dict: """ handles the same but for authenticated requests """ try: return HttpAuthenticatedRequestSerializer(request).data except AttributeError: url = self.smarter_build_absolute_uri(request) or "Unknown URL" logger.error( "SmarterJournaledJsonResponse() HttpAuthenticatedRequestSerializer could not serialize request data for %s", url, ) return {} if waffle.switch_is_active(SmarterWaffleSwitches.ENABLE_JOURNAL): # WSGIRequest can be finicky depending on the kind of response we're dealing with. # in general, we only want the user object if it's authenticated, which happens # when the user is logged in to the web console, and also when the request is made # via api, with a valid api key. # # Original exception text was: # 'WSGIRequest' object has no attribute 'user'. # AttributeError: 'PreparedRequest' object has no attribute 'user' try: if is_authenticated_request(request): user = request.user request_data = authenticated_serialized_request(request) else: user = None request_data = anonymous_serialized_request(request) except AttributeError: user = None request_data = anonymous_serialized_request(request) # pylint: disable=broad-except except Exception: logger.warning("Could not determine user from request, and, AttributeError was not raised.") user = None request_data = anonymous_serialized_request(request) try: serializable_data = json.loads(json.dumps(data, cls=SmarterJSONEncoder)) journal = SAMJournal.objects.create( user=user or (SmarterAnonymousUser() if user is None else user), thing=thing, command=command, request=request_data, response=serializable_data, status_code=status, ) data[SmarterJournalApiResponseKeys.METADATA] = { SCLIResponseMetadata.KEY: journal.key, } # pylint: disable=broad-except except Exception as e: logger.error( "user=%s, thing=%s, command=%s, status_code=%s\nrequest=%s\nresponse: %s", user, thing, command, status, request, serializable_data, ) logger.error("SmarterJournaledJsonResponse()__init__() could not create journal entry: %s", e) super().__init__(data=data, encoder=encoder, safe=safe, json_dumps_params=json_dumps_params, **kwargs)
[docs] class SmarterJournaledJsonErrorResponse(SmarterJournaledJsonResponse): """ Enhanced HTTP error response for Smarter CLI commands. This class serializes error information in a structured JSON format consumable by the Smarter CLI, allowing for consistent error formatting and display in the user console. It is the common error response for all CLI commands. :param request: The original Django request object. :type request: django.http.HttpRequest :param e: The Python exception object that was raised. :type e: Exception :param encoder: JSON encoder class. Defaults to ``django.core.serializers.json.SmarterJSONEncoder``. :type encoder: type :param safe: Controls if only ``dict`` objects may be serialized. Defaults to ``True``. :type safe: bool :param thing: The resource or entity being operated on (noun). :type thing: SmarterJournalThings or str, optional :param command: The CLI command executed on the resource. :type command: SmarterJournalCliCommands, optional :param json_dumps_params: Additional kwargs for ``json.dumps()``. :type json_dumps_params: dict, optional :param stack_trace: The stack trace for the exception. :type stack_trace: str, optional :param description: Human-readable error description. :type description: str, optional :param kwargs: Additional keyword arguments passed to the parent class. **Example error response JSON**:: { "error": { "error_class": "ValueError", "stack_trace": "...", "description": "Invalid input", "status": 400, "args": "...", "cause": "...", "context": "thing=account, command=create" } } """ # pylint: disable=too-many-arguments,too-many-locals
[docs] def __init__( self, request: HttpRequest, e: Exception, encoder=SmarterJSONEncoder, safe: bool = True, thing: Optional[Union[SmarterJournalThings, str]] = None, command: Optional[SmarterJournalCliCommands] = None, json_dumps_params: Optional[str] = None, stack_trace: str = "No stack trace available.", description: Optional[str] = None, **kwargs, ): status = kwargs.get("status", None) error_class = e.__class__.__name__ if e else "Unknown Exception" if description is None: if isinstance(e, Exception) and hasattr(e, "message"): description = e.message # type: ignore[union-attr] elif isinstance(e, dict) and hasattr(e, "args"): description = e.args[0] elif isinstance(e, str): description = e url = self.smarter_build_absolute_uri(request) or "Unknown URL" status = str(status) if status else e.status if hasattr(e, "status") else HTTPStatus.INTERNAL_SERVER_ERROR # type: ignore[union-attr] args = e.args if isinstance(e, dict) and hasattr(e, "args") else "url=" + url cause = str(e.__cause__) if isinstance(e, dict) and hasattr(e, "__cause__") else "Python Exception" context = ( str(e.__context__) if isinstance(e, dict) and hasattr(e, "__context__") else "thing=" + str(thing) + ", command=" + str(command) ) data = {} data[SmarterJournalApiResponseKeys.ERROR] = { SmarterJournalApiResponseErrorKeys.ERROR_CLASS: error_class, SmarterJournalApiResponseErrorKeys.STACK_TRACE: stack_trace, SmarterJournalApiResponseErrorKeys.DESCRIPTION: description, SmarterJournalApiResponseErrorKeys.STATUS: status, SmarterJournalApiResponseErrorKeys.ARGS: args, SmarterJournalApiResponseErrorKeys.CAUSE: cause, SmarterJournalApiResponseErrorKeys.CONTEXT: context, } logger.error(data[SmarterJournalApiResponseKeys.ERROR]) super().__init__( request=request, thing=thing, command=command, data=data, encoder=encoder, safe=safe, json_dumps_params=json_dumps_params, **kwargs, )