Coverage for functions \ flipdare \ request \ request_adapter.py: 95%
144 statements
« prev ^ index » next coverage.py v7.13.0, created at 2026-05-08 12:22 +1000
« prev ^ index » next coverage.py v7.13.0, created at 2026-05-08 12:22 +1000
1#!/usr/bin/env python
2# Copyright (c) 2026 Flipdare Pty Ltd. All rights reserved.
3#
4# This file is part of Flipdare's proprietary software and contains
5# confidential and copyrighted material. Unauthorised copying,
6# modification, distribution, or use of this file is strictly
7# prohibited without prior written permission from Flipdare Pty Ltd.
8#
9# This software includes third-party components licensed under MIT,
10# BSD, and Apache 2.0 licences. See THIRD_PARTY_NOTICES for details.
11#
13from abc import ABC
14from dataclasses import dataclass, field, replace
15from typing import Any, ClassVar, Self, cast, override
16import flask
17from firebase_functions import https_fn
18from pydantic import TypeAdapter, ValidationError
20from flipdare.app_log import LOG
21from flipdare.app_types import JsonDict, SchemaDict
22from flipdare.constants import IS_DEBUG, IS_TRACE
23from flipdare.core.proto_unwrapper import ProtoUnwrapper
24from flipdare.error.app_error import AppError
25from flipdare.error.data_load_error import DataLoadError
26from flipdare.error.message_format import ValidationErrorMsgFormat
28from flipdare.request.app_request import AppRequest, AppRequestType
29from flipdare.request.request_types import AppHttpRequest, AppHttpRequestType
30from flipdare.request.request_validator import RequestValidator
32__all__ = ["RequestAdapter", "RequestAdapterError"]
35@dataclass(frozen=True)
36class RequestAdapterError:
37 endpoint: str
38 class_name: type[Any]
39 errors: list[str] = field(default_factory=list)
40 parse_failed: bool = False
42 def add(self, error: str, parse_failed: bool = False) -> Self:
43 return replace(self, errors=[*self.errors, error], parse_failed=parse_failed)
45 @property
46 def user_error_code(self) -> str:
47 from flipdare.message.user_error_code import UserErrorCode
49 ct = len(self.errors) + 1
50 return UserErrorCode.validation(self.class_name, ct, parse_failed=self.parse_failed)
52 def error(self) -> AppError:
53 formatter = ValidationErrorMsgFormat(
54 class_type=self.class_name,
55 error=self.errors,
56 parse_failed=self.parse_failed,
57 )
58 return DataLoadError.malformed(
59 endpoint=self.endpoint,
60 missing_code=formatter.user_error_code,
61 error=self.errors,
62 )
65class RequestAdapter[TSchema: SchemaDict](ABC):
66 _request: AppRequest[AppRequestType]
67 _authenticated_uid: str | None
68 _endpoint: str
69 _params: JsonDict
70 _result: RequestAdapterError | TSchema
72 SCHEMA_CLS: type[TSchema]
73 VALIDATORS: ClassVar[tuple[type[RequestValidator], ...]] = ()
75 def __init__(self, request: AppRequest[AppRequestType]) -> None:
76 endpoint, params = RequestAdapter.request_parts(request.request_type)
77 # Only set authenticated_uid if auth was already attempted
78 self._authenticated_uid = (
79 request.auth_result.user_id if request.auth_result is not None else None
80 )
81 self._request = request
82 self._endpoint = endpoint
83 self._params = params
84 self._validate()
86 @classmethod
87 def from_callable(cls, req: https_fn.CallableRequest[Any]) -> Self:
88 request = AppRequest.callable(req)
89 return cls(request)
91 @classmethod
92 def from_http(cls, req: flask.Request, req_type: AppHttpRequestType) -> Self:
93 request = AppRequest.http(req, req_type)
94 return cls(request)
96 @staticmethod
97 def request_parts(
98 req: https_fn.CallableRequest[JsonDict] | AppHttpRequest,
99 ) -> tuple[str, dict[str, Any]]:
100 if isinstance(req, https_fn.CallableRequest):
101 # Callable requests carry structured data directly; URL is for debugging only.
102 params = req.data
103 # Use .endpoint from the underlying raw request
104 raw = req.raw_request
105 endpoint = raw.endpoint or raw.url or "unknown"
107 if IS_DEBUG:
108 debug_str = "\n\t".join(f"{k}: {v}" for k, v in params.items())
109 msg = f"CallableRequest: endpoint={endpoint}\n\t{debug_str}"
110 LOG().debug(msg)
112 return endpoint, params
113 # AppHttpRequest wraps the raw flask.Request — unwrap it first.
114 raw = req.raw_request
115 endpoint = raw.endpoint or raw.url or "unknown"
116 if raw.method.upper() == "POST":
117 if raw.is_json:
118 params = cast("dict[str, Any]", raw.get_json(silent=True) or {})
119 else:
120 # Fallback to form data, converted to a real dict
121 params = raw.form.to_dict()
122 else:
123 # For GET, raw.args is already parsed. to_dict() makes it a standard dict.
124 params = raw.args.to_dict()
126 if IS_DEBUG:
127 debug_str = "\n\t".join(f"{k}: {v}" for k, v in params.items())
128 msg = f"AppHttpRequest: endpoint={endpoint}, method={raw.method}\n\t{debug_str}"
129 LOG().debug(msg)
131 return endpoint, params
133 @property
134 def request(self) -> AppRequest[AppRequestType]:
135 return self._request
137 @property
138 def endpoint(self) -> str:
139 return self._endpoint
141 @property
142 def ip_address(self) -> str:
143 return self._request.ip_address
145 @property
146 def authenticated_uid(self) -> str | None:
147 return self._authenticated_uid
149 @property
150 def data(self) -> TSchema:
151 result = self._result
152 if isinstance(result, RequestAdapterError):
153 raise result.error()
155 return result
157 @property
158 def params(self) -> JsonDict:
159 return self._params
161 @property
162 def user_error_code(self) -> str | None:
163 if isinstance(self._result, RequestAdapterError):
164 return self._result.user_error_code
165 return None
167 def validation_errors(self) -> list[str] | None:
168 if isinstance(self._result, RequestAdapterError):
169 return self._result.errors
170 return None
172 def validate(self) -> None:
173 result = self._result
174 if isinstance(result, RequestAdapterError):
175 raise result.error()
177 def _validate(self) -> None:
178 parsed = self._parse()
179 errors: list[str] = []
181 if isinstance(parsed, RequestAdapterError):
182 # since we dont have parsed , we cant run validators ..
183 self._result = parsed
184 return
186 for validator_cls in self.VALIDATORS:
187 errors.extend(validator_cls(cast("JsonDict", parsed)).validate())
189 if errors:
190 self._result = RequestAdapterError(
191 endpoint=self._endpoint,
192 class_name=self.SCHEMA_CLS,
193 errors=errors,
194 parse_failed=False,
195 )
196 else:
197 self._result = parsed
199 def _parse(self) -> RequestAdapterError | TSchema:
200 params = self._params
201 try:
202 json = ProtoUnwrapper(params).unwrap()
203 adapter = TypeAdapter(self.SCHEMA_CLS)
204 data = adapter.validate_python(json)
205 if IS_TRACE:
206 msg = f"Successfully parsed request data for endpoint {self.endpoint}: {data}"
207 LOG().trace(msg)
208 return data
209 except ValidationError as e:
210 errors = DataLoadError.parse_error(e)
211 LOG().error(f"Error parsing request data {self.endpoint}: {e}\nERRORS={errors}")
212 return RequestAdapterError(
213 endpoint=self.endpoint,
214 class_name=self.SCHEMA_CLS,
215 errors=errors,
216 parse_failed=True,
217 )
219 @override
220 def __repr__(self) -> str:
221 return (
222 f"{self.__class__.__name__}(endpoint={self.endpoint}, "
223 f"authenticated_uid={self.authenticated_uid}, params={self.params})"
224 )
226 @override
227 def __str__(self) -> str:
228 return self.__repr__()