# pylint: disable=W0613
"""Smarter API Manifest Abstract Broker class."""
from http import HTTPStatus
from typing import Optional, Union
from django.http import HttpRequest, JsonResponse
from smarter.common.helpers.console_helpers import formatted_json, formatted_text
from smarter.common.mixins import SmarterHelperMixin
from smarter.common.utils import is_authenticated_request
from smarter.lib import json, logging
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.json import SmarterJSONEncoder
from smarter.lib.typing import SmarterDictType
from .contract.models import (
SAMJournalApiEnvelope,
SAMJournalApiErrorBody,
SAMJournalApiErrorResponse,
SAMJournalApiMetadata,
)
from .enum import SmarterJournalCliCommands, SmarterJournalThings
from .models import SAMJournal
from .signals import journal_entry_created, journal_entry_failed
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.
:class:`smarter.lib.journal.contract.models.SAMJournalApiEnvelope`, the contract of the ``api`` and ``metadata`` keys.
"""
# 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,
status: int = HTTPStatus.OK.value,
**kwargs,
):
if not isinstance(data, (dict, list)):
# wrap any other data, e.g. a string, before adding the api version and metadata to it.
data = {"response": data}
envelope = SAMJournalApiEnvelope.build(command=command, thing=thing)
if isinstance(data, dict):
data.update(envelope.dump())
logger_prefix = formatted_text(f"{__name__}.{self.formatted_class_name}.__init__()")
def anonymous_serialized_request(request) -> SmarterDictType:
"""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(
"%s HttpAnonymousRequestSerializer could not serialize request data for %s",
logger_prefix,
url,
)
return {}
def authenticated_serialized_request(request) -> SmarterDictType:
"""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(
"%s HttpAuthenticatedRequestSerializer could not serialize request data for %s",
logger_prefix,
url,
)
return {}
if waffle.switch_is_active(SmarterWaffleSwitches.ENABLE_JOURNAL):
# ASGIRequest 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:
# 'ASGIRequest' 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)
serializable_data = None
try:
serializable_data = json.loads(json.dumps(data, cls=SmarterJSONEncoder))
journal = SAMJournal.objects.create(
# SAMJournal.user is a User, or None for an anonymous request.
user=user if user and getattr(user, "is_authenticated", False) else None,
thing=thing,
command=command,
request=request_data,
response=serializable_data,
status_code=status,
)
journal_entry_created.send(sender=self.__class__, journal=journal)
if isinstance(data, dict):
# add the journal's key to the metadata, which keeps its command and thing.
envelope.metadata = SAMJournalApiMetadata(
command=envelope.metadata.command, thing=envelope.metadata.thing, key=journal.key
)
data.update(envelope.dump())
# pylint: disable=broad-except
except Exception as e:
# logged by smarter.lib.journal.receivers.journal_entry_failed_receiver().
journal_entry_failed.send(
sender=self.__class__,
user=user,
thing=str(thing),
command=str(command),
status=status,
request=request,
response=serializable_data,
error=e,
)
# Only pass allowed kwargs to JsonResponse
allowed_kwargs = {}
allowed_keys = {"content_type", "status", "headers", "reason"}
for k in list(kwargs.keys()):
if k in allowed_keys:
allowed_kwargs[k] = kwargs.pop(k)
super().__init__(
data=data,
encoder=encoder,
safe=safe and isinstance(data, dict),
json_dumps_params=json_dumps_params,
status=status,
**allowed_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 include_stack_trace: Whether the response includes ``stack_trace``. Defaults to ``True``.
Responses that a browser may receive, e.g. those of the Smarter Chat apis, pass ``False``:
the stack trace is still logged, but is never sent to the client.
:type include_stack_trace: bool, 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: Optional[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,
status: int = HTTPStatus.INTERNAL_SERVER_ERROR.value,
include_stack_trace: bool = True,
**kwargs,
):
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 = status or HTTPStatus.INTERNAL_SERVER_ERROR
args = e.args if isinstance(e, dict) and hasattr(e, "args") else "url=" + url
cause = (
f"{type(e.__cause__).__name__}: {e.__cause__}"
if isinstance(e, BaseException) and e.__cause__ is not None
else "Python Exception"
)
context = (
str(e.__context__)
if isinstance(e, dict) and hasattr(e, "__context__")
else "thing=" + str(thing) + ", command=" + str(command)
)
error_response = SAMJournalApiErrorResponse(
error=SAMJournalApiErrorBody(
errorClass=error_class,
stacktrace=stack_trace,
description=description if description is None else str(description),
status=status,
args=json.loads(json.dumps(args, cls=SmarterJSONEncoder)),
cause=cause,
context=context,
thing=str(thing),
command=str(command),
)
)
logger_prefix = formatted_text(f"{__name__}.{self.formatted_class_name}.__init__()")
logger.error("%s %s", logger_prefix, formatted_json(error_response.dump()))
# the stack trace is logged above, but, when include_stack_trace is False, never sent to the client.
data = error_response.dump(include_stack_trace=include_stack_trace)
super().__init__(
request=request,
thing=thing,
command=command,
data=data,
encoder=encoder,
description=description,
safe=safe,
json_dumps_params=json_dumps_params,
status=status,
**kwargs,
)