mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
one credential, one subject: the scope rides the key, not the argument
An operator holds one key and serves N customers. Until now every call it made on a customer's behalf was indistinguishable from every other, and nothing in the SDK could tell them apart. `Client.as_(subject)` binds a client to one of them. IAM mints a short-lived token for that subject — POST hanzo.id/v1/iam/tokens/issue?id=…, on IAM's own host, the subject in the query, the grant read off the key — and the scoped client sends it on every call, holds it until it nears expiry, and re-mints once on a 401. No operation takes a user id, so there is none to pass wrongly. The other half is the answer a policy holds for a human. It arrives as 202, and 202 is a 2xx: a caller who only checks for a raise reads a queued call as one that completed and returned nothing, because no operation declares a schema for it. `Client` raises `Held` instead, carrying the approval that gates it, and `result()` hands the same outcome back as a value whose two arms share no member — an `Approval` has no `value`, a `Done` has no `id`. Discrimination is on the body, not the status: the dozen long-running operations whose 202 means "accepted" carry their own schema and pass straight through. `Client` is the generated `ApiClient` plus those two behaviours, so all 196 generated API classes take it unchanged and there is one client, not two. 25 tests against a stub with `RESTClientObject`'s signature — the only thing the mint and the client ever call — pin the mint URL and query, the camelCase response, the cache and its skew, the re-mint on 401, and that a hold raises.
This commit is contained in:
@@ -37,9 +37,10 @@ https://api.hanzo.ai serves 112 models, no credential required
|
||||
|
||||
```python
|
||||
import os
|
||||
from hanzoai.cloud import ApiClient, Configuration, KeysApi
|
||||
from hanzoai import Client
|
||||
from hanzoai.cloud import Configuration, KeysApi
|
||||
|
||||
client = ApiClient(Configuration(
|
||||
client = Client(Configuration(
|
||||
host="https://api.hanzo.ai",
|
||||
access_token=os.environ["HANZO_API_KEY"],
|
||||
))
|
||||
@@ -50,7 +51,9 @@ with client as api:
|
||||
```
|
||||
|
||||
Every route follows that shape: one `*Api` class per tag, one method per
|
||||
operation, typed models in and out.
|
||||
operation, typed models in and out. `Client` is the generated `ApiClient` plus
|
||||
the two behaviours below — scoping and the held result — so it goes anywhere an
|
||||
`ApiClient` goes.
|
||||
|
||||
## Auth
|
||||
|
||||
@@ -82,6 +85,61 @@ hand-written `hanzoai.zap` and `hanzoai.config` read `HANZO_ZAP_ENDPOINT` and
|
||||
is where `HANZO_API_KEY` and `HANZO_BASE_URL` get resolved — one place, for all
|
||||
six flows.
|
||||
|
||||
## Tenants
|
||||
|
||||
If you are building on top of Hanzo, you hold one key and your customers hold
|
||||
none. `as_` binds a client to one of them:
|
||||
|
||||
```python
|
||||
from hanzoai import Client
|
||||
from hanzoai.cloud import Configuration, MemoryApi
|
||||
|
||||
hanzo = Client(Configuration(access_token=os.environ["HANZO_API_KEY"]))
|
||||
memory = MemoryApi(hanzo.as_("user_42")) # a subject id, or your own externalId
|
||||
```
|
||||
|
||||
IAM mints a short-lived token bound to that subject — `POST
|
||||
https://hanzo.id/v1/iam/tokens/issue?id=user_42`, on IAM's own host, reading the
|
||||
grant off your key — and the scoped client sends it on every call, keeps it
|
||||
until it nears expiry, and re-mints once on a 401. No operation takes a user id,
|
||||
so there is none to pass wrongly and none to forget. `Client(..., issuer=...)`
|
||||
points the mint at a private estate.
|
||||
|
||||
The spelling is `as_` rather than `as` only because `as` is a keyword; the
|
||||
trailing underscore is what PEP 8 prescribes, and it keeps the platform's one
|
||||
word for this from growing a synonym in Python.
|
||||
|
||||
### Held calls
|
||||
|
||||
A call your policy holds for a human decision answers `202` with the approval —
|
||||
`{"status": "held", "id", "clause", "reason"}`, the same shape
|
||||
`GET /v1/approvals/{id}` returns. A 202 is a 2xx, so a client that only checks
|
||||
for a raise reads a queued call as a completed one. This one raises:
|
||||
|
||||
```python
|
||||
from hanzoai import Held, is_held, result, unwrap
|
||||
|
||||
try:
|
||||
fact = memory.post_memory_remember(body)
|
||||
except Held as held:
|
||||
print(held.approval.id, held.approval.clause)
|
||||
```
|
||||
|
||||
Or take the outcome as a value, when handling the hold is the normal path:
|
||||
|
||||
```python
|
||||
r = result(memory.post_memory_remember, body)
|
||||
if is_held(r):
|
||||
print(r.id, r.clause, r.reason) # queued — nothing ran
|
||||
else:
|
||||
fact = unwrap(r) # raises Held rather than return a hold
|
||||
```
|
||||
|
||||
`Approval` has no `value` and `Done` has no `id`, so there is no member you can
|
||||
read off the wrong one. The dozen long-running operations whose `202` means
|
||||
"accepted, working on it" carry their own schema and pass straight through — the
|
||||
body is the discriminator, not the status code.
|
||||
|
||||
## Examples
|
||||
|
||||
`examples/` carries one directory per flow. Each is a whole path through one part
|
||||
|
||||
+32
-1
@@ -60,4 +60,35 @@ except ImportError: # pragma: no cover - hanzoai[zap] not installed
|
||||
# service and which document a symbol came from.
|
||||
from . import cloud as cloud
|
||||
|
||||
__all__ = ["cloud", "__version__"]
|
||||
# The tenant fabric — hand-written, and the only names this package adds beside
|
||||
# `cloud`. They are here because they belong to no one operation: `Client` is
|
||||
# how an operator credential is scoped to one customer, and `Result` is what an
|
||||
# approval-gated call answers with. Nothing generated is re-exported alongside
|
||||
# them; the namespace still says which document a symbol came from.
|
||||
from .client import Client as Client
|
||||
from .grant import Grant as Grant
|
||||
from .result import (
|
||||
Done as Done,
|
||||
Held as Held,
|
||||
Result as Result,
|
||||
Approval as Approval,
|
||||
is_done as is_done,
|
||||
is_held as is_held,
|
||||
result as result,
|
||||
unwrap as unwrap,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"cloud",
|
||||
"__version__",
|
||||
"Client",
|
||||
"Grant",
|
||||
"Approval",
|
||||
"Done",
|
||||
"Held",
|
||||
"Result",
|
||||
"is_done",
|
||||
"is_held",
|
||||
"result",
|
||||
"unwrap",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"""The client, and the one call that scopes it to a tenant.
|
||||
|
||||
from hanzoai import Client
|
||||
from hanzoai.cloud import Configuration, MemoryApi
|
||||
|
||||
hanzo = Client(Configuration(access_token="sk-...")) # the operator
|
||||
memory = MemoryApi(hanzo.as_("user_42")) # one customer
|
||||
|
||||
`Client` is the generated :class:`~hanzoai.cloud.api_client.ApiClient` plus two
|
||||
behaviours the generator has no way to know about — token scoping and the held
|
||||
result — so every one of the generated API classes takes it unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from hanzoai.grant import ISSUER, Grant
|
||||
from hanzoai.result import Held, Approval
|
||||
from hanzoai.cloud.api_client import ApiClient
|
||||
from hanzoai.cloud.exceptions import ApiException
|
||||
from hanzoai.cloud.configuration import Configuration
|
||||
|
||||
__all__ = ["Client"]
|
||||
|
||||
|
||||
class Client(ApiClient):
|
||||
"""An API client, optionally bound to one subject.
|
||||
|
||||
Construct it with an operator credential and call :meth:`as_` per customer.
|
||||
The returned client mints a subject-bound token from IAM, sends it on every
|
||||
call, and re-mints once on a 401 before giving up — so a rotated or expired
|
||||
token costs a round trip, not an error the caller has to handle.
|
||||
|
||||
:param issuer: where IAM answers, when it is not the public issuer.
|
||||
:param grant: the mint this client sends for. `None` on an operator client.
|
||||
A client that has one writes the live token into its configuration on
|
||||
the way into every request, so it needs a configuration of its own —
|
||||
which is what :meth:`as_` hands it.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
configuration: Optional[Configuration] = None,
|
||||
header_name: Optional[str] = None,
|
||||
header_value: Optional[str] = None,
|
||||
cookie: Optional[str] = None,
|
||||
*,
|
||||
issuer: str = ISSUER,
|
||||
grant: Optional[Grant] = None,
|
||||
) -> None:
|
||||
super().__init__(configuration, header_name, header_value, cookie)
|
||||
self.issuer = issuer
|
||||
self.grant = grant
|
||||
|
||||
def as_(self, subject: str) -> "Client":
|
||||
"""Returns a client scoped to `subject` — a subject id or an externalId.
|
||||
|
||||
Spelled with the trailing underscore PEP 8 prescribes for a name that
|
||||
collides with a keyword, so the platform's one word for this survives
|
||||
into Python instead of growing a synonym here.
|
||||
|
||||
The credential is the scope: no operation on the returned client takes a
|
||||
user id, so there is none to pass wrongly and none to forget.
|
||||
"""
|
||||
key = self.configuration.access_token
|
||||
if not key:
|
||||
raise ApiException(
|
||||
status=0,
|
||||
reason="no operator credential to scope: set Configuration.access_token",
|
||||
)
|
||||
configuration = copy.deepcopy(self.configuration)
|
||||
# The operator key never rides a scoped client. The minted token replaces
|
||||
# it on the way into every request.
|
||||
configuration.access_token = None
|
||||
return Client(
|
||||
configuration,
|
||||
issuer=self.issuer,
|
||||
grant=Grant(key, subject, self.issuer, self.rest_client),
|
||||
)
|
||||
|
||||
# Where the token joins the request. The generated serializer reads
|
||||
# `configuration.access_token` through `auth_settings()`, so refreshing it
|
||||
# here puts the live token on every operation without touching one of them.
|
||||
def param_serialize(self, *args: Any, **kwargs: Any) -> Any:
|
||||
if self.grant is not None:
|
||||
self.configuration.access_token = self.grant.token()
|
||||
return super().param_serialize(*args, **kwargs)
|
||||
|
||||
def call_api(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
header_params: Optional[Dict[str, str]] = None,
|
||||
body: Optional[Any] = None,
|
||||
post_params: Optional[Any] = None,
|
||||
_request_timeout: Optional[Any] = None,
|
||||
) -> Any:
|
||||
response = super().call_api(method, url, header_params, body, post_params, _request_timeout)
|
||||
if response.status == 401 and self.grant is not None:
|
||||
self.grant.invalidate()
|
||||
header_params = dict(header_params or {})
|
||||
header_params["Authorization"] = "Bearer {0}".format(self.grant.token())
|
||||
response = super().call_api(method, url, header_params, body, post_params, _request_timeout)
|
||||
return response
|
||||
|
||||
def response_deserialize(self, response_data: Any, response_types_map: Optional[Any] = None) -> Any:
|
||||
"""A held call raises rather than returning — it produced no value.
|
||||
|
||||
The hold arrives as 202 with ``status: "held"``. No operation declares a
|
||||
schema for that, so the generated deserializer answers it with `None`
|
||||
and the call reads as one that succeeded and returned nothing. The
|
||||
dozen operations whose 202 means "accepted, working on it" carry their
|
||||
own schema and pass straight through.
|
||||
|
||||
Wrap a call in :func:`hanzoai.result` to handle the hold as a value.
|
||||
"""
|
||||
if response_data.status == 202:
|
||||
body = response_data.data
|
||||
if isinstance(body, (bytes, bytearray)):
|
||||
body = body.decode("utf-8", "replace")
|
||||
approval = Approval.held(body)
|
||||
if approval is not None:
|
||||
raise Held(approval, body=body)
|
||||
return super().response_deserialize(response_data, response_types_map)
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Minting the token that lets one operator credential act as one subject.
|
||||
|
||||
An operator holds a single key. Every call it makes on behalf of a customer has
|
||||
to be bound to that customer and nothing else. IAM does the binding: it reads
|
||||
the grant off the key and issues a short-lived token for the subject named in
|
||||
the request, so the credential carries the scope and no method has to take a
|
||||
user id.
|
||||
|
||||
The mint answers on IAM's own host — `api.hanzo.ai` 404s it — and the path
|
||||
carries the `iam` segment. The subject rides as the `id` query; there is no
|
||||
body, because the grant is already on the key.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from hanzoai.cloud.exceptions import ApiException
|
||||
|
||||
__all__ = ["Grant", "ISSUER"]
|
||||
|
||||
#: Where IAM answers. Override for a private estate.
|
||||
ISSUER = "https://hanzo.id"
|
||||
|
||||
#: IAM's canonical mint. The platform API does not serve it.
|
||||
PATH = "/v1/iam/tokens/issue"
|
||||
|
||||
#: Re-mint this many seconds early so a request never rides an about-to-die token.
|
||||
SKEW = 30.0
|
||||
|
||||
#: Lifetime assumed when IAM states none.
|
||||
TTL = 300.0
|
||||
|
||||
|
||||
class Grant:
|
||||
"""A cached, subject-bound token minted from an operator credential.
|
||||
|
||||
Holds the token until it nears expiry and drops it on request, so a 401
|
||||
costs one extra mint rather than a failed call. Not thread-safe by design:
|
||||
the worst a race costs is a duplicate mint.
|
||||
|
||||
:param key: the operator credential IAM reads the grant off.
|
||||
:param subject: a subject id, or the ``externalId`` the operator filed the
|
||||
member under.
|
||||
:param issuer: where IAM answers.
|
||||
:param transport: anything shaped like
|
||||
:class:`hanzoai.cloud.rest.RESTClientObject` — the client passes its own
|
||||
so the mint reuses one connection pool and the SDK's TLS settings.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
key: str,
|
||||
subject: str,
|
||||
issuer: str = ISSUER,
|
||||
transport: Optional[Any] = None,
|
||||
) -> None:
|
||||
self.key = key
|
||||
self.subject = subject
|
||||
self.issuer = issuer.rstrip("/")
|
||||
self._transport = transport
|
||||
self._token: Optional[str] = None
|
||||
self._expires = 0.0
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
"""The mint, subject and all."""
|
||||
return "{0}{1}?{2}".format(self.issuer, PATH, urlencode({"id": self.subject}))
|
||||
|
||||
def token(self) -> str:
|
||||
"""The live token, minting one if the cached token is gone or near expiry."""
|
||||
if self._token is not None and self._expires - time.monotonic() > SKEW:
|
||||
return self._token
|
||||
return self._mint()
|
||||
|
||||
def invalidate(self) -> None:
|
||||
"""Drops the cached token so the next read mints a fresh one."""
|
||||
self._token = None
|
||||
self._expires = 0.0
|
||||
|
||||
def _mint(self) -> str:
|
||||
response = self.transport.request(
|
||||
"POST",
|
||||
self.url,
|
||||
headers={
|
||||
"Authorization": "Bearer {0}".format(self.key),
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
response.read()
|
||||
body = response.data.decode("utf-8", "replace") if response.data else ""
|
||||
if not 200 <= response.status <= 299:
|
||||
raise ApiException.from_response(http_resp=response, body=body, data=None)
|
||||
|
||||
# IAM answers camelCase.
|
||||
payload = json.loads(body) if body else {}
|
||||
token = payload.get("accessToken") if isinstance(payload, dict) else None
|
||||
if not isinstance(token, str) or not token:
|
||||
raise ApiException(
|
||||
status=response.status,
|
||||
reason="IAM issued no token for {0}".format(self.subject),
|
||||
body=body,
|
||||
)
|
||||
|
||||
expires_in = payload.get("expiresIn")
|
||||
seconds = float(expires_in) if isinstance(expires_in, (int, float)) else TTL
|
||||
self._token = token
|
||||
self._expires = time.monotonic() + seconds
|
||||
return token
|
||||
|
||||
@property
|
||||
def transport(self) -> Any:
|
||||
if self._transport is None:
|
||||
from hanzoai.cloud.rest import RESTClientObject
|
||||
from hanzoai.cloud.configuration import Configuration
|
||||
|
||||
self._transport = RESTClientObject(Configuration(host=self.issuer))
|
||||
return self._transport
|
||||
@@ -0,0 +1,151 @@
|
||||
"""The outcome of an approval-gated call.
|
||||
|
||||
Some calls are sensitive enough that the platform stops them for a human
|
||||
decision instead of running them. That answer arrives as HTTP 202 carrying the
|
||||
approval verbatim — the same field names `GET /v1/approvals/{id}` returns, so
|
||||
there is one shape to learn.
|
||||
|
||||
A 202 is a 2xx, which is exactly the trap: a client that only checks "did it
|
||||
throw" reads a queued call as a completed one. Nothing here lets that happen.
|
||||
Through :class:`hanzoai.Client` a held call raises :class:`Held`, which names
|
||||
the approval; :func:`result` turns that raise into a value you must destructure
|
||||
before you can read anything out of it.
|
||||
|
||||
from hanzoai import is_held, result, unwrap
|
||||
|
||||
r = result(memory.post_memory_remember, body)
|
||||
if is_held(r):
|
||||
print(r.id, r.clause, r.reason) # queued, nothing ran
|
||||
else:
|
||||
fact = unwrap(r) # raises Held if it was not done
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Union, Generic, TypeVar, Callable, Optional
|
||||
from dataclasses import dataclass
|
||||
from typing_extensions import Literal, TypeGuard
|
||||
|
||||
from hanzoai.cloud.exceptions import ApiException
|
||||
|
||||
__all__ = [
|
||||
"Approval",
|
||||
"Done",
|
||||
"Held",
|
||||
"Result",
|
||||
"is_done",
|
||||
"is_held",
|
||||
"result",
|
||||
"unwrap",
|
||||
]
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Approval:
|
||||
"""A call the platform stopped for a human decision.
|
||||
|
||||
`id` is the handle to poll or resolve by, `clause` names the policy clause
|
||||
that held the call, and `reason` says why. `status` is always ``"held"``.
|
||||
"""
|
||||
|
||||
id: str
|
||||
clause: str
|
||||
reason: str
|
||||
status: Literal["held"] = "held"
|
||||
|
||||
@classmethod
|
||||
def held(cls, body: Any) -> Optional["Approval"]:
|
||||
"""Reads a body as a hold, or returns `None` when it is not one.
|
||||
|
||||
The body is the discriminator, not the status code: 202 also means
|
||||
"accepted, working on it" on a dozen long-running operations, and those
|
||||
carry their own schema. Only ``status: "held"`` is an approval.
|
||||
|
||||
Serves a 202 and `GET /v1/approvals/{id}` alike — the field names are
|
||||
the same, and a resolved approval no longer says `held`. The server
|
||||
omits an empty field, so each one reads as ``""`` rather than `None`.
|
||||
"""
|
||||
if isinstance(body, (bytes, bytearray)):
|
||||
body = body.decode("utf-8", "replace")
|
||||
if isinstance(body, str):
|
||||
try:
|
||||
body = json.loads(body) if body else None
|
||||
except ValueError:
|
||||
return None
|
||||
if not isinstance(body, dict) or body.get("status") != "held":
|
||||
return None
|
||||
return cls(
|
||||
id=_text(body.get("id")),
|
||||
clause=_text(body.get("clause")),
|
||||
reason=_text(body.get("reason")),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Done(Generic[T]):
|
||||
"""A call that ran. `value` is what it returned."""
|
||||
|
||||
value: T
|
||||
status: Literal["done"] = "done"
|
||||
|
||||
|
||||
#: What an approval-gated call produced. The two arms share no member: an
|
||||
#: `Approval` has no `value` and a `Done` has no `id`, so reading either one
|
||||
#: without checking `status` first fails at the attribute rather than quietly
|
||||
#: handing back a half-answer.
|
||||
Result = Union[Done[T], Approval]
|
||||
|
||||
|
||||
class Held(ApiException):
|
||||
"""Raised for a call the platform held. Carries the approval that gates it.
|
||||
|
||||
It is an :class:`ApiException` because a held call is an HTTP outcome that
|
||||
produced no value: `status` is 202 and `body` is the response verbatim. A
|
||||
caller who catches `ApiException` at the edge already treats it as "no
|
||||
result", which is the safe direction; catch `Held` to treat it as "queued".
|
||||
"""
|
||||
|
||||
def __init__(self, approval: Approval, *, body: Optional[str] = None) -> None:
|
||||
super().__init__(
|
||||
status=202,
|
||||
reason="held on {0}: {1}".format(approval.clause, approval.reason),
|
||||
body=body,
|
||||
)
|
||||
self.approval = approval
|
||||
|
||||
|
||||
def is_held(r: Result[T]) -> TypeGuard[Approval]:
|
||||
"""True when the call was held. The explicit check."""
|
||||
return r.status == "held"
|
||||
|
||||
|
||||
def is_done(r: Result[T]) -> TypeGuard[Done[T]]:
|
||||
"""True when the call ran."""
|
||||
return r.status == "done"
|
||||
|
||||
|
||||
def unwrap(r: Result[T]) -> T:
|
||||
"""Returns the value of a `Done`, or raises :class:`Held` naming the approval."""
|
||||
if isinstance(r, Approval):
|
||||
raise Held(r)
|
||||
return r.value
|
||||
|
||||
|
||||
def result(call: Callable[..., T], *args: Any, **kwargs: Any) -> Result[T]:
|
||||
"""Runs an approval-gated call and returns its outcome as a value.
|
||||
|
||||
Every operation on a :class:`hanzoai.Client` raises :class:`Held` when the
|
||||
platform holds it. Wrap the call here when you want to handle the hold
|
||||
rather than let it propagate.
|
||||
"""
|
||||
try:
|
||||
return Done(call(*args, **kwargs))
|
||||
except Held as held:
|
||||
return held.approval
|
||||
|
||||
|
||||
def _text(v: Any) -> str:
|
||||
return v if isinstance(v, str) else ""
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "hanzoai"
|
||||
version = "3.2.13"
|
||||
version = "3.2.15"
|
||||
description = "The official Python library for the Hanzo API"
|
||||
dynamic = ["readme"]
|
||||
license = "Apache-2.0"
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
"""The tenant fabric: one operator credential, N isolated customers.
|
||||
|
||||
Nothing here touches the network. The stub below has the same `request()`
|
||||
signature as :class:`hanzoai.cloud.rest.RESTClientObject`, which is the only
|
||||
thing the client and the mint ever call, so these exercise the real code path
|
||||
and only the socket is missing.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from hanzoai import (
|
||||
Done,
|
||||
Held,
|
||||
Grant,
|
||||
Client,
|
||||
Approval,
|
||||
result,
|
||||
unwrap,
|
||||
is_done,
|
||||
is_held,
|
||||
)
|
||||
from hanzoai.cloud import KeysApi, Configuration
|
||||
from hanzoai.cloud.exceptions import ApiException
|
||||
|
||||
|
||||
class Reply:
|
||||
"""Shaped like `hanzoai.cloud.rest.RESTResponse`, without a socket."""
|
||||
|
||||
def __init__(self, status, payload=None, headers=None):
|
||||
self.status = status
|
||||
self.reason = "stub"
|
||||
if isinstance(payload, (bytes, bytearray)):
|
||||
self.data = bytes(payload)
|
||||
elif payload is None:
|
||||
self.data = b""
|
||||
else:
|
||||
self.data = json.dumps(payload).encode()
|
||||
self.headers = headers or {"content-type": "application/json"}
|
||||
|
||||
def read(self):
|
||||
return self.data
|
||||
|
||||
def getheaders(self):
|
||||
return self.headers
|
||||
|
||||
def getheader(self, name, default=None):
|
||||
return self.headers.get(name, default)
|
||||
|
||||
|
||||
class Transport:
|
||||
"""Answers each request with the next reply, and records what it was asked."""
|
||||
|
||||
def __init__(self, *replies):
|
||||
self.replies = list(replies)
|
||||
self.calls = []
|
||||
|
||||
def request(self, method, url, headers=None, body=None, post_params=None, _request_timeout=None):
|
||||
self.calls.append((method, url, dict(headers or {}), body))
|
||||
assert self.replies, "the client made more requests than the test staged"
|
||||
return self.replies.pop(0)
|
||||
|
||||
|
||||
def issued(token, expires_in=600):
|
||||
"""IAM's answer, in IAM's casing."""
|
||||
return Reply(200, {"accessToken": token, "expiresIn": expires_in})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# The mint
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_mint_is_iam_s_own_host_and_the_subject_is_a_query():
|
||||
"""`POST https://hanzo.id/v1/iam/tokens/issue?id=<subject>`, no body.
|
||||
|
||||
Three things this pins, each of which fails silently if it drifts: the host
|
||||
is IAM's, not the platform API's, which 404s this path; the path carries the
|
||||
`iam` segment; and the subject rides as the `id` query, because IAM reads
|
||||
the grant off the key and there is nothing to put in a body.
|
||||
"""
|
||||
transport = Transport(issued("tok-user-42"))
|
||||
grant = Grant("hk-operator", "user_42", transport=transport)
|
||||
|
||||
assert grant.token() == "tok-user-42"
|
||||
|
||||
method, url, headers, body = transport.calls[0]
|
||||
assert method == "POST"
|
||||
assert url == "https://hanzo.id/v1/iam/tokens/issue?id=user_42"
|
||||
assert headers["Authorization"] == "Bearer hk-operator"
|
||||
assert body is None
|
||||
|
||||
|
||||
def test_the_subject_is_url_encoded():
|
||||
"""An externalId is whatever the operator filed the member under."""
|
||||
transport = Transport(issued("tok"))
|
||||
Grant("hk-operator", "acct/42 x&y", transport=transport).token()
|
||||
_, url, _, _ = transport.calls[0]
|
||||
assert url == "https://hanzo.id/v1/iam/tokens/issue?id=acct%2F42+x%26y"
|
||||
|
||||
|
||||
def test_the_issuer_is_overridable_for_a_private_estate():
|
||||
transport = Transport(issued("tok"))
|
||||
Grant("hk-operator", "user_42", issuer="https://id.acme.internal/", transport=transport).token()
|
||||
_, url, _, _ = transport.calls[0]
|
||||
assert url == "https://id.acme.internal/v1/iam/tokens/issue?id=user_42"
|
||||
|
||||
|
||||
def test_the_response_is_camel_case():
|
||||
"""`accessToken` / `expiresIn`. snake_case here reads as no token at all."""
|
||||
transport = Transport(Reply(200, {"access_token": "wrong", "expires_in": 600}))
|
||||
grant = Grant("hk-operator", "user_42", transport=transport)
|
||||
|
||||
with pytest.raises(ApiException) as caught:
|
||||
grant.token()
|
||||
assert "user_42" in str(caught.value)
|
||||
|
||||
|
||||
def test_the_token_is_cached_to_expiry():
|
||||
"""One mint serves every call until the token nears expiry."""
|
||||
transport = Transport(issued("tok-1"))
|
||||
grant = Grant("hk-operator", "user_42", transport=transport)
|
||||
|
||||
assert [grant.token() for _ in range(5)] == ["tok-1"] * 5
|
||||
assert len(transport.calls) == 1
|
||||
|
||||
|
||||
def test_a_token_inside_the_skew_is_not_reused():
|
||||
"""A token about to die is replaced before it rides a request, not after.
|
||||
|
||||
`expiresIn: 10` is inside the 30s skew, so it is spent on arrival.
|
||||
"""
|
||||
transport = Transport(issued("tok-1", expires_in=10), issued("tok-2", expires_in=600))
|
||||
grant = Grant("hk-operator", "user_42", transport=transport)
|
||||
|
||||
assert grant.token() == "tok-1"
|
||||
assert grant.token() == "tok-2"
|
||||
assert len(transport.calls) == 2
|
||||
|
||||
|
||||
def test_invalidate_forces_the_next_read_to_mint():
|
||||
transport = Transport(issued("tok-1"), issued("tok-2"))
|
||||
grant = Grant("hk-operator", "user_42", transport=transport)
|
||||
|
||||
assert grant.token() == "tok-1"
|
||||
grant.invalidate()
|
||||
assert grant.token() == "tok-2"
|
||||
|
||||
|
||||
def test_a_refused_mint_is_a_typed_error():
|
||||
"""Non-2xx carries the status and the body, never a bare string."""
|
||||
transport = Transport(Reply(403, {"error": "no act grant on this key"}))
|
||||
grant = Grant("hk-operator", "user_42", transport=transport)
|
||||
|
||||
with pytest.raises(ApiException) as caught:
|
||||
grant.token()
|
||||
assert caught.value.status == 403
|
||||
assert "no act grant" in caught.value.body
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# The scope
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def operator(transport):
|
||||
"""An operator client whose pool — and so whose mint — is the stub."""
|
||||
client = Client(Configuration(host="https://api.hanzo.ai", access_token="hk-operator"))
|
||||
client.rest_client = transport
|
||||
return client
|
||||
|
||||
|
||||
def test_as_puts_the_minted_token_on_the_request():
|
||||
"""End to end: serialize a real operation off a scoped client.
|
||||
|
||||
The generated serializer reads the credential through
|
||||
`Configuration.auth_settings()`, so this asserts the whole path — mint,
|
||||
cache, `auth_settings`, `_apply_auth_params` — and not a field we set.
|
||||
"""
|
||||
transport = Transport(issued("tok-user-42"))
|
||||
client = operator(transport).as_("user_42")
|
||||
|
||||
_, _, headers, _, _ = KeysApi(client)._get_keys_serialize(
|
||||
_request_auth=None, _content_type=None, _headers=None, _host_index=0
|
||||
)
|
||||
assert headers["Authorization"] == "Bearer tok-user-42"
|
||||
|
||||
|
||||
def test_the_operator_key_never_rides_a_scoped_client():
|
||||
transport = Transport(issued("tok-user-42"))
|
||||
client = operator(transport).as_("user_42")
|
||||
|
||||
assert client.grant.subject == "user_42"
|
||||
_, _, headers, _, _ = KeysApi(client)._get_keys_serialize(
|
||||
_request_auth=None, _content_type=None, _headers=None, _host_index=0
|
||||
)
|
||||
assert "hk-operator" not in headers["Authorization"]
|
||||
|
||||
|
||||
def test_scoping_does_not_disturb_the_operator():
|
||||
"""Two subjects and the operator hold three credentials, not one shared slot."""
|
||||
transport = Transport(issued("tok-a"), issued("tok-b"))
|
||||
boss = operator(transport)
|
||||
a, b = boss.as_("user_a"), boss.as_("user_b")
|
||||
|
||||
assert a.grant.token() == "tok-a"
|
||||
assert b.grant.token() == "tok-b"
|
||||
assert boss.configuration.access_token == "hk-operator"
|
||||
assert a.configuration is not b.configuration
|
||||
|
||||
|
||||
def test_scoping_without_a_credential_refuses():
|
||||
with pytest.raises(ApiException):
|
||||
Client(Configuration(host="https://api.hanzo.ai")).as_("user_42")
|
||||
|
||||
|
||||
def test_a_401_re_mints_once_and_retries():
|
||||
"""A rotated token costs a round trip, not an error the caller handles."""
|
||||
mint = Transport(issued("tok-stale"), issued("tok-fresh"))
|
||||
calls = Transport(Reply(401, {"error": "expired"}), Reply(200, {"ok": True}))
|
||||
|
||||
client = Client(
|
||||
Configuration(host="https://api.hanzo.ai"),
|
||||
grant=Grant("hk-operator", "user_42", transport=mint),
|
||||
)
|
||||
client.rest_client = calls
|
||||
|
||||
# What `param_serialize` did on the way in: the request rides the token the
|
||||
# grant was holding.
|
||||
staged = client.grant.token()
|
||||
assert staged == "tok-stale"
|
||||
|
||||
response = client.call_api(
|
||||
"GET",
|
||||
"https://api.hanzo.ai/v1/keys",
|
||||
{"Authorization": "Bearer " + staged},
|
||||
)
|
||||
|
||||
assert response.status == 200
|
||||
assert len(mint.calls) == 2, "the stale token was not dropped"
|
||||
assert calls.calls[0][2]["Authorization"] == "Bearer tok-stale"
|
||||
assert calls.calls[1][2]["Authorization"] == "Bearer tok-fresh"
|
||||
|
||||
|
||||
def test_a_401_is_retried_once_and_then_stands():
|
||||
"""Two 401s are an answer, not a loop."""
|
||||
mint = Transport(issued("tok-1"), issued("tok-2"))
|
||||
calls = Transport(Reply(401, {"error": "expired"}), Reply(401, {"error": "expired"}))
|
||||
|
||||
client = Client(
|
||||
Configuration(host="https://api.hanzo.ai"),
|
||||
grant=Grant("hk-operator", "user_42", transport=mint),
|
||||
)
|
||||
client.rest_client = calls
|
||||
|
||||
assert client.call_api("GET", "https://api.hanzo.ai/v1/keys", {}).status == 401
|
||||
assert len(calls.calls) == 2
|
||||
|
||||
|
||||
def test_an_unscoped_client_does_not_re_mint():
|
||||
"""No grant, nothing to re-mint: the 401 is the caller's to deal with."""
|
||||
calls = Transport(Reply(401, {"error": "bad key"}))
|
||||
client = Client(Configuration(host="https://api.hanzo.ai", access_token="hk-operator"))
|
||||
client.rest_client = calls
|
||||
|
||||
assert client.call_api("GET", "https://api.hanzo.ai/v1/keys", {}).status == 401
|
||||
assert len(calls.calls) == 1
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# The held result
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
HELD = {
|
||||
"status": "held",
|
||||
"id": "apr_7f3",
|
||||
"clause": "memory.remember",
|
||||
"reason": "writes to a customer profile need review",
|
||||
}
|
||||
|
||||
|
||||
def deserialize(reply, types=None):
|
||||
client = Client(Configuration(host="https://api.hanzo.ai", access_token="hk-operator"))
|
||||
return client.response_deserialize(reply, types or {"200": "object"})
|
||||
|
||||
|
||||
def test_a_held_call_raises_rather_than_returning_nothing():
|
||||
"""No operation declares a schema for the hold, so the generated
|
||||
deserializer answers it with `None` — a queued call reading as a call that
|
||||
succeeded and returned nothing. This is the whole point of the fabric."""
|
||||
with pytest.raises(Held) as caught:
|
||||
deserialize(Reply(202, HELD))
|
||||
|
||||
held = caught.value
|
||||
assert held.approval == Approval(
|
||||
id="apr_7f3", clause="memory.remember", reason="writes to a customer profile need review"
|
||||
)
|
||||
assert held.status == 202
|
||||
assert "memory.remember" in str(held)
|
||||
|
||||
|
||||
def test_a_202_that_is_not_a_hold_passes_through():
|
||||
"""A dozen long-running operations answer 202 with their own schema.
|
||||
|
||||
The body is the discriminator, not the status code. Blanket-raising on 202
|
||||
would break every deploy and build call in the document.
|
||||
"""
|
||||
response = deserialize(Reply(202, {"id": "dep_1", "status": "queued"}), {"202": "object"})
|
||||
assert response.status_code == 202
|
||||
assert response.data == {"id": "dep_1", "status": "queued"}
|
||||
|
||||
|
||||
def test_result_turns_the_raise_into_a_value():
|
||||
def call():
|
||||
raise Held(Approval.held(HELD), body=json.dumps(HELD))
|
||||
|
||||
r = result(call)
|
||||
assert is_held(r)
|
||||
assert not is_done(r)
|
||||
assert (r.id, r.clause, r.reason) == ("apr_7f3", "memory.remember", "writes to a customer profile need review")
|
||||
|
||||
|
||||
def test_result_carries_a_completed_call():
|
||||
r = result(lambda: {"id": "card_1"})
|
||||
assert is_done(r)
|
||||
assert unwrap(r) == {"id": "card_1"}
|
||||
|
||||
|
||||
def test_unwrapping_a_hold_raises_and_names_the_approval():
|
||||
"""The one thing a caller must not be able to do is read a hold as a value."""
|
||||
with pytest.raises(Held) as caught:
|
||||
unwrap(Approval.held(HELD))
|
||||
assert caught.value.approval.id == "apr_7f3"
|
||||
|
||||
|
||||
def test_the_two_arms_share_no_member():
|
||||
"""`Approval` has no `value` and `Done` has no `id`.
|
||||
|
||||
Reading either without checking `status` first fails at the attribute
|
||||
instead of quietly handing back a half-answer — which is as close as Python
|
||||
gets to the sum type a compiler would refuse to let you ignore.
|
||||
"""
|
||||
assert not hasattr(Approval.held(HELD), "value")
|
||||
assert not hasattr(Done({"id": "card_1"}), "id")
|
||||
|
||||
|
||||
def test_an_approval_read_back_is_the_same_shape():
|
||||
"""`GET /v1/approvals/{id}` answers in the field names of the 202."""
|
||||
fetched = Approval.held(json.dumps(HELD).encode())
|
||||
assert fetched == Approval.held(HELD)
|
||||
|
||||
|
||||
def test_a_resolved_approval_is_not_a_hold():
|
||||
assert Approval.held({"status": "approved", "id": "apr_7f3"}) is None
|
||||
assert Approval.held(b"") is None
|
||||
assert Approval.held("not json") is None
|
||||
|
||||
|
||||
def test_a_hold_with_fields_omitted_still_has_all_four():
|
||||
assert Approval.held({"status": "held", "id": "apr_7f3"}) == Approval(id="apr_7f3", clause="", reason="")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# The typed error
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_a_non_2xx_is_a_typed_error_carrying_status_and_body():
|
||||
with pytest.raises(ApiException) as caught:
|
||||
deserialize(Reply(500, {"error": "boom"}))
|
||||
assert caught.value.status == 500
|
||||
assert json.loads(caught.value.body) == {"error": "boom"}
|
||||
Reference in New Issue
Block a user