Source code for luna_api.app.handlers.base_handler

# -*- coding: utf-8 -*-
""" Base handler

Module realize base class for all handlers.
"""
from abc import abstractmethod
from copy import deepcopy
from typing import Any, Dict, NamedTuple, Tuple, Union

import msgpack
import ujson
from luna3.client import Client
from luna3.common.luna_response import LunaResponse
from luna3.common.requests import makeRequest
from multidict import CIMultiDict, CIMultiDictProxy
from sanic.response import HTTPResponse
from stringcase import snakecase
from vlutils.recursive_functions import goDeep

from app.app import ApiApp, ApiRequest
from app.global_vars.context_vars import requestIdCtx
from app.handlers.schemas import schemas
from classes.enums import VisibilityArea
from configs.configs.configs.services import SettingsApi
from configs.configs.configs.settings.classes import (
    ImageStoreAddressSettings,
    ServiceAddressSettings,
    ServiceTimeoutsSettings,
)
from crutches_on_wheels.errors.errors import Error
from crutches_on_wheels.errors.exception import VLException
from crutches_on_wheels.web.base_proxy_handler_class import SessionPool
from crutches_on_wheels.web.handlers import BaseHandler

FACE_SAMPLE_RELATIVE_URL = "/{apiVersion}/samples/faces/{sampleId}"
BODY_SAMPLE_RELATIVE_URL = "/{apiVersion}/samples/bodies/{sampleId}"
ATTRIBUTE_RELATIVE_URL = "/{apiVersion}/attributes/{attributeId}"
EVENT_RELATIVE_URL = "/{apiVersion}/events/{eventId}"
FACE_RELATIVE_URL = "/{apiVersion}/faces/{faceId}"
HEADERS_WHITE_LIST = {
    "content-type",
    "luna-request-id",
    "content-disposition",
    "luna-event-time",
    "luna-event-end-time",
    "accept",
}


