dirigent-block-http 0.17.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.
- dirigent_block_http/__init__.py +31 -0
- dirigent_block_http/connections.py +36 -0
- dirigent_block_http/http.py +323 -0
- dirigent_block_http/messages.py +26 -0
- dirigent_block_http/py.typed +0 -0
- dirigent_block_http/webhooks.py +150 -0
- dirigent_block_http-0.17.0.dist-info/METADATA +23 -0
- dirigent_block_http-0.17.0.dist-info/RECORD +11 -0
- dirigent_block_http-0.17.0.dist-info/WHEEL +4 -0
- dirigent_block_http-0.17.0.dist-info/entry_points.txt +3 -0
- dirigent_block_http-0.17.0.dist-info/licenses/LICENSE +18 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""The HTTP block family: calling an endpoint, waiting for one, and posting a signed body."""
|
|
2
|
+
|
|
3
|
+
from dirigent_block_http.connections import HttpConnectionKind
|
|
4
|
+
from dirigent_block_http.http import HttpReadySensor, HttpRequestOperator
|
|
5
|
+
from dirigent_block_http.webhooks import WebhookPostOperator
|
|
6
|
+
from dirigent_plugin import Contribution, extension
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class HttpBlocks:
|
|
10
|
+
"""The plugin object the host discovers under the dirigent.plugins.v1 entry-point group."""
|
|
11
|
+
|
|
12
|
+
@extension
|
|
13
|
+
def contribute(self) -> Contribution:
|
|
14
|
+
"""Contribute the HTTP blocks and the connection kind they are addressed through."""
|
|
15
|
+
return Contribution(
|
|
16
|
+
operators=[HttpRequestOperator(), WebhookPostOperator()],
|
|
17
|
+
sensors=[HttpReadySensor()],
|
|
18
|
+
connection_kinds=[HttpConnectionKind()],
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
plugin = HttpBlocks()
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"HttpBlocks",
|
|
26
|
+
"HttpConnectionKind",
|
|
27
|
+
"HttpReadySensor",
|
|
28
|
+
"HttpRequestOperator",
|
|
29
|
+
"WebhookPostOperator",
|
|
30
|
+
"plugin",
|
|
31
|
+
]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""The generic HTTP connection kind, and the client one is turned into.
|
|
2
|
+
|
|
3
|
+
The configuration it registers is :class:`dirigent_common.HttpConnectionConfig`, so an adapter
|
|
4
|
+
pack presents the same HTTP connection as this one rather than redefining base URL, auth, TLS
|
|
5
|
+
and timeouts into a fourth slightly different form.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import ClassVar
|
|
9
|
+
|
|
10
|
+
import httpx2
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
from dirigent_common import HealthReport, HttpConnectionConfig, build_client
|
|
14
|
+
from dirigent_plugin import ConnectionKind
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class HttpConnectionKind(ConnectionKind):
|
|
18
|
+
"""The connection kind every generic HTTP block resolves its credentials through."""
|
|
19
|
+
|
|
20
|
+
id: ClassVar[str] = "http"
|
|
21
|
+
config_model: ClassVar[type[BaseModel]] = HttpConnectionConfig
|
|
22
|
+
|
|
23
|
+
async def check(self, config: BaseModel) -> HealthReport:
|
|
24
|
+
"""Request the health path and report whether the service answered."""
|
|
25
|
+
settings = HttpConnectionConfig.model_validate(config.model_dump())
|
|
26
|
+
try:
|
|
27
|
+
async with build_client(settings) as client:
|
|
28
|
+
response = await client.get(settings.health_path)
|
|
29
|
+
except httpx2.HTTPError as error:
|
|
30
|
+
return HealthReport(healthy=False, detail=f"{type(error).__name__}: {error}")
|
|
31
|
+
healthy = response.status_code < 500
|
|
32
|
+
return HealthReport(
|
|
33
|
+
healthy=healthy,
|
|
34
|
+
detail=f"HTTP {response.status_code}",
|
|
35
|
+
version=response.headers.get("server"),
|
|
36
|
+
)
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""The generic HTTP blocks: one operator that calls a service, one sensor that waits for one."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
from datetime import timedelta
|
|
6
|
+
from typing import ClassVar, Literal
|
|
7
|
+
|
|
8
|
+
import httpx2
|
|
9
|
+
from pydantic import BaseModel, Field, JsonValue, model_validator
|
|
10
|
+
|
|
11
|
+
from dirigent_block_http.connections import HttpConnectionConfig
|
|
12
|
+
from dirigent_block_http.messages import (
|
|
13
|
+
NO_TARGET,
|
|
14
|
+
RESPONSE_TOO_LARGE,
|
|
15
|
+
STATUS_REFUSED,
|
|
16
|
+
)
|
|
17
|
+
from dirigent_common import BlockModel, Duration, Size
|
|
18
|
+
from dirigent_plugin import (
|
|
19
|
+
BlockFailure,
|
|
20
|
+
ConnectionRef,
|
|
21
|
+
ErrorClass,
|
|
22
|
+
NotYet,
|
|
23
|
+
Operator,
|
|
24
|
+
OperatorSpec,
|
|
25
|
+
RemoteHandle,
|
|
26
|
+
Sensor,
|
|
27
|
+
SensorSpec,
|
|
28
|
+
StepContext,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
type HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]
|
|
32
|
+
|
|
33
|
+
DEFAULT_TEXT_CONTENT_TYPE = "text/plain; charset=utf-8"
|
|
34
|
+
|
|
35
|
+
JSON_CONTENT_TYPE = "application/json"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class HttpTarget(BlockModel):
|
|
39
|
+
"""The half of an HTTP block's config that says which service to talk to."""
|
|
40
|
+
|
|
41
|
+
connection: ConnectionRef | None = None
|
|
42
|
+
"""The code of the connection whose base URL, auth, TLS, and timeout apply."""
|
|
43
|
+
|
|
44
|
+
url: str | None = None
|
|
45
|
+
"""An absolute URL, for the case where no connection is configured."""
|
|
46
|
+
|
|
47
|
+
path: str = "/"
|
|
48
|
+
"""The path resolved against the connection's base URL."""
|
|
49
|
+
|
|
50
|
+
timeout: Duration | None = Field(default=None, gt=timedelta(0))
|
|
51
|
+
"""Overrides the connection's timeout for this call alone."""
|
|
52
|
+
|
|
53
|
+
follow_redirects: bool = False
|
|
54
|
+
"""Whether a 3xx is followed rather than returned as the answer.
|
|
55
|
+
|
|
56
|
+
A redirect to another host arrives without the connection's credentials: the client drops
|
|
57
|
+
the ``Authorization`` header when the origin changes, so a service that redirects before
|
|
58
|
+
authenticating must be configured with the URL it redirects to.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
@model_validator(mode="after")
|
|
62
|
+
def _require_a_target(self) -> "HttpTarget":
|
|
63
|
+
"""Reject a config that names neither a connection nor an absolute URL."""
|
|
64
|
+
if not self.connection and not self.url:
|
|
65
|
+
raise ValueError(NO_TARGET.render())
|
|
66
|
+
return self
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def client_for(target: HttpTarget, ctx: StepContext) -> httpx2.AsyncClient:
|
|
70
|
+
"""Build the client one call uses: the connection's, or a bare one for an absolute URL."""
|
|
71
|
+
if target.connection:
|
|
72
|
+
return ctx.http(target.connection)
|
|
73
|
+
fallback: timedelta = HttpConnectionConfig.model_fields["timeout"].default
|
|
74
|
+
return httpx2.AsyncClient(timeout=(target.timeout or fallback).total_seconds())
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def request_timeout(target: HttpTarget, client: httpx2.AsyncClient) -> httpx2.Timeout:
|
|
78
|
+
"""The timeout one request carries: the step's override, or the client's own.
|
|
79
|
+
|
|
80
|
+
``build_request`` reads an explicit ``timeout=None`` as "no timeout on any phase" rather
|
|
81
|
+
than as "whatever the client is configured with", so a step that configures no override
|
|
82
|
+
is given the client's own timeout rather than the unset field.
|
|
83
|
+
"""
|
|
84
|
+
if target.timeout is None:
|
|
85
|
+
return client.timeout
|
|
86
|
+
return httpx2.Timeout(target.timeout.total_seconds())
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def request_url(target: HttpTarget) -> str:
|
|
90
|
+
"""Resolve what one call requests: the absolute URL, or the path on the connection."""
|
|
91
|
+
return target.url or target.path
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def decode(response: httpx2.Response, payload: bytes) -> tuple[JsonValue | None, str | None]:
|
|
95
|
+
"""Split a body into its parsed JSON and its text, whichever it turned out to be."""
|
|
96
|
+
if "json" in response.headers.get("content-type", ""):
|
|
97
|
+
try:
|
|
98
|
+
parsed: JsonValue = json.loads(payload)
|
|
99
|
+
except ValueError:
|
|
100
|
+
return None, _as_text(payload)
|
|
101
|
+
return parsed, None
|
|
102
|
+
return None, _as_text(payload)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def body_of(response: httpx2.Response, payload: bytes) -> JsonValue:
|
|
106
|
+
"""The one value an output carries: the parsed JSON when the answer is JSON, else the text."""
|
|
107
|
+
parsed, text = decode(response, payload)
|
|
108
|
+
return text if parsed is None else parsed
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _as_text(payload: bytes) -> str:
|
|
112
|
+
"""Render a body as the text an output carries."""
|
|
113
|
+
return payload.decode("utf-8", errors="replace")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
async def read_bounded(response: httpx2.Response, limit: int) -> bytes:
|
|
117
|
+
"""Read a whole body, refusing one larger than the step said it would hold.
|
|
118
|
+
|
|
119
|
+
Counted as it arrives rather than trusted from a header, because content-length is the
|
|
120
|
+
service's claim and this is the worker's memory.
|
|
121
|
+
"""
|
|
122
|
+
chunks: list[bytes] = []
|
|
123
|
+
total = 0
|
|
124
|
+
async for chunk in response.aiter_bytes():
|
|
125
|
+
total += len(chunk)
|
|
126
|
+
if total > limit:
|
|
127
|
+
raise BlockFailure(RESPONSE_TOO_LARGE, error_class=ErrorClass.REJECTED, limit=limit)
|
|
128
|
+
chunks.append(chunk)
|
|
129
|
+
return b"".join(chunks)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class HttpRequestConfig(HttpTarget):
|
|
133
|
+
"""What one HTTP call sends, and which responses count as success."""
|
|
134
|
+
|
|
135
|
+
method: HttpMethod = "GET"
|
|
136
|
+
query: dict[str, str | int | float | bool] = Field(default_factory=dict[str, str | int | float | bool])
|
|
137
|
+
headers: dict[str, str] = Field(default_factory=dict[str, str])
|
|
138
|
+
body: JsonValue | None = None
|
|
139
|
+
"""What the request sends, usually a reference to what an earlier step produced.
|
|
140
|
+
|
|
141
|
+
A string is sent as it stands, which is what a query language, an XML document or a csv
|
|
142
|
+
upload goes in; any other value is serialised and sent as JSON. A document held in
|
|
143
|
+
storage is read by ``storage.read`` first and referenced here.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
content_type: str | None = None
|
|
147
|
+
"""The content type the body is sent with, overriding the default for what it carries.
|
|
148
|
+
|
|
149
|
+
A string defaults to ``text/plain; charset=utf-8`` and any other value to
|
|
150
|
+
``application/json``, so this is where an endpoint that wants ``text/csv`` or
|
|
151
|
+
``application/xml`` is told.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
success_status: list[int] = Field(default_factory=list[int])
|
|
155
|
+
"""Status codes that count as success; empty means any 2xx."""
|
|
156
|
+
|
|
157
|
+
max_response: Size = 32 * 1024 * 1024
|
|
158
|
+
"""How much of a response is read into memory.
|
|
159
|
+
|
|
160
|
+
A body has to be whole to be parsed, so one too large to hold is refused rather than
|
|
161
|
+
truncated: half a JSON document is not a smaller answer, it is a wrong one, and a step
|
|
162
|
+
that acted on it would be acting on something the service never said.
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
def request_body(self) -> tuple[dict[str, str], bytes | None]:
|
|
166
|
+
"""The headers and the bytes one request is built with, the body serialised here.
|
|
167
|
+
|
|
168
|
+
Serialised by the block rather than by the client, so the content type and the bytes
|
|
169
|
+
are decided in one place.
|
|
170
|
+
"""
|
|
171
|
+
if self.body is None:
|
|
172
|
+
return dict(self.headers), None
|
|
173
|
+
if isinstance(self.body, str):
|
|
174
|
+
content, declared = self.body.encode(), DEFAULT_TEXT_CONTENT_TYPE
|
|
175
|
+
else:
|
|
176
|
+
content = json.dumps(self.body, separators=(",", ":"), ensure_ascii=False).encode()
|
|
177
|
+
declared = JSON_CONTENT_TYPE
|
|
178
|
+
# Rebuilt without the header so one spelling of it reaches the client, whichever
|
|
179
|
+
# case the document wrote.
|
|
180
|
+
headers = {name: value for name, value in self.headers.items() if name.lower() != "content-type"}
|
|
181
|
+
named = next((value for name, value in self.headers.items() if name.lower() == "content-type"), None)
|
|
182
|
+
headers["content-type"] = self.content_type or named or declared
|
|
183
|
+
return headers, content
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class HttpRequestOutput(BlockModel):
|
|
187
|
+
"""What one HTTP call observed, which downstream steps reference by field."""
|
|
188
|
+
|
|
189
|
+
status: int
|
|
190
|
+
headers: dict[str, str]
|
|
191
|
+
body: JsonValue = None
|
|
192
|
+
"""What the service answered: the parsed document when it is JSON, else the text."""
|
|
193
|
+
|
|
194
|
+
body_bytes: int
|
|
195
|
+
"""How many bytes the answer was."""
|
|
196
|
+
|
|
197
|
+
duration_ms: int
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class HttpRequestOperator(Operator[HttpRequestConfig, HttpRequestOutput]):
|
|
201
|
+
"""Calls an HTTP service once and reports what it said."""
|
|
202
|
+
|
|
203
|
+
spec = OperatorSpec(id="http.request", summary="Call an HTTP endpoint.", idempotent=False)
|
|
204
|
+
config_model: ClassVar[type[BaseModel]] = HttpRequestConfig
|
|
205
|
+
output_model: ClassVar[type[BaseModel]] = HttpRequestOutput
|
|
206
|
+
|
|
207
|
+
async def execute(self, config: HttpRequestConfig, ctx: StepContext) -> HttpRequestOutput | RemoteHandle:
|
|
208
|
+
"""Send the request, and turn an unsuccessful status into a classified failure."""
|
|
209
|
+
started = time.monotonic()
|
|
210
|
+
async with client_for(config, ctx) as client:
|
|
211
|
+
headers, content = config.request_body()
|
|
212
|
+
request = client.build_request(
|
|
213
|
+
config.method,
|
|
214
|
+
request_url(config),
|
|
215
|
+
params=dict(config.query) or None,
|
|
216
|
+
headers=headers or None,
|
|
217
|
+
content=content,
|
|
218
|
+
timeout=request_timeout(config, client),
|
|
219
|
+
)
|
|
220
|
+
response = await client.send(request, stream=True, follow_redirects=config.follow_redirects)
|
|
221
|
+
try:
|
|
222
|
+
payload = await read_bounded(response, config.max_response)
|
|
223
|
+
finally:
|
|
224
|
+
await response.aclose()
|
|
225
|
+
duration = round((time.monotonic() - started) * 1000)
|
|
226
|
+
ctx.log.info(
|
|
227
|
+
"http call",
|
|
228
|
+
method=config.method,
|
|
229
|
+
url=request_url(config),
|
|
230
|
+
status=response.status_code,
|
|
231
|
+
bytes=len(payload),
|
|
232
|
+
duration_ms=duration,
|
|
233
|
+
)
|
|
234
|
+
if not is_success(response.status_code, config.success_status):
|
|
235
|
+
raise BlockFailure(
|
|
236
|
+
STATUS_REFUSED,
|
|
237
|
+
error_class=status_class(response.status_code),
|
|
238
|
+
method=config.method,
|
|
239
|
+
url=request_url(config),
|
|
240
|
+
status=response.status_code,
|
|
241
|
+
)
|
|
242
|
+
return HttpRequestOutput(
|
|
243
|
+
status=response.status_code,
|
|
244
|
+
headers={name.lower(): value for name, value in response.headers.items()},
|
|
245
|
+
body=body_of(response, payload),
|
|
246
|
+
body_bytes=len(payload),
|
|
247
|
+
duration_ms=duration,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class HttpReadyConfig(HttpTarget):
|
|
252
|
+
"""What readiness means for one service."""
|
|
253
|
+
|
|
254
|
+
expect_status: list[int] = Field(default_factory=list[int])
|
|
255
|
+
"""Status codes that mean ready; empty means any 2xx."""
|
|
256
|
+
|
|
257
|
+
contains: str | None = None
|
|
258
|
+
"""Optional body matcher; readiness also requires the response to contain this text."""
|
|
259
|
+
|
|
260
|
+
max_response: Size = 1024 * 1024
|
|
261
|
+
"""How much of the answer is read while looking for ``contains``.
|
|
262
|
+
|
|
263
|
+
A probe asks whether a service is up. An endpoint answering a readiness check with more
|
|
264
|
+
than this is not answering the question, so the poke reads that much and stops rather
|
|
265
|
+
than holding whatever arrives on a worker that pokes it every few seconds.
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class HttpReadyOutput(BlockModel):
|
|
270
|
+
"""The observation that a service is up, passed downstream like any output."""
|
|
271
|
+
|
|
272
|
+
status: int
|
|
273
|
+
duration_ms: int
|
|
274
|
+
matched: bool
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class HttpReadySensor(Sensor[HttpReadyConfig, HttpReadyOutput]):
|
|
278
|
+
"""Waits for an endpoint to answer successfully; each poke is one short, read-only GET."""
|
|
279
|
+
|
|
280
|
+
spec = SensorSpec(id="http.ready", summary="Wait for an HTTP endpoint to report ready.")
|
|
281
|
+
config_model: ClassVar[type[BaseModel]] = HttpReadyConfig
|
|
282
|
+
output_model: ClassVar[type[BaseModel]] = HttpReadyOutput
|
|
283
|
+
|
|
284
|
+
async def poke(self, config: HttpReadyConfig, ctx: StepContext) -> HttpReadyOutput | NotYet:
|
|
285
|
+
"""Observe once. A service that is not up yet is the condition, not an error."""
|
|
286
|
+
started = time.monotonic()
|
|
287
|
+
try:
|
|
288
|
+
async with client_for(config, ctx) as client:
|
|
289
|
+
request = client.build_request("GET", request_url(config), timeout=request_timeout(config, client))
|
|
290
|
+
response = await client.send(request, stream=True, follow_redirects=config.follow_redirects)
|
|
291
|
+
try:
|
|
292
|
+
answered = await read_bounded(response, config.max_response)
|
|
293
|
+
finally:
|
|
294
|
+
await response.aclose()
|
|
295
|
+
except httpx2.TransportError as error:
|
|
296
|
+
ctx.log.debug("endpoint is not reachable yet", error=str(error))
|
|
297
|
+
return NotYet()
|
|
298
|
+
duration = round((time.monotonic() - started) * 1000)
|
|
299
|
+
if not is_success(response.status_code, config.expect_status):
|
|
300
|
+
ctx.log.debug("endpoint is not ready yet", status=response.status_code)
|
|
301
|
+
return NotYet()
|
|
302
|
+
if config.contains is not None and config.contains not in _as_text(answered):
|
|
303
|
+
ctx.log.debug("endpoint answered but the body does not match yet")
|
|
304
|
+
return NotYet()
|
|
305
|
+
return HttpReadyOutput(status=response.status_code, duration_ms=duration, matched=config.contains is not None)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def status_class(status: int) -> ErrorClass:
|
|
309
|
+
"""Classify an HTTP status the way retry policy needs it classified.
|
|
310
|
+
|
|
311
|
+
A 429 is the one client error that asks to be retried: the server is rate limiting, and
|
|
312
|
+
the same call succeeds once the window has passed.
|
|
313
|
+
"""
|
|
314
|
+
if status >= 500 or status == 429:
|
|
315
|
+
return ErrorClass.TRANSIENT
|
|
316
|
+
if 400 <= status < 500:
|
|
317
|
+
return ErrorClass.REJECTED
|
|
318
|
+
return ErrorClass.UNKNOWN
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def is_success(status: int, expected: list[int]) -> bool:
|
|
322
|
+
"""Decide whether a status counts as success: the declared set, or any 2xx."""
|
|
323
|
+
return status in expected if expected else 200 <= status < 300
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Every refusal the http family makes, catalogued under the ``http`` prefix."""
|
|
2
|
+
|
|
3
|
+
from dirigent_common import Catalogue
|
|
4
|
+
|
|
5
|
+
HTTP = Catalogue("http")
|
|
6
|
+
|
|
7
|
+
RESPONSE_TOO_LARGE = HTTP.define(
|
|
8
|
+
"response_too_large",
|
|
9
|
+
"the response is larger than max_response ({limit} bytes) and is not being read; "
|
|
10
|
+
"raise max_response, or ask the endpoint for less",
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
STATUS_REFUSED = HTTP.define("status_refused", "{method} {url} answered {status}")
|
|
14
|
+
|
|
15
|
+
WEBHOOK_STATUS_REFUSED = HTTP.define("webhook.status_refused", "the receiver at {url} answered {status}")
|
|
16
|
+
|
|
17
|
+
WEBHOOK_NO_SECRET = HTTP.define(
|
|
18
|
+
"webhook.no_secret",
|
|
19
|
+
"connection {connection} has no hmac_secret, so this POST cannot be signed",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# What a config refuses at validation. Pydantic owns the code a validator's refusal reaches
|
|
24
|
+
# the wire under, so these are rendered into the ``ValueError`` it wraps.
|
|
25
|
+
|
|
26
|
+
NO_TARGET = HTTP.define("no_target", "an HTTP block needs either a connection or an absolute url")
|
|
File without changes
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""``webhook.post``: an outbound, optionally HMAC-signed JSON POST.
|
|
2
|
+
|
|
3
|
+
The ``X-Dirigent-Signature`` it presents is computed the same way dirigent's own inbound
|
|
4
|
+
``/hooks/{token}`` verifies one, so the two must stay in step.
|
|
5
|
+
|
|
6
|
+
The block serialises the body itself, and the signature covers the bytes that go on the
|
|
7
|
+
wire: letting the HTTP client re-encode the body after signing would produce a signature
|
|
8
|
+
that verifies nowhere.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import hmac
|
|
13
|
+
import json
|
|
14
|
+
import time
|
|
15
|
+
from typing import ClassVar, Final
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, ConfigDict, Field, JsonValue
|
|
18
|
+
|
|
19
|
+
from dirigent_block_http.connections import HttpConnectionConfig
|
|
20
|
+
from dirigent_block_http.http import (
|
|
21
|
+
HttpTarget,
|
|
22
|
+
client_for,
|
|
23
|
+
decode,
|
|
24
|
+
is_success,
|
|
25
|
+
read_bounded,
|
|
26
|
+
request_url,
|
|
27
|
+
status_class,
|
|
28
|
+
)
|
|
29
|
+
from dirigent_block_http.messages import WEBHOOK_NO_SECRET, WEBHOOK_STATUS_REFUSED
|
|
30
|
+
from dirigent_common import BlockModel, Size
|
|
31
|
+
from dirigent_plugin import BlockFailure, ConnectionRef, ErrorClass, Operator, OperatorSpec, RemoteHandle, StepContext
|
|
32
|
+
|
|
33
|
+
SIGNATURE_HEADER: Final = "X-Dirigent-Signature"
|
|
34
|
+
|
|
35
|
+
JSON_CONTENT_TYPE: Final = "application/json"
|
|
36
|
+
|
|
37
|
+
JSON_SEPARATORS: Final = (",", ":")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class WebhookPostConfig(HttpTarget):
|
|
41
|
+
"""What to POST, where, and what to sign it with."""
|
|
42
|
+
|
|
43
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
44
|
+
|
|
45
|
+
body: JsonValue = Field(default_factory=dict[str, JsonValue])
|
|
46
|
+
"""The JSON payload, usually built from upstream outputs with ``${steps...}`` references."""
|
|
47
|
+
|
|
48
|
+
headers: dict[str, str] = Field(default_factory=dict[str, str])
|
|
49
|
+
"""Extra headers the receiver wants, such as a routing key."""
|
|
50
|
+
|
|
51
|
+
max_response: Size = 1024 * 1024
|
|
52
|
+
"""How much of the receiver's answer is read, before the step is failed instead.
|
|
53
|
+
|
|
54
|
+
An acknowledgement is small: what a receiver says back is that it took the delivery, not
|
|
55
|
+
the data itself. A megabyte is generous for that, and a receiver answering with more than
|
|
56
|
+
a step said it would hold is a receiver to find out about rather than to read.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
sign_with: ConnectionRef | None = None
|
|
60
|
+
"""The connection whose ``hmac_secret`` signs the body; unset means the POST is unsigned.
|
|
61
|
+
|
|
62
|
+
Named separately from ``connection`` so a POST to an absolute URL can still be signed
|
|
63
|
+
with a secret this instance holds, and so signing is something a document says out loud
|
|
64
|
+
rather than something that happens because a connection had a field set."""
|
|
65
|
+
|
|
66
|
+
success_status: list[int] = Field(default_factory=list[int])
|
|
67
|
+
"""Status codes that count as delivered; empty means any 2xx."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class WebhookPostOutput(BlockModel):
|
|
71
|
+
"""What the receiver said, which downstream steps reference by field."""
|
|
72
|
+
|
|
73
|
+
status: int
|
|
74
|
+
signed: bool
|
|
75
|
+
duration_ms: int
|
|
76
|
+
json_body: JsonValue | None = None
|
|
77
|
+
text: str | None = None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class WebhookPostOperator(Operator[WebhookPostConfig, WebhookPostOutput]):
|
|
81
|
+
"""POSTs a JSON body to a connection or a URL, optionally signed the way dirigent signs."""
|
|
82
|
+
|
|
83
|
+
spec = OperatorSpec(id="webhook.post", summary="POST a JSON body, optionally HMAC-signed.", idempotent=False)
|
|
84
|
+
config_model: ClassVar[type[BaseModel]] = WebhookPostConfig
|
|
85
|
+
output_model: ClassVar[type[BaseModel]] = WebhookPostOutput
|
|
86
|
+
|
|
87
|
+
async def execute(self, config: WebhookPostConfig, ctx: StepContext) -> WebhookPostOutput | RemoteHandle:
|
|
88
|
+
"""Render the body once, sign those bytes, send them, and read the answer."""
|
|
89
|
+
payload = render(config.body)
|
|
90
|
+
headers = {**config.headers, "Content-Type": JSON_CONTENT_TYPE}
|
|
91
|
+
secret = _secret(config, ctx)
|
|
92
|
+
if secret is not None:
|
|
93
|
+
headers[SIGNATURE_HEADER] = sign(secret, payload)
|
|
94
|
+
started = time.monotonic()
|
|
95
|
+
async with client_for(config, ctx) as client:
|
|
96
|
+
request = client.build_request(
|
|
97
|
+
"POST",
|
|
98
|
+
request_url(config),
|
|
99
|
+
content=payload,
|
|
100
|
+
headers=headers,
|
|
101
|
+
timeout=config.timeout.total_seconds() if config.timeout is not None else None,
|
|
102
|
+
)
|
|
103
|
+
response = await client.send(request, stream=True, follow_redirects=config.follow_redirects)
|
|
104
|
+
try:
|
|
105
|
+
answered = await read_bounded(response, config.max_response)
|
|
106
|
+
finally:
|
|
107
|
+
await response.aclose()
|
|
108
|
+
duration = round((time.monotonic() - started) * 1000)
|
|
109
|
+
ctx.log.info(
|
|
110
|
+
"webhook delivered",
|
|
111
|
+
status=response.status_code,
|
|
112
|
+
bytes_sent=len(payload),
|
|
113
|
+
signed=secret is not None,
|
|
114
|
+
duration_ms=duration,
|
|
115
|
+
)
|
|
116
|
+
if not is_success(response.status_code, config.success_status):
|
|
117
|
+
raise BlockFailure(
|
|
118
|
+
WEBHOOK_STATUS_REFUSED,
|
|
119
|
+
error_class=status_class(response.status_code),
|
|
120
|
+
url=request_url(config),
|
|
121
|
+
status=response.status_code,
|
|
122
|
+
)
|
|
123
|
+
parsed, text = decode(response, answered)
|
|
124
|
+
return WebhookPostOutput(
|
|
125
|
+
status=response.status_code,
|
|
126
|
+
signed=secret is not None,
|
|
127
|
+
duration_ms=duration,
|
|
128
|
+
json_body=parsed,
|
|
129
|
+
text=text,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def render(body: JsonValue) -> bytes:
|
|
134
|
+
"""Serialise the body once, into the exact bytes that are both signed and sent."""
|
|
135
|
+
return json.dumps(body, separators=JSON_SEPARATORS, ensure_ascii=False).encode()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def sign(secret: bytes, payload: bytes) -> str:
|
|
139
|
+
"""Compute the signature over the raw body, the way dirigent's own intake verifies one."""
|
|
140
|
+
return hmac.new(secret, payload, hashlib.sha256).hexdigest()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _secret(config: WebhookPostConfig, ctx: StepContext) -> bytes | None:
|
|
144
|
+
"""Read the signing secret off the named connection, refusing one that has none."""
|
|
145
|
+
if config.sign_with is None:
|
|
146
|
+
return None
|
|
147
|
+
connection = ctx.connection(config.sign_with, HttpConnectionConfig)
|
|
148
|
+
if connection.hmac_secret is None:
|
|
149
|
+
raise BlockFailure(WEBHOOK_NO_SECRET, error_class=ErrorClass.REJECTED, connection=repr(config.sign_with))
|
|
150
|
+
return connection.hmac_secret.get_secret_value().encode()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dirigent-block-http
|
|
3
|
+
Version: 0.17.0
|
|
4
|
+
Summary: The HTTP block family for dirigent: http.request, http.ready, webhook.post, and the http connection kind.
|
|
5
|
+
License-Expression: LicenseRef-Proprietary
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
9
|
+
Requires-Dist: dirigent-common==0.17.0
|
|
10
|
+
Requires-Dist: dirigent-plugin==0.17.0
|
|
11
|
+
Requires-Dist: httpx2>=2.12.0
|
|
12
|
+
Requires-Python: >=3.13
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# dirigent-block-http
|
|
16
|
+
|
|
17
|
+
The HTTP block family: `http.request` calls an endpoint, `http.ready` waits for one to answer,
|
|
18
|
+
and `webhook.post` sends a JSON body that can carry an HMAC signature.
|
|
19
|
+
|
|
20
|
+
It registers the `http` connection kind, which is where a base URL, its credential, its TLS
|
|
21
|
+
settings and its timeouts are held, so a step names a connection rather than a URL and a
|
|
22
|
+
secret. The configuration and the client behind that kind are `dirigent-common`'s, and any
|
|
23
|
+
pack may use them; the registration is this family's.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
dirigent_block_http/__init__.py,sha256=nf4T94blO2qbnN2rRSXA64C8oL8tOn2f85_plZ_0cSw,989
|
|
2
|
+
dirigent_block_http/connections.py,sha256=Kxk2Ha6TbZVf_KDClab7S0lJcQXS37__Oa20HvdCtR8,1448
|
|
3
|
+
dirigent_block_http/http.py,sha256=NMSDcnSm5oRKO5hKjLpZCzvhxXCYYMcv8-B1wnHbZr0,13161
|
|
4
|
+
dirigent_block_http/messages.py,sha256=Z57MYi9wZw6AjoaOE6FqLKcrgG91Un6ujy-ELjxiMrU,953
|
|
5
|
+
dirigent_block_http/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
dirigent_block_http/webhooks.py,sha256=j314Ir0iQLzZbhm7QkAu1EdKCO8ol76Ps8r76YGMgww,6059
|
|
7
|
+
dirigent_block_http-0.17.0.dist-info/licenses/LICENSE,sha256=LKBm7Cx-WBc1zca4DjGxq99VEpAiWGnZDxIKmntn1hQ,910
|
|
8
|
+
dirigent_block_http-0.17.0.dist-info/WHEEL,sha256=R1d3uUTbmXM1FHXH_itQashbrqrOSVj-hvBCpmkIIGE,81
|
|
9
|
+
dirigent_block_http-0.17.0.dist-info/entry_points.txt,sha256=_V_HZvF2K9-oBPX4nr4QZ8qsfvPjQ9pYmjwLIj7FuMk,63
|
|
10
|
+
dirigent_block_http-0.17.0.dist-info/METADATA,sha256=X28vqphyWMoYmiywQk_-YkjEEAsMEhszJfEPUWLUIBs,1032
|
|
11
|
+
dirigent_block_http-0.17.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
Copyright (c) 2026 Morten Olav Hansen <morten@winterop.com>. All rights reserved.
|
|
2
|
+
|
|
3
|
+
This source code and accompanying documentation are the property of
|
|
4
|
+
Morten Olav Hansen. No license, express or implied, is granted to use, copy,
|
|
5
|
+
modify, merge, publish, distribute, sublicense, or sell copies of this
|
|
6
|
+
software or its derivatives.
|
|
7
|
+
|
|
8
|
+
The source is published for reference only. Any use beyond reading
|
|
9
|
+
requires written permission from the copyright holder.
|
|
10
|
+
|
|
11
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
|
12
|
+
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
13
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT.
|
|
14
|
+
IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES,
|
|
15
|
+
OR OTHER LIABILITY ARISING FROM THE USE OF THE SOFTWARE.
|
|
16
|
+
|
|
17
|
+
Third-party components redistributed with this software, and the licences they
|
|
18
|
+
carry, are listed in THIRD_PARTY_NOTICES.md.
|