# 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.
