Skip to content

Commit a15fa56

Browse files
committed
feat: add request handler module for http request management
- Authentication via API key - Support for JSON and non-JSON responses - Custom error handling for various HTTP status codes - Parameter normalization The module is compatible with Python 3.11+ and earlier versions.
1 parent 4c79948 commit a15fa56

File tree

2 files changed

+275
-0
lines changed

2 files changed

+275
-0
lines changed

src/typesense/request_handler.py

+274
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
"""
2+
This module provides functionality for handling HTTP requests in the Typesense client library.
3+
4+
Classes:
5+
- RequestHandler: Manages HTTP requests to the Typesense API.
6+
- SessionFunctionKwargs: Type for keyword arguments in session functions.
7+
8+
The RequestHandler class interacts with the Typesense API to manage HTTP requests,
9+
handle authentication, and process responses. It provides methods to send requests,
10+
normalize parameters, and handle errors.
11+
12+
This module uses type hinting and is compatible with Python 3.11+ as well as earlier
13+
versions through the use of the typing_extensions library.
14+
15+
Key Features:
16+
- Handles authentication via API key
17+
- Supports JSON and non-JSON responses
18+
- Provides custom error handling for various HTTP status codes
19+
- Normalizes boolean parameters for API requests
20+
21+
Note: This module relies on the 'requests' library for making HTTP requests.
22+
"""
23+
24+
import json
25+
import sys
26+
from types import MappingProxyType
27+
28+
import requests
29+
30+
if sys.version_info >= (3, 11):
31+
import typing
32+
else:
33+
import typing_extensions as typing
34+
35+
from typesense.configuration import Configuration
36+
from typesense.exceptions import (
37+
HTTPStatus0Error,
38+
ObjectAlreadyExists,
39+
ObjectNotFound,
40+
ObjectUnprocessable,
41+
RequestForbidden,
42+
RequestMalformed,
43+
RequestUnauthorized,
44+
ServerError,
45+
ServiceUnavailable,
46+
TypesenseClientError,
47+
)
48+
49+
TEntityDict = typing.TypeVar("TEntityDict")
50+
TParams = typing.TypeVar("TParams")
51+
TBody = typing.TypeVar("TBody")
52+
53+
_ERROR_CODE_MAP: typing.Mapping[str, typing.Type[TypesenseClientError]] = (
54+
MappingProxyType(
55+
{
56+
"0": HTTPStatus0Error,
57+
"400": RequestMalformed,
58+
"401": RequestUnauthorized,
59+
"403": RequestForbidden,
60+
"404": ObjectNotFound,
61+
"409": ObjectAlreadyExists,
62+
"422": ObjectUnprocessable,
63+
"500": ServerError,
64+
"503": ServiceUnavailable,
65+
},
66+
)
67+
)
68+
69+
70+
class SessionFunctionKwargs(typing.Generic[TParams, TBody], typing.TypedDict):
71+
"""
72+
Type definition for keyword arguments used in session functions.
73+
74+
Attributes:
75+
params (Optional[Union[TParams, None]]): Query parameters for the request.
76+
77+
data (Optional[Union[TBody, str, None]]): Body of the request.
78+
79+
headers (Optional[Dict[str, str]]): Headers for the request.
80+
81+
timeout (float): Timeout for the request in seconds.
82+
83+
verify (bool): Whether to verify SSL certificates.
84+
"""
85+
86+
params: typing.NotRequired[typing.Union[TParams, None]]
87+
data: typing.NotRequired[typing.Union[TBody, str, None]]
88+
headers: typing.NotRequired[typing.Dict[str, str]]
89+
timeout: float
90+
verify: bool
91+
92+
93+
class RequestHandler:
94+
"""
95+
Handles HTTP requests to the Typesense API.
96+
97+
This class manages authentication, request sending, and response processing
98+
for interactions with the Typesense API.
99+
100+
Attributes:
101+
api_key_header_name (str): The header name for the API key.
102+
config (Configuration): The configuration object for the Typesense client.
103+
"""
104+
105+
api_key_header_name: typing.Final[str] = "X-TYPESENSE-API-KEY"
106+
107+
def __init__(self, config: Configuration):
108+
"""
109+
Initialize the RequestHandler with a configuration.
110+
111+
Args:
112+
config (Configuration): The configuration object for the Typesense client.
113+
"""
114+
self.config = config
115+
116+
@typing.overload
117+
def make_request(
118+
self,
119+
fn: typing.Callable[..., requests.models.Response],
120+
url: str,
121+
entity_type: typing.Type[TEntityDict],
122+
as_json: typing.Literal[False],
123+
**kwargs: typing.Unpack[SessionFunctionKwargs[TParams, TBody]],
124+
) -> str:
125+
"""
126+
Make an HTTP request to the Typesense API and return the response as a string.
127+
128+
This overload is used when as_json is set to False, indicating that the response
129+
should be returned as a raw string instead of being parsed as JSON.
130+
131+
Args:
132+
fn (Callable): The HTTP method function to use (e.g., requests.get).
133+
134+
url (str): The URL to send the request to.
135+
136+
entity_type (Type[TEntityDict]): The expected type of the response entity.
137+
138+
as_json (Literal[False]): Specifies that the response should not be parsed as JSON.
139+
140+
kwargs: Additional keyword arguments for the request.
141+
142+
Returns:
143+
str: The raw string response from the API.
144+
145+
Raises:
146+
TypesenseClientError: If the API returns an error response.
147+
"""
148+
149+
@typing.overload
150+
def make_request(
151+
self,
152+
fn: typing.Callable[..., requests.models.Response],
153+
url: str,
154+
entity_type: typing.Type[TEntityDict],
155+
as_json: typing.Literal[True],
156+
**kwargs: typing.Unpack[SessionFunctionKwargs[TParams, TBody]],
157+
) -> TEntityDict:
158+
"""
159+
Make an HTTP request to the Typesense API.
160+
161+
Args:
162+
fn (Callable): The HTTP method function to use (e.g., requests.get).
163+
164+
url (str): The URL to send the request to.
165+
166+
entity_type (Type[TEntityDict]): The expected type of the response entity.
167+
168+
as_json (bool): Whether to return the response as JSON. Defaults to True.
169+
170+
kwargs: Additional keyword arguments for the request.
171+
172+
Returns:
173+
TEntityDict: The response, as a JSON object.
174+
175+
Raises:
176+
TypesenseClientError: If the API returns an error response.
177+
"""
178+
179+
def make_request(
180+
self,
181+
fn: typing.Callable[..., requests.models.Response],
182+
url: str,
183+
entity_type: typing.Type[TEntityDict],
184+
as_json: typing.Union[typing.Literal[True], typing.Literal[False]] = True,
185+
**kwargs: typing.Unpack[SessionFunctionKwargs[TParams, TBody]],
186+
) -> typing.Union[TEntityDict, str]:
187+
"""
188+
Make an HTTP request to the Typesense API.
189+
190+
Args:
191+
fn (Callable): The HTTP method function to use (e.g., requests.get).
192+
193+
url (str): The URL to send the request to.
194+
195+
entity_type (Type[TEntityDict]): The expected type of the response entity.
196+
197+
as_json (bool): Whether to return the response as JSON. Defaults to True.
198+
199+
kwargs: Additional keyword arguments for the request.
200+
201+
Returns:
202+
Union[TEntityDict, str]: The response, either as a JSON object or a string.
203+
204+
Raises:
205+
TypesenseClientError: If the API returns an error response.
206+
"""
207+
headers = {self.api_key_header_name: self.config.api_key}
208+
kwargs.setdefault("headers", {}).update(headers)
209+
kwargs.setdefault("timeout", self.config.connection_timeout_seconds)
210+
kwargs.setdefault("verify", self.config.verify)
211+
if kwargs.get("data") and not isinstance(kwargs["data"], (str, bytes)):
212+
kwargs["data"] = json.dumps(kwargs["data"])
213+
214+
response = fn(url, **kwargs)
215+
216+
if response.status_code < 200 or response.status_code >= 300:
217+
error_message = self._get_error_message(response)
218+
raise self._get_exception(response.status_code)(
219+
response.status_code,
220+
error_message,
221+
)
222+
223+
if as_json:
224+
res: TEntityDict = response.json()
225+
return res
226+
227+
return response.text
228+
229+
@staticmethod
230+
def normalize_params(params: TParams) -> None:
231+
"""
232+
Normalize boolean parameters in the request.
233+
234+
Args:
235+
params (TParams): The parameters to normalize.
236+
237+
Raises:
238+
ValueError: If params is not a dictionary.
239+
"""
240+
if not isinstance(params, typing.Dict):
241+
raise ValueError("Params must be a dictionary.")
242+
for key, parameter_value in params.items():
243+
if isinstance(parameter_value, bool):
244+
params[key] = str(parameter_value).lower()
245+
246+
@staticmethod
247+
def _get_error_message(response: requests.Response) -> str:
248+
"""
249+
Extract the error message from an API response.
250+
251+
Args:
252+
response (requests.Response): The API response.
253+
254+
Returns:
255+
str: The extracted error message or a default message.
256+
"""
257+
content_type = response.headers.get("Content-Type", "")
258+
if content_type.startswith("application/json"):
259+
err_message: str = response.json().get("message", "API error.")
260+
return err_message
261+
return "API error."
262+
263+
@staticmethod
264+
def _get_exception(http_code: int) -> typing.Type[TypesenseClientError]:
265+
"""
266+
Map an HTTP status code to the appropriate exception type.
267+
268+
Args:
269+
http_code (int): The HTTP status code.
270+
271+
Returns:
272+
Type[TypesenseClientError]: The exception type corresponding to the status code.
273+
"""
274+
return _ERROR_CODE_MAP.get(str(http_code), TypesenseClientError)

tests/import_test.py

+1
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"collection",
3333
"collections",
3434
"configuration",
35+
"request_handler",
3536
"conversations_models",
3637
"document",
3738
"documents",

0 commit comments

Comments
 (0)