198 lines
5.5 KiB
Markdown
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.
|