lightweb-adapter-sdk 0.2.2__tar.gz → 0.2.4__tar.gz
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.
- {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/CHANGELOG.md +62 -0
- {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/PKG-INFO +1 -1
- {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/docs/testkit.md +84 -1
- lightweb_adapter_sdk-0.2.4/src/lightweb_adapter_sdk/auth/__init__.py +42 -0
- lightweb_adapter_sdk-0.2.4/src/lightweb_adapter_sdk/auth/errors.py +16 -0
- lightweb_adapter_sdk-0.2.4/src/lightweb_adapter_sdk/auth/protocol.py +145 -0
- lightweb_adapter_sdk-0.2.4/src/lightweb_adapter_sdk/auth/provider.py +128 -0
- lightweb_adapter_sdk-0.2.4/src/lightweb_adapter_sdk/event_consumer/parser.py +119 -0
- {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/testkit/__init__.py +7 -0
- {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/testkit/fake_event_source.py +39 -24
- lightweb_adapter_sdk-0.2.4/src/lightweb_adapter_sdk/testkit/fake_token_endpoint.py +145 -0
- lightweb_adapter_sdk-0.2.2/src/lightweb_adapter_sdk/event_consumer/parser.py +0 -94
- {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/.github/workflows/ci.yml +0 -0
- {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/.github/workflows/release.yml +0 -0
- {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/.gitignore +0 -0
- {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/LICENSE +0 -0
- {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/README.md +0 -0
- {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/docs/conformance-audit.md +0 -0
- {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/docs/conformance.md +0 -0
- {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/docs/contract.md +0 -0
- {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/docs/event-consumer.md +0 -0
- {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/docs/examples/round-0-prompt.md +0 -0
- {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/docs/round-prompt-template.md +0 -0
- {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/docs/supervisor-side-edits.md +0 -0
- {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/docs/wrap-procedures.md +0 -0
- {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/pyproject.toml +0 -0
- {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/__init__.py +0 -0
- {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/adapter/__init__.py +0 -0
- {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/conformance/__init__.py +0 -0
- {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/conformance/fault_control.py +0 -0
- {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/conformance/test_apply.py +0 -0
- {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/conformance/test_capabilities.py +0 -0
- {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/conformance/test_disable.py +0 -0
- {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/conformance/test_event_consumer.py +0 -0
- {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/conformance/test_health.py +0 -0
- {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/conformance/test_identity_mapping.py +0 -0
- {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/conformance/test_list_accounts.py +0 -0
- {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/conformance/test_reconcile.py +0 -0
- {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/event_consumer/__init__.py +0 -0
- {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/event_consumer/dedup.py +0 -0
- {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/event_consumer/jetstream.py +0 -0
- {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/event_consumer/protocol.py +0 -0
- {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/py.typed +0 -0
- {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/reference/__init__.py +0 -0
- {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/reference/event_consumer.py +0 -0
- {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/reference/fake_server.py +0 -0
- {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/reference/fault_control.py +0 -0
- {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/testkit/fake_entitlements.py +0 -0
- {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/testkit/structural.py +0 -0
- {lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/types/__init__.py +0 -0
|
@@ -8,6 +8,68 @@ Breaking changes are flagged with `**BREAKING:**` at the start of the entry, eve
|
|
|
8
8
|
|
|
9
9
|
The SDK is in 0.x until its contract has been validated by at least one production adapter. Per [`CONVENTIONS.md`](CONVENTIONS.md) §1.3, breaking changes may land in minor (0.X → 0.(X+1)) releases while in 0.x; pin against a specific minor version.
|
|
10
10
|
|
|
11
|
+
## [0.2.4] - 2026-05-26
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- `auth` module: `ClientCredentialsTokenProvider` — a reusable OAuth2
|
|
16
|
+
client-credentials token provider that acquires, caches, and
|
|
17
|
+
refreshes bearer tokens. Takes an **injected** async HTTP client
|
|
18
|
+
(structural `HttpClient` Protocol) rather than importing an HTTP
|
|
19
|
+
library, so the SDK's runtime dependency surface remains
|
|
20
|
+
`pydantic` + `nats-py`. Includes `TokenProvider` Protocol,
|
|
21
|
+
`HttpClient`/`HttpResponse` Protocols, and `TokenAcquisitionError`
|
|
22
|
+
exception. Concurrency-safe: concurrent `get_token()` calls near
|
|
23
|
+
expiry trigger a single re-acquisition under an `asyncio.Lock`.
|
|
24
|
+
- `testkit.FakeTokenEndpoint`: an in-process fake of an OAuth2 token
|
|
25
|
+
endpoint that satisfies the `HttpClient` Protocol structurally. Lets
|
|
26
|
+
tests stage token values, assert caching (call count), exercise
|
|
27
|
+
refresh-before-expiry, and verify clean failure on error responses.
|
|
28
|
+
|
|
29
|
+
## [0.2.3] - 2026-05-24
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- `event_consumer.parser.parse_envelope` now reads the platform's
|
|
34
|
+
native event-envelope field names — `event_id`, `event_type`,
|
|
35
|
+
`event_version`, `occurred_at`, `emitted_by` — per
|
|
36
|
+
`libs/events/schemas/entitlements/_envelope.json` and the entitlements
|
|
37
|
+
service's `outbox/envelope.py::build_envelope`. It previously read a
|
|
38
|
+
CloudEvents shape (`id`, `type`, `specversion`, `time`, `source`) that
|
|
39
|
+
the platform never emits, rejecting every real event at `Missing
|
|
40
|
+
required field: id`; an adapter consuming the live bus would have
|
|
41
|
+
nacked/dead-lettered all entitlements events. `member_id` is still
|
|
42
|
+
sourced from `data.member_id`; `subject` is tolerated and ignored (it
|
|
43
|
+
is schema-required and always present, but the consumer hook is
|
|
44
|
+
`data.member_id`, since `subject.id` varies by event type). The
|
|
45
|
+
`EventEnvelope` type is unchanged — this is a parser-internal fix,
|
|
46
|
+
non-breaking. The parser docstring, which had incorrectly described
|
|
47
|
+
the CloudEvents shape as matching the schema, is corrected to the
|
|
48
|
+
native shape. Surfaced by NextCloud D3 consumer-wiring integration
|
|
49
|
+
work (integration finding F8).
|
|
50
|
+
- `testkit.FakeEventSource.publish`/`publish_async` now serialize the
|
|
51
|
+
native envelope shape (matching `build_envelope`: `event_id`,
|
|
52
|
+
`event_type`, `event_version`, `occurred_at`, `emitted_by`, a member
|
|
53
|
+
`subject`, `data.member_id`, and `context` as an object). They
|
|
54
|
+
previously emitted the same CloudEvents shape the parser wrongly read
|
|
55
|
+
— so the fake producer and the parser were a matched pair that agreed
|
|
56
|
+
with each other and not with the platform, which is why no test caught
|
|
57
|
+
F8. Adapter authors testing consumers against `FakeEventSource` now
|
|
58
|
+
exercise the real on-wire shape. Behavior change to the testkit fake;
|
|
59
|
+
adapter consumer tests that inspected the published bytes for `id`/
|
|
60
|
+
`type`/etc. must read the native field names.
|
|
61
|
+
|
|
62
|
+
### Added
|
|
63
|
+
|
|
64
|
+
- Raw-bytes parser coverage in `tests/unit/test_event_consumer_conformance.py`
|
|
65
|
+
(`TestParseEnvelope`): asserts `parse_envelope` reads the native
|
|
66
|
+
envelope shape and rejects the legacy CloudEvents shape (F8 regression
|
|
67
|
+
guard), plus a case confirming `member_id` is taken from
|
|
68
|
+
`data.member_id` rather than `subject.id`. The prior parser tests
|
|
69
|
+
asserted the CloudEvents shape — they validated the bug rather than
|
|
70
|
+
catching it. Test payloads are derived from the producer
|
|
71
|
+
(`build_envelope`) pending a captured real event.
|
|
72
|
+
|
|
11
73
|
## [0.2.2] - 2026-05-24
|
|
12
74
|
|
|
13
75
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lightweb-adapter-sdk
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: Published Python SDK for the Lightweb adapter contract
|
|
5
5
|
Project-URL: Homepage, https://github.com/lightweb/adapter-sdk
|
|
6
6
|
Project-URL: Repository, https://github.com/lightweb/adapter-sdk
|
|
@@ -197,7 +197,90 @@ assert stored.external_id == "alice"
|
|
|
197
197
|
subsequent calls with the same key update the existing record.
|
|
198
198
|
``get_app_account`` returns ``None`` for unknown keys.
|
|
199
199
|
|
|
200
|
-
##
|
|
200
|
+
## The fake token endpoint
|
|
201
|
+
|
|
202
|
+
The ``FakeTokenEndpoint`` class is an in-process fake of an OAuth2
|
|
203
|
+
token endpoint. It satisfies the ``HttpClient`` Protocol structurally
|
|
204
|
+
so it can be injected into ``ClientCredentialsTokenProvider``. Adapter
|
|
205
|
+
authors use it to test token acquisition, caching, refresh-before-expiry,
|
|
206
|
+
and clean-failure behaviour.
|
|
207
|
+
|
|
208
|
+
### Basic usage
|
|
209
|
+
|
|
210
|
+
```python
|
|
211
|
+
from lightweb_adapter_sdk.testkit import FakeTokenEndpoint
|
|
212
|
+
from lightweb_adapter_sdk.auth import ClientCredentialsTokenProvider
|
|
213
|
+
|
|
214
|
+
fake = FakeTokenEndpoint(access_token="my-token", expires_in=300)
|
|
215
|
+
|
|
216
|
+
provider = ClientCredentialsTokenProvider(
|
|
217
|
+
token_endpoint="https://keycloak.example.com/token",
|
|
218
|
+
client_id="my-adapter",
|
|
219
|
+
client_secret="secret",
|
|
220
|
+
http_client=fake,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
token = await provider.get_token()
|
|
224
|
+
assert token == "my-token"
|
|
225
|
+
assert fake.call_count == 1
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### Asserting caching
|
|
229
|
+
|
|
230
|
+
Call ``get_token()`` multiple times — the fake's ``call_count`` stays
|
|
231
|
+
at 1 as long as the token is within its lifetime:
|
|
232
|
+
|
|
233
|
+
```python
|
|
234
|
+
for _ in range(10):
|
|
235
|
+
await provider.get_token()
|
|
236
|
+
|
|
237
|
+
assert fake.call_count == 1 # only one acquisition
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
### Refresh-before-expiry
|
|
241
|
+
|
|
242
|
+
Stage a token with a short ``expires_in`` (or use a large
|
|
243
|
+
``refresh_margin_seconds``) to exercise the refresh path:
|
|
244
|
+
|
|
245
|
+
```python
|
|
246
|
+
fake = FakeTokenEndpoint(access_token="old-token", expires_in=0)
|
|
247
|
+
provider = ClientCredentialsTokenProvider(
|
|
248
|
+
token_endpoint="...",
|
|
249
|
+
client_id="...",
|
|
250
|
+
client_secret="...",
|
|
251
|
+
http_client=fake,
|
|
252
|
+
refresh_margin_seconds=1000.0,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
await provider.get_token() # acquires "old-token"
|
|
256
|
+
fake.stage_token("new-token", expires_in=300)
|
|
257
|
+
token = await provider.get_token() # triggers refresh
|
|
258
|
+
|
|
259
|
+
assert token == "new-token"
|
|
260
|
+
assert fake.call_count == 2
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
### Error responses
|
|
264
|
+
|
|
265
|
+
Stage an error response to verify clean failure:
|
|
266
|
+
|
|
267
|
+
```python
|
|
268
|
+
fake = FakeTokenEndpoint()
|
|
269
|
+
fake.stage_error(status_code=401)
|
|
270
|
+
provider = ClientCredentialsTokenProvider(
|
|
271
|
+
token_endpoint="...",
|
|
272
|
+
client_id="...",
|
|
273
|
+
client_secret="...",
|
|
274
|
+
http_client=fake,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
from lightweb_adapter_sdk.auth import TokenAcquisitionError
|
|
278
|
+
|
|
279
|
+
with pytest.raises(TokenAcquisitionError):
|
|
280
|
+
await provider.get_token()
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
### Beyond the testkit
|
|
201
284
|
|
|
202
285
|
The testkit's structural tests verify that an adapter looks like an
|
|
203
286
|
adapter. They do not verify that the adapter implements the contract
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Auth module — OAuth2 token acquisition for adapters.
|
|
2
|
+
|
|
3
|
+
This module provides a reusable client-credentials token provider.
|
|
4
|
+
Adapters authenticate to the entitlements service with a Keycloak-issued
|
|
5
|
+
bearer token; acquiring and refreshing that token is shared platform
|
|
6
|
+
plumbing.
|
|
7
|
+
|
|
8
|
+
Public surface:
|
|
9
|
+
|
|
10
|
+
- ``TokenProvider`` — structural Protocol (single ``get_token()`` method).
|
|
11
|
+
- ``ClientCredentialsTokenProvider`` — concrete provider that runs the
|
|
12
|
+
client_credentials grant, caches the token, and refreshes before expiry.
|
|
13
|
+
- ``TokenAcquisitionError`` — raised when token acquisition fails.
|
|
14
|
+
|
|
15
|
+
Import as::
|
|
16
|
+
|
|
17
|
+
from lightweb_adapter_sdk.auth import (
|
|
18
|
+
ClientCredentialsTokenProvider,
|
|
19
|
+
TokenAcquisitionError,
|
|
20
|
+
TokenProvider,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
The SDK takes **no new runtime dependency** in this module — the token
|
|
24
|
+
provider receives its HTTP client via injection (see the
|
|
25
|
+
``HttpClient`` Protocol in ``auth.protocol``).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from lightweb_adapter_sdk.auth.errors import TokenAcquisitionError
|
|
29
|
+
from lightweb_adapter_sdk.auth.protocol import (
|
|
30
|
+
HttpClient,
|
|
31
|
+
HttpResponse,
|
|
32
|
+
TokenProvider,
|
|
33
|
+
)
|
|
34
|
+
from lightweb_adapter_sdk.auth.provider import ClientCredentialsTokenProvider
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"ClientCredentialsTokenProvider",
|
|
38
|
+
"HttpClient",
|
|
39
|
+
"HttpResponse",
|
|
40
|
+
"TokenAcquisitionError",
|
|
41
|
+
"TokenProvider",
|
|
42
|
+
]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Authentication error types for the auth module.
|
|
2
|
+
|
|
3
|
+
See ``docs/contract.md`` for the overall adapter contract.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TokenAcquisitionError(Exception):
|
|
10
|
+
"""Raised when the token provider cannot acquire a bearer token.
|
|
11
|
+
|
|
12
|
+
Covers non-2xx HTTP responses, missing ``access_token`` in the
|
|
13
|
+
response body, and transport errors surfaced by the injected HTTP
|
|
14
|
+
client. Adapter code should catch this rather than letting a raw
|
|
15
|
+
HTTP exception propagate.
|
|
16
|
+
"""
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Protocols for the auth module.
|
|
2
|
+
|
|
3
|
+
This module defines two structural Protocols:
|
|
4
|
+
|
|
5
|
+
- ``HttpClient`` — a narrow interface describing the async HTTP client
|
|
6
|
+
the token provider calls (a single ``post`` method).
|
|
7
|
+
- ``TokenProvider`` — the public surface of a token provider, a single
|
|
8
|
+
``get_token`` method.
|
|
9
|
+
|
|
10
|
+
Both are ``@runtime_checkable`` structural Protocols, not base classes.
|
|
11
|
+
See ``src/lightweb_adapter_sdk/event_consumer/protocol.py`` for the
|
|
12
|
+
house style.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import time
|
|
19
|
+
from typing import Any, Awaitable, Callable, Protocol, runtime_checkable
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@runtime_checkable
|
|
23
|
+
class HttpClient(Protocol):
|
|
24
|
+
"""Narrow structural Protocol for the injected async HTTP client.
|
|
25
|
+
|
|
26
|
+
This is a structural Protocol, not a base class. Any async HTTP
|
|
27
|
+
client whose ``post`` method matches the signature below satisfies
|
|
28
|
+
the contract — the SDK does not import ``httpx`` or any other
|
|
29
|
+
HTTP library.
|
|
30
|
+
|
|
31
|
+
The provider calls exactly one method on the injected client:
|
|
32
|
+
``post(url, data={...})`` with form-encoded body, expecting a
|
|
33
|
+
response with ``.status_code`` and ``.json()``.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
async def post(
|
|
37
|
+
self,
|
|
38
|
+
url: str,
|
|
39
|
+
*,
|
|
40
|
+
data: dict[str, str],
|
|
41
|
+
) -> HttpResponse:
|
|
42
|
+
"""Send a form-encoded POST and return the response.
|
|
43
|
+
|
|
44
|
+
The caller passes ``data={"grant_type": "client_credentials", ...}``
|
|
45
|
+
and reads ``response.json()["access_token"]`` on success.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@runtime_checkable
|
|
50
|
+
class HttpResponse(Protocol):
|
|
51
|
+
"""Response shape returned by ``HttpClient.post``.
|
|
52
|
+
|
|
53
|
+
Structural Protocol — any object with these attributes satisfies
|
|
54
|
+
the contract regardless of its concrete type.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def status_code(self) -> int:
|
|
59
|
+
"""HTTP status code (e.g. 200, 401, 500)."""
|
|
60
|
+
|
|
61
|
+
def json(self) -> dict[str, Any]:
|
|
62
|
+
"""Parse and return the response body as a dict.
|
|
63
|
+
|
|
64
|
+
Returns a mixed-type dict — the Keycloak token endpoint
|
|
65
|
+
returns ``access_token`` (str), ``expires_in`` (int), etc.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def raise_for_status(self) -> None:
|
|
69
|
+
"""Raise an exception for non-2xx status codes."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@runtime_checkable
|
|
73
|
+
class TokenProvider(Protocol):
|
|
74
|
+
"""Protocol defining the token-provider contract.
|
|
75
|
+
|
|
76
|
+
This is a structural Protocol, not a base class. Adapter authors
|
|
77
|
+
can type against this interface without depending on the concrete
|
|
78
|
+
implementation.
|
|
79
|
+
|
|
80
|
+
The single method ``get_token()`` returns a bearer token string.
|
|
81
|
+
Implementations are responsible for caching and refreshing as
|
|
82
|
+
needed.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
async def get_token(self) -> str:
|
|
86
|
+
"""Return a valid bearer token string.
|
|
87
|
+
|
|
88
|
+
Implementations cache the token and refresh it before expiry.
|
|
89
|
+
On acquisition failure, a ``TokenAcquisitionError`` is raised
|
|
90
|
+
rather than returning an empty or invalid token.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class _RefreshGuard:
|
|
95
|
+
"""Internal helper: ensures only one refresh runs at a time.
|
|
96
|
+
|
|
97
|
+
Wraps an async acquisition callable with an ``asyncio.Lock`` so
|
|
98
|
+
that concurrent callers near expiry trigger a single re-acquire
|
|
99
|
+
rather than a stampede.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
def __init__(
|
|
103
|
+
self,
|
|
104
|
+
acquire: Callable[[], Awaitable[tuple[str, float]]],
|
|
105
|
+
refresh_margin: float,
|
|
106
|
+
) -> None:
|
|
107
|
+
self._acquire = acquire
|
|
108
|
+
self._margin = refresh_margin
|
|
109
|
+
self._lock = asyncio.Lock()
|
|
110
|
+
self._token: str | None = None
|
|
111
|
+
self._expires_at: float = 0.0
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def token(self) -> str | None:
|
|
115
|
+
return self._token
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def expires_at(self) -> float:
|
|
119
|
+
return self._expires_at
|
|
120
|
+
|
|
121
|
+
def is_expired(self) -> bool:
|
|
122
|
+
"""Return True if the cached token is gone or past the refresh margin."""
|
|
123
|
+
if self._token is None:
|
|
124
|
+
return True
|
|
125
|
+
return time.monotonic() >= self._expires_at - self._margin
|
|
126
|
+
|
|
127
|
+
async def get_or_refresh(self) -> str:
|
|
128
|
+
"""Return cached token or acquire a new one (with lock guard)."""
|
|
129
|
+
if not self.is_expired():
|
|
130
|
+
assert self._token is not None
|
|
131
|
+
return self._token
|
|
132
|
+
|
|
133
|
+
async with self._lock:
|
|
134
|
+
# Double-check after acquiring the lock — another caller may
|
|
135
|
+
# have refreshed while we waited. Use actual expiry (not
|
|
136
|
+
# margin-adjusted) because the margin is for the fast-path
|
|
137
|
+
# outside the lock; inside the lock we just need to know if
|
|
138
|
+
# the token is still actually valid.
|
|
139
|
+
if self._token is not None and self._expires_at > time.monotonic():
|
|
140
|
+
return self._token
|
|
141
|
+
|
|
142
|
+
token, expires_in = await self._acquire()
|
|
143
|
+
self._token = token
|
|
144
|
+
self._expires_at = time.monotonic() + expires_in
|
|
145
|
+
return token
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Client-credentials token provider.
|
|
2
|
+
|
|
3
|
+
Acquires and caches OAuth2 bearer tokens via the client_credentials
|
|
4
|
+
grant. Tokens are refreshed before expiry to avoid handing out
|
|
5
|
+
near-expired tokens to downstream requests.
|
|
6
|
+
|
|
7
|
+
See ``docs/contract.md`` for the overall adapter contract.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
|
|
14
|
+
from lightweb_adapter_sdk.auth.errors import TokenAcquisitionError
|
|
15
|
+
from lightweb_adapter_sdk.auth.protocol import (
|
|
16
|
+
HttpClient,
|
|
17
|
+
TokenProvider,
|
|
18
|
+
_RefreshGuard,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# Default expires_in fallback when the token endpoint omits the field.
|
|
22
|
+
# Keycloak defaults to 300s (5 min); this mirrors that convention.
|
|
23
|
+
_DEFAULT_EXPIRES_IN_SECONDS: float = 300.0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ClientCredentialsTokenProvider(TokenProvider):
|
|
27
|
+
"""OAuth2 client-credentials token provider.
|
|
28
|
+
|
|
29
|
+
Constructs bearer tokens by POSTing form-encoded credentials to an
|
|
30
|
+
OAuth2 token endpoint. The acquired token is cached and returned
|
|
31
|
+
on subsequent ``get_token()`` calls until it is within
|
|
32
|
+
``refresh_margin_seconds`` of expiry, at which point a new token
|
|
33
|
+
is acquired.
|
|
34
|
+
|
|
35
|
+
The provider takes an **injected** async HTTP client rather than
|
|
36
|
+
importing an HTTP library directly, keeping the SDK's runtime
|
|
37
|
+
dependency surface at ``pydantic`` + ``nats-py``. Any async HTTP
|
|
38
|
+
client whose ``post`` method matches the ``HttpClient`` Protocol
|
|
39
|
+
(a single ``post(url, data={...})`` returning an object with
|
|
40
|
+
``.status_code``, ``.json()``) is accepted.
|
|
41
|
+
|
|
42
|
+
Concurrency: ``get_token()`` is safe for concurrent callers. When
|
|
43
|
+
the cached token is expired, a single re-acquisition is performed
|
|
44
|
+
under an ``asyncio.Lock``; all concurrent callers wait for and
|
|
45
|
+
receive the same refreshed token.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
token_endpoint: The OAuth2 token endpoint URL.
|
|
49
|
+
client_id: The client identifier.
|
|
50
|
+
client_secret: The client secret.
|
|
51
|
+
http_client: An injected async HTTP client satisfying the
|
|
52
|
+
``HttpClient`` Protocol.
|
|
53
|
+
refresh_margin_seconds: Seconds before expiry to trigger a
|
|
54
|
+
refresh. The default is ``30.0`` — this covers the common
|
|
55
|
+
case where a token has a ~5-minute lifetime and a downstream
|
|
56
|
+
request may take several seconds; handing back a token with
|
|
57
|
+
< 30 s remaining would cause latent failures. Tune this
|
|
58
|
+
value if your token lifetime or request latency profile
|
|
59
|
+
differs.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
token_endpoint: str,
|
|
65
|
+
client_id: str,
|
|
66
|
+
client_secret: str,
|
|
67
|
+
http_client: HttpClient,
|
|
68
|
+
refresh_margin_seconds: float = 30.0,
|
|
69
|
+
) -> None:
|
|
70
|
+
self._endpoint = token_endpoint
|
|
71
|
+
self._client_id = client_id
|
|
72
|
+
self._client_secret = client_secret
|
|
73
|
+
self._http_client = http_client
|
|
74
|
+
self._margin = refresh_margin_seconds
|
|
75
|
+
self._guard: _RefreshGuard | None = None
|
|
76
|
+
self._init_lock = asyncio.Lock()
|
|
77
|
+
|
|
78
|
+
async def _acquire(self) -> tuple[str, float]:
|
|
79
|
+
"""POST the client_credentials grant and return (token, expires_in)."""
|
|
80
|
+
try:
|
|
81
|
+
resp = await self._http_client.post(
|
|
82
|
+
self._endpoint,
|
|
83
|
+
data={
|
|
84
|
+
"grant_type": "client_credentials",
|
|
85
|
+
"client_id": self._client_id,
|
|
86
|
+
"client_secret": self._client_secret,
|
|
87
|
+
},
|
|
88
|
+
)
|
|
89
|
+
except Exception as exc:
|
|
90
|
+
raise TokenAcquisitionError(
|
|
91
|
+
f"HTTP request to token endpoint failed: {exc}"
|
|
92
|
+
) from exc
|
|
93
|
+
|
|
94
|
+
if resp.status_code < 200 or resp.status_code >= 300:
|
|
95
|
+
raise TokenAcquisitionError(
|
|
96
|
+
f"Token endpoint returned status {resp.status_code}"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
body = resp.json()
|
|
100
|
+
token = body.get("access_token")
|
|
101
|
+
if not token:
|
|
102
|
+
raise TokenAcquisitionError("Token response missing 'access_token' field")
|
|
103
|
+
expires_in: float = float(body.get("expires_in", _DEFAULT_EXPIRES_IN_SECONDS))
|
|
104
|
+
return (token, expires_in)
|
|
105
|
+
|
|
106
|
+
async def get_token(self) -> str:
|
|
107
|
+
"""Return a valid bearer token, acquiring or refreshing as needed.
|
|
108
|
+
|
|
109
|
+
On first call, acquires a token via the client_credentials grant.
|
|
110
|
+
On subsequent calls, returns the cached token unless it is within
|
|
111
|
+
``refresh_margin_seconds`` of expiry — then re-acquires.
|
|
112
|
+
|
|
113
|
+
Raises:
|
|
114
|
+
TokenAcquisitionError: If the token endpoint returns a
|
|
115
|
+
non-2xx response, the response body lacks
|
|
116
|
+
``access_token``, or a transport error occurs.
|
|
117
|
+
"""
|
|
118
|
+
if self._guard is None:
|
|
119
|
+
async with self._init_lock:
|
|
120
|
+
# Double-check after acquiring the init lock — another
|
|
121
|
+
# concurrent caller may have created the guard while we
|
|
122
|
+
# waited.
|
|
123
|
+
if self._guard is None:
|
|
124
|
+
self._guard = _RefreshGuard(
|
|
125
|
+
self._acquire,
|
|
126
|
+
self._margin,
|
|
127
|
+
)
|
|
128
|
+
return await self._guard.get_or_refresh()
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Envelope parser: raw JetStream payload → EventEnvelope.
|
|
2
|
+
|
|
3
|
+
The platform publishes JetStream messages as JSON in the entitlements
|
|
4
|
+
event-envelope shape (per ``libs/events/schemas/entitlements/_envelope.json``,
|
|
5
|
+
Conventions §4.3, emitted by the entitlements service's
|
|
6
|
+
``outbox/envelope.py::build_envelope``):
|
|
7
|
+
|
|
8
|
+
.. code-block:: json
|
|
9
|
+
|
|
10
|
+
{
|
|
11
|
+
"event_id": "evt_<base58>",
|
|
12
|
+
"event_type": "entitlements.<entity>.<verb>",
|
|
13
|
+
"event_version": "1.0",
|
|
14
|
+
"occurred_at": "2026-05-17T12:00:00Z",
|
|
15
|
+
"emitted_by": "service:entitlements",
|
|
16
|
+
"subject": {"type": "member", "id": "mem_..."},
|
|
17
|
+
"data": {"member_id": "mem_...", ...},
|
|
18
|
+
"context": {...}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
This module parses that shape into the SDK's ``EventEnvelope``,
|
|
22
|
+
flattening ``data.member_id`` to the top level and omitting ``subject``
|
|
23
|
+
and ``data`` (beyond ``member_id``). ``subject`` is tolerated and
|
|
24
|
+
ignored — the platform always emits it (the schema requires it), but
|
|
25
|
+
the consumer's hook is ``data.member_id``, not ``subject.id`` (subject's
|
|
26
|
+
``id`` varies by event type: member_id for member events, subscription_id
|
|
27
|
+
for subscription events, etc.).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
from datetime import datetime, timezone
|
|
34
|
+
|
|
35
|
+
from lightweb_adapter_sdk.types import EventEnvelope
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ParseError(ValueError):
|
|
39
|
+
"""The raw payload could not be parsed into an EventEnvelope."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_envelope(raw_payload: bytes) -> EventEnvelope:
|
|
43
|
+
"""Parse a raw JetStream message payload into an ``EventEnvelope``.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
raw_payload: The raw bytes from JetStream, expected to be
|
|
47
|
+
UTF-8 encoded JSON matching the entitlements event-envelope
|
|
48
|
+
schema (``_envelope.json``).
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
A fully-populated ``EventEnvelope``.
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
ParseError: If the payload is not valid JSON, is missing
|
|
55
|
+
required fields, or has an unexpected shape.
|
|
56
|
+
"""
|
|
57
|
+
try:
|
|
58
|
+
data = json.loads(raw_payload)
|
|
59
|
+
except (json.JSONDecodeError, TypeError) as exc:
|
|
60
|
+
raise ParseError(f"Payload is not valid JSON: {exc}") from exc
|
|
61
|
+
|
|
62
|
+
if not isinstance(data, dict):
|
|
63
|
+
raise ParseError(
|
|
64
|
+
f"Payload must be a JSON object, got {type(data).__name__}"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# Required top-level fields, per _envelope.json. ``subject`` and
|
|
68
|
+
# ``data`` are also schema-required but handled separately below
|
|
69
|
+
# (subject is tolerated-and-ignored; data is read for member_id).
|
|
70
|
+
for field in (
|
|
71
|
+
"event_id",
|
|
72
|
+
"event_type",
|
|
73
|
+
"event_version",
|
|
74
|
+
"occurred_at",
|
|
75
|
+
"emitted_by",
|
|
76
|
+
):
|
|
77
|
+
if field not in data:
|
|
78
|
+
raise ParseError(f"Missing required field: {field}")
|
|
79
|
+
|
|
80
|
+
# data.member_id is the source for EventEnvelope.member_id.
|
|
81
|
+
if "data" not in data or not isinstance(data["data"], dict):
|
|
82
|
+
raise ParseError("Missing or invalid 'data' field")
|
|
83
|
+
if "member_id" not in data["data"]:
|
|
84
|
+
raise ParseError("Missing 'data.member_id' field")
|
|
85
|
+
|
|
86
|
+
member_id = data["data"]["member_id"]
|
|
87
|
+
if not isinstance(member_id, str):
|
|
88
|
+
raise ParseError(
|
|
89
|
+
f"'data.member_id' must be a string, got {type(member_id).__name__}"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# context is optional and preserved when present. The producer emits
|
|
93
|
+
# an empty object ``{}`` (not null) when there is no context; both an
|
|
94
|
+
# empty dict and an absent/None context are accepted here.
|
|
95
|
+
context = data.get("context")
|
|
96
|
+
if context is not None and not isinstance(context, dict):
|
|
97
|
+
raise ParseError("'context' must be an object when present")
|
|
98
|
+
|
|
99
|
+
# occurred_at — accept ISO-8601 strings (the producer emits with a
|
|
100
|
+
# trailing 'Z'; datetime.fromisoformat handles 'Z' on Python 3.11+).
|
|
101
|
+
occurred_at_str = data["occurred_at"]
|
|
102
|
+
try:
|
|
103
|
+
occurred_at = datetime.fromisoformat(occurred_at_str)
|
|
104
|
+
if occurred_at.tzinfo is None:
|
|
105
|
+
occurred_at = occurred_at.replace(tzinfo=timezone.utc)
|
|
106
|
+
except (ValueError, TypeError) as exc:
|
|
107
|
+
raise ParseError(
|
|
108
|
+
f"Invalid 'occurred_at' field: {occurred_at_str!r}: {exc}"
|
|
109
|
+
) from exc
|
|
110
|
+
|
|
111
|
+
return EventEnvelope(
|
|
112
|
+
event_id=data["event_id"],
|
|
113
|
+
event_type=data["event_type"],
|
|
114
|
+
event_version=data["event_version"],
|
|
115
|
+
occurred_at=occurred_at,
|
|
116
|
+
emitted_by=data["emitted_by"],
|
|
117
|
+
member_id=member_id,
|
|
118
|
+
context=context,
|
|
119
|
+
)
|
|
@@ -8,6 +8,11 @@ Import from this module to test your adapter during development:
|
|
|
8
8
|
- ``FakeEntitlementsService``: an in-process fake of the platform's
|
|
9
9
|
entitlements service for staging state in tests that drive
|
|
10
10
|
``apply()`` or ``reconcile()``.
|
|
11
|
+
- ``FakeEventSource``: an in-process fake of the JetStream event source
|
|
12
|
+
for testing event consumers.
|
|
13
|
+
- ``FakeTokenEndpoint``: an in-process fake of an OAuth2 token endpoint
|
|
14
|
+
for testing the auth provider's token acquisition, caching, and
|
|
15
|
+
refresh-before-expiry behaviour.
|
|
11
16
|
|
|
12
17
|
Adapter authors re-export the test functions in their own
|
|
13
18
|
``tests/test_structural.py`` and provide an ``adapter_under_test``
|
|
@@ -18,6 +23,7 @@ See docs/testkit.md for full usage instructions.
|
|
|
18
23
|
|
|
19
24
|
from lightweb_adapter_sdk.testkit.fake_entitlements import FakeEntitlementsService
|
|
20
25
|
from lightweb_adapter_sdk.testkit.fake_event_source import FakeEventSource
|
|
26
|
+
from lightweb_adapter_sdk.testkit.fake_token_endpoint import FakeTokenEndpoint
|
|
21
27
|
from lightweb_adapter_sdk.testkit.structural import (
|
|
22
28
|
test_capabilities_returns_well_shaped,
|
|
23
29
|
test_health_is_callable,
|
|
@@ -28,6 +34,7 @@ from lightweb_adapter_sdk.testkit.structural import (
|
|
|
28
34
|
__all__ = [
|
|
29
35
|
"FakeEntitlementsService",
|
|
30
36
|
"FakeEventSource",
|
|
37
|
+
"FakeTokenEndpoint",
|
|
31
38
|
"test_satisfies_protocol",
|
|
32
39
|
"test_capabilities_returns_well_shaped",
|
|
33
40
|
"test_health_is_callable",
|
|
@@ -4,6 +4,17 @@ Provides ``FakeEventSource``, an in-process fake that publishes
|
|
|
4
4
|
``EventEnvelope`` instances as raw JetStream-style JSON bytes,
|
|
5
5
|
allowing tests to drive event consumer behavior without a real
|
|
6
6
|
NATS/JetStream connection.
|
|
7
|
+
|
|
8
|
+
The published bytes match the platform's native event-envelope shape
|
|
9
|
+
(per ``libs/events/schemas/entitlements/_envelope.json`` and the
|
|
10
|
+
entitlements service's ``build_envelope``): ``event_id``, ``event_type``,
|
|
11
|
+
``event_version``, ``occurred_at``, ``emitted_by``, ``subject``,
|
|
12
|
+
``data`` (carrying ``member_id``), and ``context``. This is the shape
|
|
13
|
+
``parse_envelope`` consumes, so a round-trip publish→handle_event
|
|
14
|
+
exercises the real parse path. (Prior to 0.2.3 this fake emitted a
|
|
15
|
+
CloudEvents shape — ``id``/``type``/``specversion``/``time``/``source``
|
|
16
|
+
— matching a parser that read the same wrong shape; neither matched the
|
|
17
|
+
platform. See CHANGELOG 0.2.3 / integration finding F8.)
|
|
7
18
|
"""
|
|
8
19
|
|
|
9
20
|
from __future__ import annotations
|
|
@@ -23,6 +34,10 @@ class FakeEventSource:
|
|
|
23
34
|
callbacks (consumers). Events are stored internally for inspection
|
|
24
35
|
and can be published synchronously or asynchronously.
|
|
25
36
|
|
|
37
|
+
The serialized bytes use the platform's native envelope shape so
|
|
38
|
+
that consumers exercising ``parse_envelope`` see exactly what the
|
|
39
|
+
entitlements service emits.
|
|
40
|
+
|
|
26
41
|
Args:
|
|
27
42
|
subject: The subject name this fake source publishes to
|
|
28
43
|
(e.g. ``"entitlements.member.>"``).
|
|
@@ -47,6 +62,28 @@ class FakeEventSource:
|
|
|
47
62
|
self._sequence += 1
|
|
48
63
|
return f"evt-fake-{self._sequence:04d}"
|
|
49
64
|
|
|
65
|
+
def _serialize(self, envelope: EventEnvelope) -> bytes:
|
|
66
|
+
"""Serialize an ``EventEnvelope`` to the platform's native shape.
|
|
67
|
+
|
|
68
|
+
Mirrors the entitlements service's ``build_envelope`` output:
|
|
69
|
+
native field names, a member ``subject`` derived from
|
|
70
|
+
``member_id`` (the consumer ignores ``subject`` but the real
|
|
71
|
+
envelope always carries it), ``member_id`` nested under
|
|
72
|
+
``data``, and ``context`` as an object (``{}`` when empty,
|
|
73
|
+
matching the producer, which never emits null).
|
|
74
|
+
"""
|
|
75
|
+
payload_dict: dict[str, Any] = {
|
|
76
|
+
"event_id": envelope.event_id,
|
|
77
|
+
"event_type": envelope.event_type,
|
|
78
|
+
"event_version": envelope.event_version,
|
|
79
|
+
"occurred_at": envelope.occurred_at.isoformat(),
|
|
80
|
+
"emitted_by": envelope.emitted_by,
|
|
81
|
+
"subject": {"type": "member", "id": envelope.member_id},
|
|
82
|
+
"data": {"member_id": envelope.member_id},
|
|
83
|
+
"context": envelope.context if envelope.context is not None else {},
|
|
84
|
+
}
|
|
85
|
+
return json.dumps(payload_dict).encode("utf-8")
|
|
86
|
+
|
|
50
87
|
def publish(self, envelope: EventEnvelope) -> bytes:
|
|
51
88
|
"""Publish an event envelope as raw JSON bytes.
|
|
52
89
|
|
|
@@ -58,18 +95,7 @@ class FakeEventSource:
|
|
|
58
95
|
Returns:
|
|
59
96
|
The raw JSON bytes that were published.
|
|
60
97
|
"""
|
|
61
|
-
|
|
62
|
-
"id": envelope.event_id,
|
|
63
|
-
"type": envelope.event_type,
|
|
64
|
-
"specversion": envelope.event_version,
|
|
65
|
-
"time": envelope.occurred_at.isoformat(),
|
|
66
|
-
"source": envelope.emitted_by,
|
|
67
|
-
"data": {"member_id": envelope.member_id},
|
|
68
|
-
}
|
|
69
|
-
if envelope.context is not None:
|
|
70
|
-
payload_dict["context"] = envelope.context
|
|
71
|
-
|
|
72
|
-
raw = json.dumps(payload_dict).encode("utf-8")
|
|
98
|
+
raw = self._serialize(envelope)
|
|
73
99
|
self._published.append((envelope, raw))
|
|
74
100
|
|
|
75
101
|
for consumer in self._consumers:
|
|
@@ -88,18 +114,7 @@ class FakeEventSource:
|
|
|
88
114
|
Returns:
|
|
89
115
|
The raw JSON bytes that were published.
|
|
90
116
|
"""
|
|
91
|
-
|
|
92
|
-
"id": envelope.event_id,
|
|
93
|
-
"type": envelope.event_type,
|
|
94
|
-
"specversion": envelope.event_version,
|
|
95
|
-
"time": envelope.occurred_at.isoformat(),
|
|
96
|
-
"source": envelope.emitted_by,
|
|
97
|
-
"data": {"member_id": envelope.member_id},
|
|
98
|
-
}
|
|
99
|
-
if envelope.context is not None:
|
|
100
|
-
payload_dict["context"] = envelope.context
|
|
101
|
-
|
|
102
|
-
raw = json.dumps(payload_dict).encode("utf-8")
|
|
117
|
+
raw = self._serialize(envelope)
|
|
103
118
|
self._published.append((envelope, raw))
|
|
104
119
|
|
|
105
120
|
for consumer in self._consumers:
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Fake token endpoint for testing the auth provider.
|
|
2
|
+
|
|
3
|
+
Provides ``FakeTokenEndpoint``, an in-process fake that satisfies the
|
|
4
|
+
``HttpClient`` Protocol structurally. Tests use it to stage token
|
|
5
|
+
responses, assert caching behaviour, and exercise refresh-before-expiry
|
|
6
|
+
and clean-failure paths.
|
|
7
|
+
|
|
8
|
+
See docs/testkit.md for usage instructions.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any
|
|
14
|
+
import asyncio
|
|
15
|
+
|
|
16
|
+
class _FakeResponse:
|
|
17
|
+
"""In-memory response returned by ``FakeTokenEndpoint.post``.
|
|
18
|
+
|
|
19
|
+
Structural match for ``httpx.Response`` as consumed by the provider:
|
|
20
|
+
``.status_code``, ``.json()``, ``.raise_for_status()``.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
status_code: int,
|
|
26
|
+
body: dict[str, Any],
|
|
27
|
+
) -> None:
|
|
28
|
+
self._status_code = status_code
|
|
29
|
+
self._body = body
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def status_code(self) -> int:
|
|
33
|
+
return self._status_code
|
|
34
|
+
|
|
35
|
+
def json(self) -> dict[str, Any]:
|
|
36
|
+
return dict(self._body)
|
|
37
|
+
|
|
38
|
+
def raise_for_status(self) -> None:
|
|
39
|
+
if self._status_code < 200 or self._status_code >= 300:
|
|
40
|
+
raise RuntimeError(f"HTTP {self._status_code}: {self._body}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class FakeTokenEndpoint:
|
|
44
|
+
"""In-process fake of an OAuth2 token endpoint.
|
|
45
|
+
|
|
46
|
+
Satisfies the ``HttpClient`` Protocol structurally so it can be
|
|
47
|
+
injected into ``ClientCredentialsTokenProvider``. Each call to
|
|
48
|
+
``post`` returns a canned token response (or error) determined by
|
|
49
|
+
the staged state.
|
|
50
|
+
|
|
51
|
+
Tests use this to:
|
|
52
|
+
- Stage the token value(s) it returns and the ``expires_in``.
|
|
53
|
+
- Count how many times it was called (to assert caching).
|
|
54
|
+
- Return a near-expiry / expired token so a test can assert
|
|
55
|
+
refresh-before-expiry triggers re-acquisition.
|
|
56
|
+
- Return an error response (non-2xx / missing ``access_token``)
|
|
57
|
+
so a test can assert clean failure.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
access_token: The token value returned by ``post``.
|
|
61
|
+
expires_in: Seconds until expiry (default 300).
|
|
62
|
+
status_code: HTTP status code to return (default 200).
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
access_token: str = "fake-token-00000000000000000000000000000000",
|
|
68
|
+
expires_in: int = 300,
|
|
69
|
+
status_code: int = 200,
|
|
70
|
+
) -> None:
|
|
71
|
+
self._access_token = access_token
|
|
72
|
+
self._expires_in = expires_in
|
|
73
|
+
self._status_code = status_code
|
|
74
|
+
self._call_count = 0
|
|
75
|
+
|
|
76
|
+
async def post(
|
|
77
|
+
self,
|
|
78
|
+
url: str,
|
|
79
|
+
*,
|
|
80
|
+
data: dict[str, str],
|
|
81
|
+
) -> _FakeResponse:
|
|
82
|
+
"""Return the staged token response and increment the call count.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
url: The token endpoint URL (ignored; the fake accepts any URL).
|
|
86
|
+
data: The form-encoded data sent by the provider
|
|
87
|
+
(grant_type, client_id, client_secret).
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
A ``_FakeResponse`` with the staged status code and body.
|
|
91
|
+
"""
|
|
92
|
+
await asyncio.sleep(0)
|
|
93
|
+
self._call_count += 1
|
|
94
|
+
body: dict[str, Any] = {
|
|
95
|
+
"access_token": self._access_token,
|
|
96
|
+
"token_type": "Bearer",
|
|
97
|
+
"expires_in": self._expires_in,
|
|
98
|
+
}
|
|
99
|
+
return _FakeResponse(self._status_code, body)
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def call_count(self) -> int:
|
|
103
|
+
"""Number of times ``post`` was called."""
|
|
104
|
+
return self._call_count
|
|
105
|
+
|
|
106
|
+
def stage_token(
|
|
107
|
+
self,
|
|
108
|
+
access_token: str,
|
|
109
|
+
expires_in: int = 300,
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Stage a new token value and expiry for the next ``post`` call.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
access_token: The token string to return.
|
|
115
|
+
expires_in: Seconds until expiry (default 300).
|
|
116
|
+
"""
|
|
117
|
+
self._access_token = access_token
|
|
118
|
+
self._expires_in = expires_in
|
|
119
|
+
|
|
120
|
+
def stage_error(
|
|
121
|
+
self,
|
|
122
|
+
status_code: int = 401,
|
|
123
|
+
error_body: dict[str, Any] | None = None,
|
|
124
|
+
) -> None:
|
|
125
|
+
"""Stage an error response for the next ``post`` call.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
status_code: HTTP status code (e.g. 401, 500).
|
|
129
|
+
error_body: Optional body dict (defaults to
|
|
130
|
+
``{"error": "invalid_client"}``).
|
|
131
|
+
"""
|
|
132
|
+
self._status_code = status_code
|
|
133
|
+
self._access_token = ""
|
|
134
|
+
self._expires_in = 0
|
|
135
|
+
if error_body is not None:
|
|
136
|
+
self._error_body = error_body
|
|
137
|
+
else:
|
|
138
|
+
self._error_body = {"error": "invalid_client"}
|
|
139
|
+
|
|
140
|
+
def reset(self) -> None:
|
|
141
|
+
"""Reset call count and restore default token."""
|
|
142
|
+
self._call_count = 0
|
|
143
|
+
self.stage_token("fake-token-00000000000000000000000000000000")
|
|
144
|
+
self._expires_in = 300
|
|
145
|
+
self._status_code = 200
|
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
"""Envelope parser: raw JetStream payload → EventEnvelope.
|
|
2
|
-
|
|
3
|
-
The platform publishes JetStream messages as JSON with the following
|
|
4
|
-
shape (per ``libs/events/schemas/entitlements/_envelope.json``):
|
|
5
|
-
|
|
6
|
-
.. code-block:: json
|
|
7
|
-
|
|
8
|
-
{
|
|
9
|
-
"id": "<event-id>",
|
|
10
|
-
"type": "entitlements.<entity>.<verb>",
|
|
11
|
-
"specversion": "1.0",
|
|
12
|
-
"time": "2026-05-17T12:00:00Z",
|
|
13
|
-
"source": "service:entitlements",
|
|
14
|
-
"subject": {"type": "member", "id": "mem_..."},
|
|
15
|
-
"data": {"member_id": "mem_...", ...},
|
|
16
|
-
"context": {"trace_context": "..."}
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
This module parses that shape into the SDK's ``EventEnvelope``,
|
|
20
|
-
flattening ``data.member_id`` to the top level and omitting ``subject``
|
|
21
|
-
and ``data`` (beyond ``member_id``).
|
|
22
|
-
"""
|
|
23
|
-
|
|
24
|
-
from __future__ import annotations
|
|
25
|
-
|
|
26
|
-
import json
|
|
27
|
-
from datetime import datetime, timezone
|
|
28
|
-
|
|
29
|
-
from lightweb_adapter_sdk.types import EventEnvelope
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
class ParseError(ValueError):
|
|
33
|
-
"""The raw payload could not be parsed into an EventEnvelope."""
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def parse_envelope(raw_payload: bytes) -> EventEnvelope:
|
|
37
|
-
"""Parse a raw JetStream message payload into an ``EventEnvelope``.
|
|
38
|
-
|
|
39
|
-
Args:
|
|
40
|
-
raw_payload: The raw bytes from JetStream, expected to be
|
|
41
|
-
UTF-8 encoded JSON matching the platform envelope schema.
|
|
42
|
-
|
|
43
|
-
Returns:
|
|
44
|
-
A fully-populated ``EventEnvelope``.
|
|
45
|
-
|
|
46
|
-
Raises:
|
|
47
|
-
ParseError: If the payload is not valid JSON, is missing
|
|
48
|
-
required fields, or has an unexpected shape.
|
|
49
|
-
"""
|
|
50
|
-
try:
|
|
51
|
-
data = json.loads(raw_payload)
|
|
52
|
-
except (json.JSONDecodeError, TypeError) as exc:
|
|
53
|
-
raise ParseError(f"Payload is not valid JSON: {exc}") from exc
|
|
54
|
-
|
|
55
|
-
# Required top-level fields
|
|
56
|
-
for field in ("id", "type", "specversion", "time", "source"):
|
|
57
|
-
if field not in data:
|
|
58
|
-
raise ParseError(f"Missing required field: {field}")
|
|
59
|
-
|
|
60
|
-
# data.member_id is the source for EventEnvelope.member_id
|
|
61
|
-
if "data" not in data or not isinstance(data["data"], dict):
|
|
62
|
-
raise ParseError("Missing or invalid 'data' field")
|
|
63
|
-
if "member_id" not in data["data"]:
|
|
64
|
-
raise ParseError("Missing 'data.member_id' field")
|
|
65
|
-
|
|
66
|
-
member_id = data["data"]["member_id"]
|
|
67
|
-
if not isinstance(member_id, str):
|
|
68
|
-
raise ParseError(
|
|
69
|
-
f"'data.member_id' must be a string, got {type(member_id).__name__}"
|
|
70
|
-
)
|
|
71
|
-
|
|
72
|
-
# context is optional but preserved when present
|
|
73
|
-
context = data.get("context")
|
|
74
|
-
if context is not None and not isinstance(context, dict):
|
|
75
|
-
raise ParseError("'context' must be an object when present")
|
|
76
|
-
|
|
77
|
-
# Parse time — accept ISO-8601 strings
|
|
78
|
-
time_str = data["time"]
|
|
79
|
-
try:
|
|
80
|
-
occurred_at = datetime.fromisoformat(time_str)
|
|
81
|
-
if occurred_at.tzinfo is None:
|
|
82
|
-
occurred_at = occurred_at.replace(tzinfo=timezone.utc)
|
|
83
|
-
except (ValueError, TypeError) as exc:
|
|
84
|
-
raise ParseError(f"Invalid 'time' field: {time_str}: {exc}") from exc
|
|
85
|
-
|
|
86
|
-
return EventEnvelope(
|
|
87
|
-
event_id=data["id"],
|
|
88
|
-
event_type=data["type"],
|
|
89
|
-
event_version=data["specversion"],
|
|
90
|
-
occurred_at=occurred_at,
|
|
91
|
-
emitted_by=data["source"],
|
|
92
|
-
member_id=member_id,
|
|
93
|
-
context=context,
|
|
94
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lightweb_adapter_sdk-0.2.2 → lightweb_adapter_sdk-0.2.4}/src/lightweb_adapter_sdk/types/__init__.py
RENAMED
|
File without changes
|