agent-framework-declarative 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,237 @@
1
+ # Copyright (c) Microsoft. All rights reserved.
2
+
3
+ """HTTP request handler abstraction for declarative workflows.
4
+
5
+ Mirrors the .NET ``IHttpRequestHandler`` / ``DefaultHttpRequestHandler`` pair from
6
+ ``Microsoft.Agents.AI.Workflows.Declarative``. Provides:
7
+
8
+ - :class:`HttpRequestInfo` — request input data passed from the executor.
9
+ - :class:`HttpRequestResult` — response data returned to the executor.
10
+ - :class:`HttpRequestHandler` — :class:`typing.Protocol` callers implement to plug
11
+ in custom transports (e.g. with allowlisting, mTLS, retries, etc.).
12
+ - :class:`DefaultHttpRequestHandler` — production-grade default backed by
13
+ ``httpx.AsyncClient``.
14
+
15
+ Security note: :class:`DefaultHttpRequestHandler` performs **no** URL filtering
16
+ or SSRF protection. Production deployments should supply a custom handler that
17
+ enforces an allowlist or DNS-rebinding-resistant policy. This split mirrors the
18
+ .NET design.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+ from collections.abc import Awaitable, Callable, Mapping
25
+ from dataclasses import dataclass, field
26
+ from typing import Any, Protocol, runtime_checkable
27
+
28
+ import httpx
29
+
30
+ __all__ = [
31
+ "DefaultHttpRequestHandler",
32
+ "HttpRequestHandler",
33
+ "HttpRequestInfo",
34
+ "HttpRequestResult",
35
+ ]
36
+
37
+
38
+ @dataclass
39
+ class HttpRequestInfo:
40
+ """Description of an HTTP request to be dispatched by a :class:`HttpRequestHandler`.
41
+
42
+ Mirrors the .NET ``HttpRequestInfo`` record. Field semantics:
43
+
44
+ - ``method``: HTTP method (``GET``, ``POST``, etc.). Already upper-cased by the executor.
45
+ - ``url``: Absolute URL. Already evaluated from the YAML expression.
46
+ - ``headers``: Single-value header map (case-insensitive keys per HTTP semantics
47
+ but stored as authored). Empty values are skipped by the executor.
48
+ - ``query_parameters``: String key/value pairs appended to the URL.
49
+ - ``body``: Request body bytes/text, or ``None`` for no body.
50
+ - ``body_content_type``: Content type to send (e.g. ``application/json``).
51
+ Ignored when ``body`` is ``None``.
52
+ - ``timeout_ms``: Per-request timeout in milliseconds. ``None`` => use the
53
+ handler's default.
54
+ - ``connection_name``: Optional Foundry connection name for handlers that
55
+ resolve auth/credentials by connection.
56
+ """
57
+
58
+ method: str
59
+ url: str
60
+ headers: dict[str, str] = field(default_factory=dict) # type: ignore[reportUnknownVariableType]
61
+ query_parameters: dict[str, str] = field(default_factory=dict) # type: ignore[reportUnknownVariableType]
62
+ body: str | None = None
63
+ body_content_type: str | None = None
64
+ timeout_ms: int | None = None
65
+ connection_name: str | None = None
66
+
67
+
68
+ @dataclass
69
+ class HttpRequestResult:
70
+ """Response returned by a :class:`HttpRequestHandler`.
71
+
72
+ Mirrors the .NET ``HttpRequestResult`` record. ``headers`` preserves
73
+ multi-value response headers (e.g. multiple ``Set-Cookie`` headers) as a
74
+ ``dict[str, list[str]]``. The executor folds duplicates into a single
75
+ comma-joined string only at the point it assigns ``responseHeaders`` to
76
+ workflow state.
77
+
78
+ Header keys are normalized to lowercase so that lookups are consistent
79
+ regardless of the server's transmitted casing (HTTP headers are
80
+ case-insensitive per RFC 7230 §3.2). Custom :class:`HttpRequestHandler`
81
+ implementations should follow the same convention.
82
+ """
83
+
84
+ status_code: int
85
+ is_success_status_code: bool
86
+ body: str
87
+ headers: dict[str, list[str]] = field(default_factory=dict) # type: ignore[reportUnknownVariableType]
88
+
89
+
90
+ @runtime_checkable
91
+ class HttpRequestHandler(Protocol):
92
+ """Protocol for HTTP request handlers used by ``HttpRequestAction``.
93
+
94
+ Implementations must be safe to call concurrently from multiple workflow
95
+ runs. Implementations are responsible for any URL allowlisting, SSRF
96
+ guards, retry policies, auth resolution, and other policies that the
97
+ workflow author wants applied.
98
+ """
99
+
100
+ async def send(self, info: HttpRequestInfo) -> HttpRequestResult:
101
+ """Dispatch ``info`` and return the response result.
102
+
103
+ Args:
104
+ info: Description of the request to send.
105
+
106
+ Returns:
107
+ The response. Implementations should NOT raise on non-2xx status
108
+ codes; instead, set ``is_success_status_code`` accordingly. They
109
+ SHOULD raise on transport-level failures (connection refused,
110
+ DNS errors, timeouts).
111
+ """
112
+ ...
113
+
114
+
115
+ ClientProvider = Callable[[HttpRequestInfo], Awaitable["httpx.AsyncClient | None"]]
116
+
117
+
118
+ class DefaultHttpRequestHandler:
119
+ """Default :class:`HttpRequestHandler` backed by :class:`httpx.AsyncClient`.
120
+
121
+ Construction modes:
122
+
123
+ 1. ``DefaultHttpRequestHandler()`` — owns an internal client created lazily
124
+ on first ``send()``. Closed by :meth:`aclose`.
125
+ 2. ``DefaultHttpRequestHandler(client=existing)`` — caller-owned client.
126
+ Not closed by :meth:`aclose`.
127
+ 3. ``DefaultHttpRequestHandler(client_provider=cb)`` — per-request client
128
+ lookup (parity with .NET's ``httpClientProvider`` callback). The
129
+ provider may return ``None`` to fall back to the owned/default client.
130
+
131
+ .. warning::
132
+
133
+ This handler performs **no** URL filtering or SSRF protection. Wrap or
134
+ replace it with a custom handler in production.
135
+ """
136
+
137
+ def __init__(
138
+ self,
139
+ *,
140
+ client: httpx.AsyncClient | None = None,
141
+ client_provider: ClientProvider | None = None,
142
+ ) -> None:
143
+ self._owned_client: httpx.AsyncClient | None = None
144
+ self._caller_client = client
145
+ self._client_provider = client_provider
146
+ # Guards lazy creation of ``_owned_client`` against concurrent first
147
+ # ``send()`` calls leaking duplicate clients.
148
+ self._owned_client_lock = asyncio.Lock()
149
+
150
+ async def send(self, info: HttpRequestInfo) -> HttpRequestResult:
151
+ """Dispatch the request and return the parsed result."""
152
+ if not info.url:
153
+ raise ValueError("HttpRequestInfo.url must be a non-empty string.")
154
+ if not info.method:
155
+ raise ValueError("HttpRequestInfo.method must be a non-empty string.")
156
+
157
+ client = await self._resolve_client(info)
158
+
159
+ timeout: httpx.Timeout | object
160
+ if info.timeout_ms is not None and info.timeout_ms > 0:
161
+ timeout = httpx.Timeout(info.timeout_ms / 1000.0)
162
+ else:
163
+ timeout = httpx.USE_CLIENT_DEFAULT
164
+
165
+ headers = dict(info.headers)
166
+ content: bytes | str | None = None
167
+ if info.body is not None:
168
+ content = info.body
169
+ if not _has_header(headers, "content-type"):
170
+ # Match .NET DefaultHttpRequestHandler: when a body is sent
171
+ # without an explicit content type, default to ``text/plain``
172
+ # so the request is interpretable by servers and direct
173
+ # callers (not just the YAML executor) get sensible defaults.
174
+ headers["Content-Type"] = info.body_content_type or "text/plain"
175
+
176
+ params: Mapping[str, str] | None = info.query_parameters or None
177
+
178
+ response = await client.request(
179
+ method=info.method,
180
+ url=info.url,
181
+ params=params,
182
+ headers=headers or None,
183
+ content=content,
184
+ timeout=timeout,
185
+ )
186
+
187
+ # Preserve multi-value headers (e.g. multiple Set-Cookie) as list[str].
188
+ # Normalize names to lowercase so lookups are consistent and case
189
+ # variations from the transport do not create duplicate logical keys
190
+ # (HTTP headers are case-insensitive per RFC 7230 §3.2).
191
+ result_headers: dict[str, list[str]] = {}
192
+ for key, value in response.headers.multi_items():
193
+ result_headers.setdefault(key.lower(), []).append(value)
194
+
195
+ body_text = response.text
196
+
197
+ return HttpRequestResult(
198
+ status_code=response.status_code,
199
+ is_success_status_code=200 <= response.status_code < 300,
200
+ body=body_text,
201
+ headers=result_headers,
202
+ )
203
+
204
+ async def aclose(self) -> None:
205
+ """Release the owned client, if any. Caller-owned clients are NOT closed."""
206
+ if self._owned_client is not None:
207
+ await self._owned_client.aclose()
208
+ self._owned_client = None
209
+
210
+ async def _resolve_client(self, info: HttpRequestInfo) -> httpx.AsyncClient:
211
+ """Pick a client for this request: provider → caller → lazily-owned."""
212
+ if self._client_provider is not None:
213
+ provided = await self._client_provider(info)
214
+ if provided is not None:
215
+ return provided
216
+ if self._caller_client is not None:
217
+ return self._caller_client
218
+ if self._owned_client is None:
219
+ # Double-checked locking under asyncio.Lock so concurrent first
220
+ # callers don't each create a fresh httpx.AsyncClient and orphan
221
+ # one of them.
222
+ async with self._owned_client_lock:
223
+ if self._owned_client is None:
224
+ self._owned_client = httpx.AsyncClient()
225
+ return self._owned_client
226
+
227
+ async def __aenter__(self) -> DefaultHttpRequestHandler:
228
+ return self
229
+
230
+ async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
231
+ await self.aclose()
232
+
233
+
234
+ def _has_header(headers: Mapping[str, str], name: str) -> bool:
235
+ """Case-insensitive header presence check."""
236
+ needle = name.lower()
237
+ return any(key.lower() == needle for key in headers)