network/gaiia/README.md
2026-07-19 14:42:02 -05:00

198 lines
5.5 KiB
Markdown

# gaiia-py
Python client for the [gaiia](https://app.gaiia.com/docs/overview) GraphQL API
and webhooks. Thin wrapper around the official GraphQL surface — no codegen, no
opinionated ORM. You write GraphQL, the client handles auth, pagination, rate
limits, GlobalID encoding, and webhook signature verification.
## Install
```bash
pip install gaiia
```
Requires Python 3.10+. Depends on `httpx`.
## Quick start
```python
from gaiia import GaiiaClient
with GaiiaClient(api_key="gaiia_sk_live_...", timezone="America/Chicago") as g:
data = g.query(
"query Q($first: Int!) { technicians(first: $first) { nodes { id firstName lastName } } }",
{"first": 10},
)
for t in data["technicians"]["nodes"]:
print(t)
```
If `api_key=` is omitted, the client reads `GAIIA_KEY` from the environment.
## Mutations
The `mutate()` helper unwraps the mutation result and raises
`GaiiaMutationError` when the response contains validation errors under
`data.<mutation>.errors`:
```python
res = g.mutate(
"""
mutation A($i: AssignTechnicianToWorkOrderInput!) {
assignTechnicianToWorkOrder(input: $i) {
workOrder { id status startDate }
errors { code message }
}
}
""",
"assignTechnicianToWorkOrder",
{"i": {
"workOrderId": "work_order_...",
"technicianId": "technician_...",
"startDate": "2026-05-12T14:00:00Z",
}},
)
print(res["workOrder"]["id"])
```
## Pagination
`iter_nodes()` paginates a Relay Connection automatically. Your query must
accept an `$after: String` variable and pass it to the paginated field:
```python
for tech in g.iter_nodes(
"""
query Q($after: String) {
technicians(first: 50, after: $after) {
nodes { id firstName lastName }
pageInfo { hasNextPage endCursor }
}
}
""",
"technicians",
):
print(tech["firstName"], tech["lastName"])
```
Default page size is 50, max 250 (per the gaiia docs). Some fields cap lower —
check the field's reference page.
For bulk extracts (millions of rows, reporting, analytics), gaiia recommends
[Snowflake](https://app.gaiia.com/docs/rate-limits) rather than the GraphQL
API.
## Rate limiting
After every call, `client.last_rate_limit` is populated from the response
headers (`X-Rate-Limit-*`). If gaiia returns a `RATE_LIMITED` error, the
client raises `GaiiaRateLimitedError` with `limit`, `used`, `remaining`, and
`retry_at`:
```python
from gaiia import GaiiaClient, GaiiaRateLimitedError
with GaiiaClient(max_retries=2) as g:
try:
g.query("{ ... }")
except GaiiaRateLimitedError as e:
print("retry after", e.retry_at)
```
Setting `max_retries` > 0 makes the client sleep until `retry_at` and retry up
to that many times before re-raising.
## GlobalIDs
```python
from gaiia import encode_global_id, decode_global_id
encode_global_id("Account", "3c3b1978-6a68-4a13-bdc2-2d51c8ef7519")
# 'account_8rnXNuR5sKP5uNwoPL41Zp'
decode_global_id("account_8rnXNuR5sKP5uNwoPL41Zp")
# ('account', '3c3b1978-6a68-4a13-bdc2-2d51c8ef7519')
```
The encoding follows gaiia's `short-uuid` setup: Flickr Base-58 of the UUID's
128-bit integer, left-padded to 22 chars, prefixed with the snake-cased type.
## Webhooks
```python
from gaiia import verify_webhook_signature, WebhookSignatureError
try:
verify_webhook_signature(
header_value=request.headers["X-Gaiia-Webhook-Signature"],
raw_body=request.body_bytes, # the exact bytes gaiia signed
secret=os.environ["GAIIA_WEBHOOK_SECRET"],
tolerance_seconds=300,
)
except WebhookSignatureError:
return 400
```
The verifier:
- Parses `t=...,v1=...` (multiple `v1=` allowed for key rotation),
- Computes `HMAC-SHA256(secret, "${t}.${rawBody}")`,
- Constant-time compares against each `v1=` candidate,
- Rejects timestamps outside the tolerance window.
See `examples/webhook_flask.py` for a complete handler.
## Async
`AsyncGaiiaClient` mirrors `GaiiaClient` for `asyncio` code:
```python
import asyncio
from gaiia import AsyncGaiiaClient
async def main():
async with AsyncGaiiaClient(timezone="America/Chicago") as g:
data = await g.query("{ technicians(first: 5) { nodes { id firstName lastName } } }")
print(data)
asyncio.run(main())
```
`async for` over `iter_nodes()` paginates lazily.
## Timezones
gaiia stores all timestamps in UTC. Pass `timezone="America/Chicago"` (or
whatever IANA TZ applies) at the client level — it becomes the `x-timezone`
header on every request, which gaiia uses for scheduling/availability logic.
Override per-call with `g.query(..., timezone="America/Phoenix")`.
For display, query the work order's `location.timezone` and format times in
that zone — not the caller's.
## Error model
| Class | When it's raised |
| ------------------------ | ---------------------------------------------------------------------- |
| `GaiiaTransportError` | Network failure, non-JSON response, HTTP 5xx. |
| `GaiiaAPIError` | Top-level GraphQL `errors[]` returned (schema/auth/permission). |
| `GaiiaRateLimitedError` | Subclass of `GaiiaAPIError` for `RATE_LIMITED`. |
| `GaiiaMutationError` | Mutation `data.<m>.errors[]` is non-empty (validation/business rule). |
All inherit from `GaiiaError`.
## Development
```bash
git clone https://github.com/grahammcintire/gaiia-py
cd gaiia-py
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
pytest
ruff check .
mypy src/gaiia
```
## License
MIT.