langchain-openapi-tools 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,412 @@
1
+ """Production middleware pipeline for request/response handling."""
2
+
3
+ import asyncio
4
+ import hashlib
5
+ import json
6
+ import logging
7
+ import time
8
+ from collections.abc import Awaitable, Callable, Sequence
9
+ from typing import Any, Protocol, runtime_checkable
10
+
11
+ import httpx
12
+
13
+ from langchain_openapi.utils import sanitize_request_log
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ NextCallable = Callable[[httpx.Request], Awaitable[httpx.Response]]
18
+
19
+
20
+ @runtime_checkable
21
+ class Middleware(Protocol):
22
+ """Protocol for HTTP request/response middleware components."""
23
+
24
+ async def dispatch(
25
+ self,
26
+ request: httpx.Request,
27
+ call_next: NextCallable,
28
+ ) -> httpx.Response: ...
29
+
30
+
31
+ class MiddlewarePipeline:
32
+ """Orchestrates sequential execution of a list of Middleware instances."""
33
+
34
+ def __init__(self, middlewares: Sequence[Middleware] | None = None) -> None:
35
+ self.middlewares = list(middlewares) if middlewares else []
36
+
37
+ async def execute(
38
+ self,
39
+ request: httpx.Request,
40
+ transport_call: NextCallable,
41
+ ) -> httpx.Response:
42
+ index = 0
43
+
44
+ async def call_next(req: httpx.Request) -> httpx.Response:
45
+ nonlocal index
46
+ if index < len(self.middlewares):
47
+ mw = self.middlewares[index]
48
+ index += 1
49
+ return await mw.dispatch(req, call_next)
50
+ return await transport_call(req)
51
+
52
+ return await call_next(request)
53
+
54
+
55
+ class RetryMiddleware(Middleware):
56
+ """Middleware for retrying failed HTTP requests with configurable backoff."""
57
+
58
+ def __init__(
59
+ self,
60
+ retries: int = 3,
61
+ backoff: str = "exponential",
62
+ backoff_factor: float = 0.5,
63
+ retry_status_codes: Sequence[int] = (429, 500, 502, 503, 504),
64
+ ) -> None:
65
+ self.retries = retries
66
+ self.backoff = backoff
67
+ self.backoff_factor = backoff_factor
68
+ self.retry_status_codes = set(retry_status_codes)
69
+
70
+ async def dispatch(
71
+ self,
72
+ request: httpx.Request,
73
+ call_next: NextCallable,
74
+ ) -> httpx.Response:
75
+ attempt = 0
76
+ last_exception: Exception | None = None
77
+
78
+ while attempt <= self.retries:
79
+ try:
80
+ response = await call_next(request)
81
+ if (
82
+ response.status_code in self.retry_status_codes
83
+ and attempt < self.retries
84
+ ):
85
+ attempt += 1
86
+ delay = self._calculate_delay(attempt)
87
+ logger.warning(
88
+ "Retrying %s (attempt %d/%d) after HTTP %d (delay %.2fs)",
89
+ request.url,
90
+ attempt,
91
+ self.retries,
92
+ response.status_code,
93
+ delay,
94
+ )
95
+ await asyncio.sleep(delay)
96
+ continue
97
+ return response
98
+ except (httpx.TransportError, httpx.TimeoutException) as exc:
99
+ last_exception = exc
100
+ if attempt < self.retries:
101
+ attempt += 1
102
+ delay = self._calculate_delay(attempt)
103
+ logger.warning(
104
+ "Retrying %s (attempt %d/%d) after error: %s (delay %.2fs)",
105
+ request.url,
106
+ attempt,
107
+ self.retries,
108
+ exc,
109
+ delay,
110
+ )
111
+ await asyncio.sleep(delay)
112
+ continue
113
+ raise
114
+
115
+ if last_exception:
116
+ raise last_exception
117
+ return response
118
+
119
+ def _calculate_delay(self, attempt: int) -> float:
120
+ if self.backoff == "fixed":
121
+ return float(self.backoff_factor)
122
+ return float(self.backoff_factor * (2 ** (attempt - 1)))
123
+
124
+
125
+ class RateLimitMiddleware(Middleware):
126
+ """Middleware for throttling HTTP requests using a token bucket algorithm."""
127
+
128
+ def __init__(self, requests_per_second: float = 5.0) -> None:
129
+ if requests_per_second <= 0:
130
+ raise ValueError("requests_per_second must be greater than zero.")
131
+
132
+ self.rate = float(requests_per_second)
133
+ self.capacity = float(requests_per_second)
134
+ self.tokens = float(requests_per_second)
135
+ self.last_update = time.monotonic()
136
+ self._lock = asyncio.Lock()
137
+
138
+ async def dispatch(
139
+ self,
140
+ request: httpx.Request,
141
+ call_next: NextCallable,
142
+ ) -> httpx.Response:
143
+ await self._acquire_token()
144
+ return await call_next(request)
145
+
146
+ async def _acquire_token(self) -> None:
147
+ async with self._lock:
148
+ while True:
149
+ now = time.monotonic()
150
+ elapsed = now - self.last_update
151
+ self.last_update = now
152
+
153
+ self.tokens = min(self.capacity, self.tokens + elapsed * self.rate)
154
+
155
+ if self.tokens >= 1.0:
156
+ self.tokens -= 1.0
157
+ break
158
+
159
+ needed_tokens = 1.0 - self.tokens
160
+ wait_time = needed_tokens / self.rate
161
+ await asyncio.sleep(wait_time)
162
+
163
+
164
+ @runtime_checkable
165
+ class CacheBackend(Protocol):
166
+ """Protocol interface for cache storage backends."""
167
+
168
+ async def get(self, key: str) -> httpx.Response | None: ...
169
+
170
+ async def set(self, key: str, response: httpx.Response, ttl: float) -> None: ...
171
+
172
+
173
+ class InMemoryCacheBackend(CacheBackend):
174
+ """Default in-memory cache backend with TTL expiration."""
175
+
176
+ def __init__(self) -> None:
177
+ self._cache: dict[str, tuple[httpx.Response, float]] = {}
178
+
179
+ async def get(self, key: str) -> httpx.Response | None:
180
+ if key not in self._cache:
181
+ return None
182
+
183
+ response, expire_at = self._cache[key]
184
+ if time.monotonic() > expire_at:
185
+ del self._cache[key]
186
+ return None
187
+
188
+ return httpx.Response(
189
+ status_code=response.status_code,
190
+ headers=response.headers,
191
+ content=response.content,
192
+ request=response.request,
193
+ )
194
+
195
+ async def set(self, key: str, response: httpx.Response, ttl: float) -> None:
196
+ expire_at = time.monotonic() + ttl
197
+ content = (
198
+ await response.aread() if hasattr(response, "aread") else response.content
199
+ )
200
+ cached_resp = httpx.Response(
201
+ status_code=response.status_code,
202
+ headers=response.headers,
203
+ content=content,
204
+ request=response.request,
205
+ )
206
+ self._cache[key] = (cached_resp, expire_at)
207
+
208
+
209
+ class CacheMiddleware(Middleware):
210
+ """Middleware for caching HTTP GET responses."""
211
+
212
+ def __init__(
213
+ self,
214
+ ttl: float = 300.0,
215
+ backend: CacheBackend | None = None,
216
+ cache_authenticated: bool = False,
217
+ ) -> None:
218
+ self.ttl = ttl
219
+ self.backend = backend or InMemoryCacheBackend()
220
+ self.cache_authenticated = cache_authenticated
221
+
222
+ async def dispatch(
223
+ self,
224
+ request: httpx.Request,
225
+ call_next: NextCallable,
226
+ ) -> httpx.Response:
227
+ if request.method.upper() != "GET":
228
+ return await call_next(request)
229
+
230
+ if not self.cache_authenticated and self._is_authenticated(request):
231
+ return await call_next(request)
232
+
233
+ key = self._generate_cache_key(request)
234
+
235
+ cached = await self.backend.get(key)
236
+ if cached is not None:
237
+ logger.info("Cache hit for %s", request.url)
238
+ return cached
239
+
240
+ logger.info("Cache miss for %s", request.url)
241
+ response = await call_next(request)
242
+
243
+ if response.status_code == 200:
244
+ await self.backend.set(key, response, self.ttl)
245
+
246
+ return response
247
+
248
+ def _is_authenticated(self, request: httpx.Request) -> bool:
249
+ auth_headers = {"authorization", "cookie", "x-api-key"}
250
+ return any(h.lower() in auth_headers for h in request.headers)
251
+
252
+ def _generate_cache_key(self, request: httpx.Request) -> str:
253
+ key_raw = f"{request.method.upper()}:{request.url}"
254
+ return hashlib.sha256(key_raw.encode("utf-8")).hexdigest()
255
+
256
+
257
+ class PaginationMiddleware(Middleware):
258
+ """Middleware for automated multi-page result aggregation."""
259
+
260
+ def __init__(
261
+ self,
262
+ max_pages: int = 10,
263
+ max_items: int = 1000,
264
+ ) -> None:
265
+ self.max_pages = max_pages
266
+ self.max_items = max_items
267
+
268
+ async def dispatch(
269
+ self,
270
+ request: httpx.Request,
271
+ call_next: NextCallable,
272
+ ) -> httpx.Response:
273
+ params = dict(request.url.params)
274
+ is_paginate_flag = (
275
+ request.headers.get("X-LangChain-Paginate", "").lower() == "true"
276
+ or params.pop("__paginate__", "").lower() == "true"
277
+ )
278
+
279
+ if not is_paginate_flag:
280
+ return await call_next(request)
281
+
282
+ if "X-LangChain-Paginate" in request.headers:
283
+ del request.headers["X-LangChain-Paginate"]
284
+ request.url = request.url.copy_with(params=params)
285
+
286
+ response = await call_next(request)
287
+ if response.status_code != 200:
288
+ return response
289
+
290
+ try:
291
+ initial_data = json.loads(response.text)
292
+ except Exception:
293
+ return response
294
+
295
+ aggregated_items, data_key = self._extract_items(initial_data)
296
+ if aggregated_items is None:
297
+ return response
298
+
299
+ base_payload = initial_data
300
+ current_page = 1
301
+ page_param_name, _current_val = self._detect_pagination_param(params)
302
+
303
+ while current_page < self.max_pages and len(aggregated_items) < self.max_items:
304
+ current_page += 1
305
+ next_params = dict(params)
306
+
307
+ if page_param_name == "page":
308
+ next_params["page"] = str(current_page)
309
+ elif page_param_name == "offset":
310
+ limit = int(params.get("limit", len(aggregated_items)))
311
+ next_params["offset"] = str((current_page - 1) * limit)
312
+ elif page_param_name == "cursor" and isinstance(initial_data, dict):
313
+ cursor_val = initial_data.get("next_cursor") or initial_data.get(
314
+ "cursor"
315
+ )
316
+ if not cursor_val:
317
+ break
318
+ next_params["cursor"] = str(cursor_val)
319
+ else:
320
+ next_params["page"] = str(current_page)
321
+
322
+ next_req = httpx.Request(
323
+ method=request.method,
324
+ url=request.url.copy_with(params=next_params),
325
+ headers=request.headers,
326
+ content=request.content,
327
+ )
328
+
329
+ page_resp = await call_next(next_req)
330
+ if page_resp.status_code != 200:
331
+ break
332
+
333
+ try:
334
+ page_data = json.loads(page_resp.text)
335
+ except Exception:
336
+ break
337
+
338
+ page_items, _ = self._extract_items(page_data)
339
+ if not page_items:
340
+ break
341
+
342
+ aggregated_items.extend(page_items)
343
+ initial_data = page_data
344
+
345
+ if len(aggregated_items) >= self.max_items:
346
+ aggregated_items = aggregated_items[: self.max_items]
347
+ break
348
+
349
+ final_payload: Any
350
+ if data_key is not None and isinstance(base_payload, dict):
351
+ base_payload[data_key] = aggregated_items
352
+ final_payload = base_payload
353
+ else:
354
+ final_payload = aggregated_items
355
+
356
+ return httpx.Response(
357
+ status_code=200,
358
+ headers=response.headers,
359
+ content=json.dumps(final_payload).encode("utf-8"),
360
+ request=request,
361
+ )
362
+
363
+ def _extract_items(self, data: Any) -> tuple[list[Any] | None, str | None]:
364
+ if isinstance(data, list):
365
+ return list(data), None
366
+ if isinstance(data, dict):
367
+ for key in ("items", "data", "results", "records"):
368
+ if key in data and isinstance(data[key], list):
369
+ return list(data[key]), key
370
+ return None, None
371
+
372
+ def _detect_pagination_param(self, params: dict[str, str]) -> tuple[str, str]:
373
+ for p in ("page", "offset", "cursor"):
374
+ if p in params:
375
+ return p, params[p]
376
+ return "page", "1"
377
+
378
+
379
+ class LoggingMiddleware(Middleware):
380
+ """Middleware for logging HTTP request/response metrics."""
381
+
382
+ def __init__(self, logger_instance: logging.Logger | None = None) -> None:
383
+ self.logger = logger_instance or logger
384
+
385
+ async def dispatch(
386
+ self,
387
+ request: httpx.Request,
388
+ call_next: NextCallable,
389
+ ) -> httpx.Response:
390
+ sanitized = sanitize_request_log(request.method, str(request.url))
391
+ start_time = time.monotonic()
392
+ self.logger.info("HTTP Request: %s", sanitized)
393
+
394
+ try:
395
+ response = await call_next(request)
396
+ elapsed = time.monotonic() - start_time
397
+ self.logger.info(
398
+ "HTTP Response: %s -> Status %d (%.3fs)",
399
+ sanitized,
400
+ response.status_code,
401
+ elapsed,
402
+ )
403
+ return response
404
+ except Exception as exc:
405
+ elapsed = time.monotonic() - start_time
406
+ self.logger.error(
407
+ "HTTP Exception: %s failed after %.3fs: %s",
408
+ sanitized,
409
+ elapsed,
410
+ exc,
411
+ )
412
+ raise
@@ -0,0 +1,146 @@
1
+ """Internal normalized models for OpenAPI specification entities.
2
+
3
+ Note on Limitations:
4
+ Current parser models skip polymorphic constructs (oneOf, anyOf, allOf,
5
+ discriminator), callbacks, links, webhooks, and complex example inheritance
6
+ patterns. These features will be evaluated in future milestones.
7
+ """
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Any
11
+
12
+ from langchain_openapi.enums import DataType, HTTPMethod, ParameterLocation
13
+
14
+
15
+ @dataclass
16
+ class Schema:
17
+ """Represents a data type schema definition.
18
+
19
+ Attributes:
20
+ type: The data type (e.g. 'string', 'integer', 'object', 'array').
21
+ format: Optional format modifier (e.g. 'date-time', 'email', 'uuid').
22
+ properties: Dictionary of property schemas if type is 'object'.
23
+ items: Schema of array elements if type is 'array'.
24
+ required: List of required property names if type is 'object'.
25
+ enum: List of allowed values for enum types.
26
+ default: Default value for the schema.
27
+ nullable: Whether null values are permitted.
28
+ description: Description of the schema element.
29
+ """
30
+
31
+ type: DataType | str | list[DataType | str] | None = None
32
+ format: str | None = None
33
+ properties: dict[str, "Schema"] | None = None
34
+ items: "Schema | None" = None
35
+ required: list[str] | None = None
36
+ enum: list[Any] | None = None
37
+ default: Any = None
38
+ nullable: bool = False
39
+ description: str | None = None
40
+
41
+
42
+ @dataclass
43
+ class MediaType:
44
+ """Represents a media type definition (e.g., 'application/json').
45
+
46
+ Attributes:
47
+ content_type: MIME type string (e.g., 'application/json').
48
+ schema: Optional Schema instance defining the payload structure.
49
+ example: Single example value for this media type.
50
+ examples: Dictionary of named example objects.
51
+ """
52
+
53
+ content_type: str
54
+ schema: Schema | None = None
55
+ example: Any = None
56
+ examples: dict[str, Any] | None = None
57
+
58
+
59
+ @dataclass
60
+ class Parameter:
61
+ """Represents an operation parameter (path, query, header, or cookie).
62
+
63
+ Attributes:
64
+ name: Parameter name.
65
+ location: Parameter location (path, query, header, cookie).
66
+ required: Whether the parameter is required.
67
+ description: Parameter description.
68
+ schema: Optional Schema defining the parameter value type.
69
+ default: Default value for the parameter.
70
+ example: Example value for the parameter.
71
+ style: Parameter serialization style modifier.
72
+ explode: Whether parameter array/object items explode into separate values.
73
+ """
74
+
75
+ name: str
76
+ location: ParameterLocation
77
+ required: bool = False
78
+ description: str | None = None
79
+ schema: Schema | None = None
80
+ default: Any = None
81
+ example: Any = None
82
+ style: str | None = None
83
+ explode: bool | None = None
84
+
85
+
86
+ @dataclass
87
+ class RequestBody:
88
+ """Represents an operation request payload definition.
89
+
90
+ Attributes:
91
+ required: Whether a request body is mandatory.
92
+ description: Request body description.
93
+ content: Map of MIME content types to MediaType instances.
94
+ """
95
+
96
+ required: bool = False
97
+ description: str | None = None
98
+ content: dict[str, MediaType] = field(default_factory=dict)
99
+
100
+
101
+ @dataclass
102
+ class Response:
103
+ """Represents an HTTP response status definition.
104
+
105
+ Attributes:
106
+ status_code: HTTP status code string (e.g., '200', '404', 'default').
107
+ description: Response description.
108
+ content: Map of MIME content types to MediaType instances.
109
+ """
110
+
111
+ status_code: str
112
+ description: str
113
+ content: dict[str, MediaType] = field(default_factory=dict)
114
+
115
+
116
+ @dataclass
117
+ class Operation:
118
+ """Represents a single API operation corresponding to an HTTP method on a path.
119
+
120
+ Attributes:
121
+ name: Resolved operation identifier (or generated fallback name).
122
+ summary: Short summary of the operation.
123
+ description: Detailed description of the operation.
124
+ method: HTTP method (GET, POST, etc.).
125
+ path: Path template (e.g., '/users/{id}').
126
+ operation_id: Explicit operationId if declared in spec.
127
+ tags: Tags associated with the operation.
128
+ parameters: List of Parameter objects for path, query, headers, cookies.
129
+ request_body: Optional RequestBody instance for request payloads.
130
+ responses: Map of HTTP status code strings to Response objects.
131
+ deprecated: Whether the operation is deprecated.
132
+ security: List of security requirement maps for this operation.
133
+ """
134
+
135
+ name: str
136
+ method: HTTPMethod
137
+ path: str
138
+ summary: str | None = None
139
+ description: str | None = None
140
+ operation_id: str | None = None
141
+ tags: list[str] = field(default_factory=list)
142
+ parameters: list[Parameter] = field(default_factory=list)
143
+ request_body: RequestBody | None = None
144
+ responses: dict[str, Response] = field(default_factory=dict)
145
+ deprecated: bool = False
146
+ security: list[dict[str, list[str]]] = field(default_factory=list)