Metadata-Version: 2.4
Name: mock-granola-api
Version: 0.1.0
Summary: A local FastAPI mock of the Granola API for testing ingestion pipelines without a real Granola workspace.
Author: Alex Garcia
License-Expression: MIT
Project-URL: Repository, https://github.com/garcia-alex/mock-granola-api
Requires-Python: >=3.13.0
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: faker>=40.27.0
Requires-Dist: fastapi>=0.138.2
Requires-Dist: httpx>=0.28.1
Requires-Dist: uvicorn[standard]>=0.49.0
Dynamic: license-file

# mock-granola-api

> This is a public-source project — see [CONTRIBUTING.md](CONTRIBUTING.md)
> for the PR policy.

A local FastAPI mock of the [Granola API](https://docs.granola.ai/introduction)
(meeting notes, transcripts, folders, webhooks) for testing ingestion
pipelines without a real Granola workspace. It mirrors the real API's schemas,
ID formats, error shapes, pagination, rate limits, and signed webhook
delivery closely enough that client code written against it runs unmodified
against the real API — only a base URL and an API key change.

On top of the real API surface, an `/admin/*` control-plane (mock-only, no
auth) lets you script synthetic data — seed random notes, create specific
notes with exact content, edit them, and trigger the same events
(`note.generated`, `note.edited`, `note.regenerated`, `note.access_granted`)
a real Granola workspace would fire.

## Quickstart

```bash
make run    # starts the mock at http://127.0.0.1:8000
make test   # runs the test suite
```

## API surface

**Public API** (`/v1/*`, requires `Authorization: Bearer grn_...`) —
identical to the real Granola API:

| Method | Path | Description |
|---|---|---|
| GET | `/v1/notes` | list, paginated, filterable by date/folder |
| GET | `/v1/notes/{id}` | one note; `?include=transcript` to include it |
| GET | `/v1/folders` | list, paginated |
| POST | `/v1/webhook-endpoints` | register a URL for note events |
| GET | `/v1/webhook-endpoints` | list registered endpoints |
| DELETE | `/v1/webhook-endpoints/{id}` | remove one |

**Admin control-plane** (`/admin/*`, mock-only, no auth) — for generating
synthetic data, not something a real ingestion client would ever call:

| Method | Path | Description |
|---|---|---|
| POST | `/admin/seed` | bulk-generate `count` random Faker notes/folders |
| POST | `/admin/reset` | wipe all state (optionally reseed) |
| POST | `/admin/folders` | create one folder |
| POST | `/admin/notes` | create one note with exact content (see below) |
| PATCH | `/admin/notes/{id}` | edit a note, fires `note.edited` |
| POST | `/admin/notes/{id}/regenerate` | fires `note.regenerated` |
| POST | `/admin/notes/{id}/grant-access` | fires `note.access_granted` |
| GET | `/admin/webhook-deliveries` | inspect delivery attempts/retries |

`POST /admin/notes` accepts a `transcript` field — a list of
`{text, speaker_name?, source?, diarization_label?}` turns — for exact
transcript content instead of random Faker filler. See
`app/models.py::AdminTranscriptTurnInput`.

## Calling this API from a Python script

`examples/granola_client.py` is a small, dependency-light client
(`httpx` only) that talks to `/v1/*` — the same surface the real Granola API
exposes. It never touches `/admin/*`, so it works identically against the
mock and the real thing:

```python
from granola_client import GranolaClient

with GranolaClient.from_env() as client:
    for summary in client.iter_all_notes(page_size=10):
        note = client.get_note(summary["id"], include_transcript=True)
        print(note["id"], note["title"], note["owner"]["name"])
```

`examples/ingest_notes.py` is that snippet as a runnable script. Try it
against the mock:

```bash
make run                                    # terminal 1
make acme-demo                              # terminal 2: seeds some notes
GRANOLA_ENV=dev GRANOLA_API_KEY=grn_dev_dummy_key \
    uv run --frozen python examples/ingest_notes.py
```

(`make ingest-demo` does the same with `GRANOLA_ENV`/`GRANOLA_API_KEY`
pre-set.)

## The dev/prod switch

All environment-specific behavior lives in one place:
`GranolaClient.from_env()`. Calling code never branches on environment — it
just constructs the client and calls the same methods either way:

| Env var | dev | prod |
|---|---|---|
| `GRANOLA_ENV` | `dev` (default) | `prod` |
| `GRANOLA_API_BASE_URL` | optional override | optional override |
| `GRANOLA_API_KEY` | any `grn_...` string | a real workspace key |

Left unset, `GRANOLA_API_BASE_URL` defaults to `http://localhost:8000` in
dev and `https://public-api.granola.ai` in prod — set it explicitly only
when the mock lives somewhere else (e.g. a docker-compose service name, see
below). `GRANOLA_API_KEY` only needs to look like a real key in dev — the
mock checks the `grn_...` format only, never a real registry; prod needs an
actual workspace key issued by Granola.

This is the same pattern a real Google Drive connector for a different data
source would establish: build and validate everything against synthetic
data first, and only point at the real API once the connector is proven —
no code changes between the two, only environment.

### Packaging for Docker

Because the only difference between environments is env vars, the ingestion
image is the same artifact in both places:

```yaml
# docker-compose.dev.yml — mock + ingestion, for local/dev/CI
services:
  granola-mock:
    build: ./mock-granola-api
    ports: ["8000:8000"]

  ingestion:
    image: ghcr.io/your-org/ingestion:latest
    environment:
      GRANOLA_ENV: dev
      GRANOLA_API_BASE_URL: http://granola-mock:8000
      GRANOLA_API_KEY: grn_dev_dummy_key
    depends_on: [granola-mock]
```

```yaml
# docker-compose.prod.yml — same ingestion image, no mock container at all
services:
  ingestion:
    image: ghcr.io/your-org/ingestion:latest
    environment:
      GRANOLA_ENV: prod
      GRANOLA_API_KEY: ${GRANOLA_API_KEY}  # real key, injected at deploy time
```

## Context: why this exists

Built to test an ingestion pipeline that lists Granola as an inflow source
but has no live connector yet — a `deal-note.md`-style skill only parses a
manually pasted Granola export. `examples/acme_demo.py` demonstrates the
full loop this mock enables instead: seed VC-deal-shaped synthetic notes,
register a webhook and confirm signed delivery, fetch through the real
public endpoints, and render the result both as the plain-text export
`deal-note.md` already parses and as a `wiki-dealflow` frontmatter stub
(`title/status/sector/ask/portfolio_company/updated`) — proving a real
connector could be built and fully tested here, against synthetic data
only, before it ever sees a real Granola API key.

## Development

```bash
make test           # pytest
ruff check .         # lint
ruff format .        # format
pyright .            # types
```

See `app/` for the mock's implementation and `tests/` for coverage of
pagination, auth, rate limiting, webhook signing/retries, and the admin API.