[docs]class ProxyRequest(NamedTuple): """ Proxy request """ body: Any # body for sending to a proxed service headers: Union[Dict[str, str], CIMultiDictProxy[str]] # headers for sending to a proxed service query: Dict[str, Union[None, str]] # query for sending to a proxed service
[docs]class BaseRequestHandler(BaseHandler): """ Base handler for other handlers. Attributes: luna3Client (luna3.client.Client): luna3 client accountId (str): account id """ # IDE type hint stub request: ApiRequest def __init__(self, request: ApiRequest): super().__init__(request) self.accountId = request.credentials.accountId self.addDataForMonitoring(tags={"account_id": self.accountId}) requestIdCtx.set(self.requestId) self.luna3Client: Client = self.app.ctx.luna3session.getClient(self.requestId) @property def app(self) -> ApiApp: """ Get running app Returns: app """ return self.request.app @property def config(self) -> SettingsApi: """ Get app config Returns: app config """ return self.app.ctx.serviceConfig
[docs] @staticmethod def filterAllowedHeaders(headers: CIMultiDict[str, str]) -> dict: """ Filter only allowed headers. Args: headers: headers to filter Returns: filtered headers """ return {k: v for k, v in headers.items() if k.lower() in HEADERS_WHITE_LIST}
[docs] async def options(self, *args, **kwargs) -> HTTPResponse: """ Default 'options' method """ allowHeaders = {"Allow": ", ".join(self.getAllowedMethods())} return self.success(body=None, extraHeaders=allowHeaders)
[docs]class BaseProxyHandler(BaseRequestHandler): """ Base proxy handler for other handlers. """ # list of allowed proxy methods allowedMethods: Tuple[str, ...] = () # session pool session = SessionPool() @property def serviceAddress(self) -> ServiceAddressSettings: """ Get a proxed service address Returns: a proxed service address """ raise NotImplemented @property def serviceTimeouts(self) -> ServiceTimeoutsSettings: """ Get a proxed service timeouts Returns: a proxed service timeouts """ raise NotImplemented @property def serviceUrl(self) -> str: """Service url""" return f"{self.serviceAddress.origin}/{self.serviceAddress.apiVersion}"
[docs] def prepareRequestDefault(self) -> ProxyRequest: """ Prepare proxy request by default Returns: default proxy request """ return ProxyRequest(self.request.body, self.prepareHeaders(), self.prepareQuery())
[docs] async def prepareRequestPost(self): """ Prepare proxy request for method `post` Returns: proxy request """ return self.prepareRequestDefault()
[docs] async def prepareRequestPatch(self): """ Prepare proxy request for method `patch` Returns: proxy request """ return self.prepareRequestDefault()
[docs] async def prepareRequestDelete(self): """ Prepare proxy request for method `delete` Returns: proxy request """ return self.prepareRequestDefault()
[docs] async def prepareRequestPut(self): """ Prepare proxy request for method `put` Returns: proxy request """ return self.prepareRequestDefault()
[docs] async def prepareProxyRequest(self) -> ProxyRequest: """ Prepare proxy request Returns: proxy request """ if self.request.method in ("GET", "OPTIONS", "HEAD"): return ProxyRequest(b"", self.prepareHeaders(), self.prepareQuery()) elif self.request.method in ("POST", "PUT", "DELETE", "PATCH"): return await getattr(self, f"prepareRequest{self.request.method.capitalize()}")() raise RuntimeError(f"bad request method {self.request.method}")
[docs] def postProcessingDefault(self, response: LunaResponse) -> HTTPResponse: """ Default post processing response from the service Args: response: response Returns: response in api format """ headers = self.filterAllowedHeaders(response.headers) return self.success(response.statusCode, body=response.body, extraHeaders=headers)
[docs] async def postProcessingPost(self, response: LunaResponse) -> HTTPResponse: """ Default post processing response from the service Args: response: response Returns: response in api format """ return self.postProcessingDefault(response)
[docs] async def postProcessingGet(self, response: LunaResponse) -> HTTPResponse: """ Post processing response from the service for method `get` Args: response: response Returns: response in api format """ return self.postProcessingDefault(response)
[docs] async def postProcessingPatch(self, response: LunaResponse) -> HTTPResponse: """ Post processing response from the service for method `patch` Args: response: response Returns: response in api format """ return self.postProcessingDefault(response)
[docs] async def postProcessingDelete(self, response: LunaResponse) -> HTTPResponse: """ Post processing response from the service for method `delete` Args: response: response Returns: response in api format """ return self.postProcessingDefault(response)
[docs] async def postProcessingPut(self, response: LunaResponse) -> HTTPResponse: """ Post processing response from the service for method `put` Args: response: response Returns: response in api format """ return self.postProcessingDefault(response)
[docs] async def postProcessingOptions(self, response: LunaResponse) -> HTTPResponse: """ Post processing response from the service for method `post` Args: response: response Returns: response in api format """ return self.postProcessingDefault(response)
[docs] async def postProcessingHead(self, response: LunaResponse) -> HTTPResponse: """ Post processing response from the service for method `head` Args: response: response Returns: response in api format """ return self.postProcessingDefault(response)
[docs] async def postProcessing(self, response: LunaResponse) -> HTTPResponse: """ Post processing response Args: response: response Returns: response in api format """ return await getattr(self, f"postProcessing{self.request.method.capitalize()}")(response)
[docs] def prepareRequestCreation(self) -> ProxyRequest: """ Add account id to root json for creating some objects Returns: proxy request Raises: VLException(Error.BadContentType): if request has incorrect content type """ headers = self.prepareHeaders() contentType = self.request.content_type if contentType == "application/json": inputJson = self.request.json elif contentType == "application/octet-stream": inputJson = {} headers["Content-Type"] = "application/json" elif contentType == "application/msgpack": inputJson = self.request.json headers["Content-Type"] = "application/msgpack" else: raise VLException(Error.BadContentType, statusCode=400, isCriticalError=False) self.validateJson(inputJson, schemas.OBJECT_SCHEMA) if contentType == "application/msgpack": body = msgpack.packb(inputJson, use_bin_type=True) else: body = ujson.dumps(inputJson, ensure_ascii=False) return ProxyRequest(body, headers, self.prepareQuery())
[docs] def prepareQuery(self) -> Dict[str, str]: """ Prepare headers for a proxy request """ return dict(self.request.args)
[docs] def prepareHeaders(self) -> Dict[str, str]: """ Prepare headers for a proxy request Returns: headers with `Luna-Request-Id` and other input headers """ headers = self.filterAllowedHeaders(self.request.headers) headers["Luna-Request-Id"] = self.requestId headers["Accept-Encoding"] = "identity, deflate, gzip" if (accountId := self.request.credentials.accountId) is not None: headers["Luna-Account-Id"] = accountId return headers
[docs] def convertIncomingUrls(self, response: LunaResponse) -> Dict[str, str]: """ Convert service URLs to output JSON and update Location in handler Returns: output JSON with converted service URLs """ outputJson = deepcopy(response.json) location = outputJson["url"].replace(f"/{self.serviceAddress.apiVersion}", f"/{self.app.ctx.apiVersion}", 1) self.respHeaders["Location"] = location outputJson["url"] = location return outputJson
[docs] def prepareUrl(self) -> str: """ Prepare url to a service Returns: same url with correct api version """ uriWithoutApiVersion = self.request.server_path.replace(f"/{self.app.ctx.apiVersion}", "", 1) return f"{self.serviceUrl}{uriWithoutApiVersion}"
[docs] def checkTokenPermissionsDefault(self, objectName: str) -> None: """ Check token permissions for request using default method to permission map """ permissions = self.request.credentials.permissions if permissions is None: return objectPermissions = permissions.__getattribute__(objectName) action = { "POST": "creation", "GET": "view", "HEAD": "view", "PUT": "modification", "PATCH": "modification", "DELETE": "deletion", }.get(self.request.method) if action is not None and action not in objectPermissions: raise VLException(Error.ForbiddenByToken.format(action, snakecase(objectName)), 403, False)
[docs] @abstractmethod def checkTokenPermissions(self) -> None: """ Check token permissions for request """
[docs] async def makeRequestToService(self) -> HTTPResponse: """ Make request to custom service Returns: response """ if self.request.method not in self.allowedMethods: return self.setMethodNotAllowed() self.checkTokenPermissions() url = self.prepareUrl() preparedRequest = await self.prepareProxyRequest() kwargs = dict( url=url, method=self.request.method, queryParams=preparedRequest.query, headers=preparedRequest.headers, asyncRequest=True, totalTimeout=self.serviceTimeouts.totalTimeout, connectTimeout=self.serviceTimeouts.connectTimeout, sockConnectTimeout=self.serviceTimeouts.sockConnectTimeout, sockReadTimeout=self.serviceTimeouts.sockReadTimeout, session=self.session, ) reply = await makeRequest(body=preparedRequest.body, **kwargs) if reply.success: return await self.postProcessing(reply) return self.postProcessingDefault(reply)
[docs] async def post(self, *args, **kwargs) -> HTTPResponse: """ Default 'post' method Returns: proxy response """ return await self.makeRequestToService()
[docs] async def put(self, *args, **kwargs) -> HTTPResponse: """ Default 'put' method Returns: proxy response """ return await self.makeRequestToService()
[docs] async def get(self, *args, **kwargs) -> HTTPResponse: """ Default 'get' method Returns: proxy response """ return await self.makeRequestToService()
[docs] async def delete(self, *args, **kwargs) -> HTTPResponse: """ Default 'delete' method Returns: proxy response """ return await self.makeRequestToService()
[docs] async def patch(self, *args, **kwargs) -> HTTPResponse: """ Default 'patch' method Returns: proxy response """ return await self.makeRequestToService()
[docs] async def head(self, *args, **kwargs): """ Default 'head' method Returns: proxy response """ return await self.makeRequestToService()
[docs]class EventServiceBaseHandler(BaseProxyHandler): """ Class for proxy handlers to events """ @property def serviceAddress(self) -> ServiceAddressSettings: """ Get a events service address Returns: a events service address """ return self.config.eventsAddress @property def serviceTimeouts(self) -> ServiceTimeoutsSettings: """ Get a events service timeouts Returns: a events service timeouts """ return self.config.eventsTimeouts def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if not self.config.additionalServicesUsage.lunaEvents: raise VLException(Error.LunaEventsIsDisabled, statusCode=403, isCriticalError=False)
[docs]class FacesServiceBaseHandler(BaseProxyHandler): """ Class for proxy handlers to faces """ @property def serviceAddress(self) -> ServiceAddressSettings: """ Get a faces service address Returns: a faces service address """ return self.config.facesAddress @property def serviceTimeouts(self) -> ServiceTimeoutsSettings: """ Get a faces service timeouts Returns: a faces service timeouts """ return self.config.facesTimeouts
[docs]class HandlersServiceBaseHandler(BaseProxyHandler): """ Class for proxy handlers to handlers """ @property def serviceAddress(self) -> ServiceAddressSettings: """ Get a handlers service address Returns: a handlers service address """ return self.config.handlersAddress @property def serviceTimeouts(self) -> ServiceTimeoutsSettings: """ Get a handlers service timeouts Returns: a handlers service timeouts """ return self.config.handlersTimeouts
[docs] def convertAttributeUrls(self, replyJson: dict) -> dict: """ Convert attribute URLs in output JSON Args: replyJson: JSON Returns: output JSON with converted attribute URLs """ outputJson = deepcopy(replyJson) for attribute in outputJson: if attribute["attribute_id"]: attribute["url"] = ATTRIBUTE_RELATIVE_URL.format( apiVersion=self.app.ctx.apiVersion, attributeId=attribute["attribute_id"], ) return outputJson
[docs] def convertVerifierUrls(self, replyJson: dict) -> dict: """ Convert attribute and sample URLs in output JSON Args: replyJson: JSON Returns: output JSON with converted attribute URLs """ outputJson = deepcopy(replyJson) for image in outputJson["images"]: for detection in image["detections"]["face_detections"]: attributeId = detection["face_attributes"]["attribute_id"] if attributeId is not None: detection["face_attributes"]["url"] = ATTRIBUTE_RELATIVE_URL.format( apiVersion=self.app.ctx.apiVersion, attributeId=attributeId, ) if (sample := detection.get("sample")) is not None: if sample["face"]["sample_id"] is not None: detection["sample"]["face"]["url"] = FACE_SAMPLE_RELATIVE_URL.format( apiVersion=self.app.ctx.apiVersion, sampleId=sample["face"]["sample_id"], ) return outputJson
[docs] def convertDetectorSampleUrls(self, replyJson: dict) -> dict: """ Convert detections sample URLs in output JSON Args: replyJson: JSON Returns: output JSON with converted sample URLs """ outputJson = deepcopy(replyJson) for image in outputJson["images"]: for sample in image["detections"]["samples"]: sample["face"]["url"] = FACE_SAMPLE_RELATIVE_URL.format( apiVersion=self.app.ctx.apiVersion, sampleId=sample["face"]["sample_id"], ) return outputJson
[docs] def convertHandlerEventsUrls(self, replyJson: dict) -> dict: """ Convert events/faces/detections samples URLs in output JSON Args: replyJson: JSON Returns: output JSON with converted sample URLs """ outputJson = deepcopy(replyJson) for event in outputJson["events"]: if event["url"] is not None: event["url"] = EVENT_RELATIVE_URL.format(apiVersion=self.app.ctx.apiVersion, eventId=event["event_id"]) if event["face"] is not None: event["face"]["url"] = FACE_RELATIVE_URL.format( apiVersion=self.app.ctx.apiVersion, faceId=event["face"]["face_id"] ) if event["face_attributes"] is not None and event["face_attributes"]["url"] is not None: event["face_attributes"]["url"] = ATTRIBUTE_RELATIVE_URL.format( apiVersion=self.app.ctx.apiVersion, attributeId=event["face_attributes"]["attribute_id"], ) for detection in event["detections"]: samples = detection["samples"] if samples["face"] and samples["face"]["url"]: samples["face"]["url"] = FACE_SAMPLE_RELATIVE_URL.format( apiVersion=self.app.ctx.apiVersion, sampleId=samples["face"]["sample_id"], ) if samples["body"] and samples["body"]["url"]: samples["body"]["url"] = BODY_SAMPLE_RELATIVE_URL.format( apiVersion=self.app.ctx.apiVersion, sampleId=samples["body"]["sample_id"], ) return outputJson
[docs] def updatePolicies(self) -> None: """ Update policies: - if visibility area is limited - update matching candidates account id """ if self.request.credentials.visibilityArea == VisibilityArea.account and isinstance( (matchPolicies := goDeep(self.request.json, ["policies", "match_policy"])), list ): for matchPolicy in matchPolicies: if isinstance((candidates := goDeep(matchPolicy, ["candidates"])), dict): accountId = candidates.get("account_id") if accountId is None: candidates["account_id"] = self.request.credentials.accountId continue if accountId != self.request.credentials.accountId: raise VLException( Error.ForbiddenVisibilityAreaDenied.format("handler matching policy candidates"), 403, False )
[docs]class TasksServiceBaseHandler(BaseProxyHandler): """ Class for proxy handlers to tasks """ @property def serviceAddress(self) -> ServiceAddressSettings: """ Get a tasks service address Returns: a tasks service address """ return self.config.tasksAddress @property def serviceTimeouts(self) -> ServiceTimeoutsSettings: """ Get a tasks service timeouts Returns: a tasks service timeouts """ return self.config.tasksTimeouts def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) if not self.config.additionalServicesUsage.lunaTasks: raise VLException(Error.LunaTasksIsDisabled, statusCode=403, isCriticalError=False)
[docs] def checkTokenPermissions(self) -> None: """ Description see :func:`~BaseRequestHandler.checkTokenPermissions`. """ self.checkTokenPermissionsDefault(objectName="task")
[docs]class PythonMatcherServiceBaseHandler(BaseProxyHandler): """ Class for proxy handlers to python-matcher/python-matcher-proxy """ @property def serviceAddress(self) -> ServiceAddressSettings: """ Get a python matcher service address Returns: a python matcher service address """ return ( self.config.matcherProxyAddress if self.config.additionalServicesUsage.lunaMatcherProxy else self.config.pythonMatcherAddress ) @property def serviceTimeouts(self) -> ServiceTimeoutsSettings: """ Get a python matcher service timeouts Returns: a python matcher service timeouts """ return ( self.config.matcherProxyTimeouts if self.config.additionalServicesUsage.lunaMatcherProxy else self.config.pythonMatcherTimeouts )
[docs]class BaseFaceSampleProxyHandler(BaseProxyHandler): """ Base face sample proxy. """ @property def serviceAddress(self) -> ImageStoreAddressSettings: """ Get a image store service address Returns: a image store service address """ return self.config.faceSamplesStorage @property def serviceTimeouts(self) -> ServiceTimeoutsSettings: """ Get a image store service timeouts Returns: a image store service timeouts """ return self.config.faceSamplesStorageTimeouts
[docs]class BaseBodySampleProxyHandler(BaseProxyHandler): """ Base body sample proxy. """ @property def serviceAddress(self) -> ImageStoreAddressSettings: """ Get a image store service address Returns: a image store service address """ return self.config.bodySamplesStorage @property def serviceTimeouts(self) -> ServiceTimeoutsSettings: """ Get a image store service timeouts Returns: a image store service timeouts """ return self.config.bodySamplesStorageTimeouts
[docs]class AccountsBaseHandler(BaseProxyHandler): """ Class for proxy handlers to accounts """ @property def serviceAddress(self) -> ServiceAddressSettings: """ Get a faces service address Returns: a faces service address """ return self.config.accountsAddress @property def serviceTimeouts(self) -> ServiceTimeoutsSettings: """ Get a faces service timeouts Returns: a faces service timeouts """ return self.config.accountsTimeouts
[docs] def checkTokenPermissions(self) -> None: """ Description see :func:`~BaseRequestHandler.checkTokenPermissions`. """
[docs] def checkAccountType(self) -> None: """ Check account type in specified json is allowed Raises: VLException(Error.BadInputJson.format("", accountType)) if `account_type` is not allowed """ if self.request.json: self.validateJson(self.request.json, schemas.OBJECT_SCHEMA) accountType = self.request.json.get("account_type") if accountType is not None and accountType not in ("advanced_user", "user"): raise VLException( Error.BadInputJson.format( "account_type", "value is not a valid enumeration member; permitted: 'advanced_user', 'user'" ), 400, False, )