mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-26 11:49:36 +00:00
Thirteen places where this SDK and the other two answered the same question
differently. Every one is now what the served handler reads, or what HIP-0044
says where the handler is silent.
Where this SDK was already right, the other two moved: search normalizes the
knowledge kind in both directions, `Page.total` stays the server's own count,
and `put` picks its method from the name. Where it was not:
- kb.Import carries what the handler answers. `/v1/knowledge/import` writes
{format, imported, pages} (knowledge/import.go); this modelled `imported`
alone, so a caller could not see which pages landed.
- Fact carries the server's three. `GET /v1/graph` serves twelve members
(graph/typed.go, wireFact) and no other route hands back an assertion's
content address, so projecting the nine an asserter states made a read lossy
with nothing to recover it from. `id`, `by` and `knowable` arrive read-only
and `write()` never sends them.
- graph.extract and graph.ingest take a Source. Both routes bind the same
{source, subject, text, at} (graph/semantic.go, graphSourceIn), so it is one
value — and the first positional argument is now the same thing here as it is
in the other two.
- import_(format, data, project) — the order the contract writes and the other
two take. It was (export, *, format).
- A body carries what somebody set. `send` dropped None from the query and left
it in the body, so `search.find` posted {"index": null, "offset": null,
"project": null} where the other two omit the key: three requests for one
call, and cloud asked to read a value where there is none. Audit's pageSize
and p go the same way — the route's own defaults are the same 100 and 1, so
an unset term is one the route decides identically, and `all` pins the size it
walks at because it needs to know the page length.
- A write body is field data and nothing else. `url` belongs to a source, and an
empty title is left out so the doctype names the required field that is
missing rather than being handed a blank one.
- kb.list takes order and kb.links takes a project. `/v1/framework/{doctype}`
serves order_by and `/v1/knowledge/graph` serves ?project=.
- policy_denied and entitlement_required do not exist. Neither string appears
anywhere in hanzoai/cloud, so REFUSED is the two codes it does emit. A 403
read as denied on an invented code would hand an unauthenticated caller a cure
for a refusal nobody made.
- One refusal reader, by named fallback: code from `code` else `error`, reason
from `detail` else `message`, cures from `cure`. The gate's own `reason` —
"unpaid", "unresolved" — names the admit leg that failed rather than
explaining anything, so it is dropped, and `title` is not a refusal member.
- Money counts minor units, not cents: hundredths for USD, whole yen for JPY,
thousandths for KWD.
- Balance.reserved, not held. The wire says `holds`; `held` is an arm.
- Vocabulary.rules, because it is a list. The wire member stays `rule`.
- The fourth outcome is Fault, not the generated client's ApiException. It is
the one shared concept whose name a reader could not carry between languages,
and an exception from the generated layer is not this layer's vocabulary.
- A client with no credential builds. It raised at construction, which is a
process that cannot start where the other two are a process whose first call
fails — and the first call is where the credential is actually needed.
HIP-0044 is amended where the code was right and the document was not.
ruff check, ruff format --check and mypy clean on every changed file; 109
passed. The two test_smoke failures are pre-existing (ToolsApi is absent from
the generated tree) and reproduce on a stashed working copy.
Co-authored-by: Hanzo Dev <[email protected]>
163 lines
5.5 KiB
Python
163 lines
5.5 KiB
Python
"""The access token the client presents, minted from its own IAM credential.
|
|
|
|
A caller holds a clientId and a clientSecret. It exchanges them for a
|
|
short-lived access token and sends that. It never takes a bearer from a config
|
|
value: a credential the SDK is handed is a credential nobody rotates, and it
|
|
says nothing about who is calling — which is the one question every gate in the
|
|
estate exists to answer.
|
|
|
|
POST {issuer}/v1/iam/oauth/token
|
|
Authorization: Basic base64(id:secret)
|
|
Content-Type: application/x-www-form-urlencoded
|
|
|
|
grant_type=client_credentials&resource={resource}
|
|
|
|
`resource` is RFC 8707: it names the server the token is for, so a token minted
|
|
for api.hanzo.ai is useless anywhere else. IAM verifies the exchange (HIP-0111)
|
|
and every resource server verifies the result against the published JWKS.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import time
|
|
from base64 import b64encode
|
|
from typing import Any, Optional
|
|
|
|
from hanzoai.answer import Fault
|
|
|
|
__all__ = ["Token", "ISSUER", "BASE", "PATH", "EARLY", "TTL"]
|
|
|
|
#: Where IAM answers. Override for a private estate.
|
|
ISSUER = "https://hanzo.id"
|
|
|
|
#: The one endpoint. Every capability path is `/v1/...` below it.
|
|
BASE = "https://api.hanzo.ai"
|
|
|
|
#: IAM's client_credentials exchange.
|
|
PATH = "/v1/iam/oauth/token"
|
|
|
|
#: How long before expiry a held token stops being offered. A token that dies
|
|
#: in flight is a 401 the caller cannot tell from a revoked identity, so it is
|
|
#: replaced while it still works.
|
|
EARLY = 60.0
|
|
|
|
#: Lifetime assumed when IAM states none.
|
|
TTL = 300.0
|
|
|
|
|
|
class Token:
|
|
"""A live access token, minted on demand and held until it nears expiry.
|
|
|
|
Not thread-safe by design: the worst a race costs is a duplicate mint.
|
|
|
|
:param id: the IAM clientId.
|
|
:param secret: the IAM clientSecret.
|
|
:param issuer: where IAM answers.
|
|
:param resource: the RFC 8707 audience the token is minted for.
|
|
:param transport: anything shaped like
|
|
:class:`hanzoai.cloud.rest.RESTClientObject`. Left unset it builds its
|
|
own against `issuer`, which is a different host from the API and so a
|
|
different pool either way.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
id: str,
|
|
secret: str,
|
|
issuer: str = ISSUER,
|
|
resource: str = BASE,
|
|
transport: Optional[Any] = None,
|
|
) -> None:
|
|
self.id = id
|
|
self.secret = secret
|
|
self.issuer = issuer.rstrip("/")
|
|
self.resource = resource
|
|
self._transport = transport
|
|
self._held: Optional[str] = None
|
|
self._until = 0.0
|
|
|
|
@property
|
|
def url(self) -> str:
|
|
"""The mint."""
|
|
return self.issuer + PATH
|
|
|
|
def token(self) -> str:
|
|
"""The live token, minting one when what is held is gone or nearly so.
|
|
|
|
A client built with no credential raises here rather than at
|
|
construction: there is nothing to exchange, so nothing is sent to IAM
|
|
and nothing is sent unsigned to the gateway — an unsigned call comes
|
|
back a bare 403, which reads as a refusal of the caller rather than the
|
|
absence of one.
|
|
"""
|
|
if not self.id or not self.secret:
|
|
raise Fault(
|
|
0,
|
|
reason="no IAM client credentials: pass id and secret, or set HANZO_CLIENT_ID and HANZO_CLIENT_SECRET",
|
|
)
|
|
if self._held is not None and self._until - time.monotonic() > EARLY:
|
|
return self._held
|
|
return self._mint()
|
|
|
|
def invalidate(self) -> None:
|
|
"""Drops the held token so the next read mints a fresh one."""
|
|
self._held = None
|
|
self._until = 0.0
|
|
|
|
def _mint(self) -> str:
|
|
basic = b64encode("{0}:{1}".format(self.id, self.secret).encode()).decode()
|
|
form = [("grant_type", "client_credentials")]
|
|
if self.resource:
|
|
form.append(("resource", self.resource))
|
|
|
|
response = self.transport.request(
|
|
"POST",
|
|
self.url,
|
|
headers={
|
|
"Authorization": "Basic " + basic,
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
"Accept": "application/json",
|
|
},
|
|
post_params=form,
|
|
)
|
|
response.read()
|
|
body = response.data.decode("utf-8", "replace") if response.data else ""
|
|
payload = _json(body)
|
|
|
|
token = payload.get("access_token")
|
|
if not 200 <= response.status <= 299 or not isinstance(token, str) or not token:
|
|
# Say which identity was refused. A 401 here reads the same whether
|
|
# the id is wrong, the secret is stale, or the app may not use this
|
|
# grant, and the reader is holding none of those.
|
|
raise Fault(
|
|
response.status,
|
|
reason="{0} refused client {1}: {2} {3}".format(
|
|
self.issuer,
|
|
self.id,
|
|
payload.get("error", ""),
|
|
payload.get("error_description", ""),
|
|
).rstrip(),
|
|
)
|
|
|
|
seconds = payload.get("expires_in")
|
|
self._held = token
|
|
self._until = time.monotonic() + (float(seconds) if isinstance(seconds, (int, float)) else TTL)
|
|
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
|
|
|
|
|
|
def _json(body: str) -> Any:
|
|
try:
|
|
return json.loads(body) if body else {}
|
|
except ValueError:
|
|
return {}
|