From eb52b23ff07cafcec01aa49abbab36e30ccac75a Mon Sep 17 00:00:00 2001 From: Jeroen Rinzema Date: Tue, 23 Jun 2026 00:00:32 +0200 Subject: [PATCH 1/9] feat!: require project_id and move client API under /projects/{project_id} Every authenticated Lunogram Client API endpoint is now scoped to a project. The project UUID is supplied once when constructing the client and is injected into every Client API path as a `/projects//` segment; callers no longer pass it per request. BREAKING CHANGE: `Lunogram(api_key)` becomes `Lunogram(api_key, project_id)`. Auth is unchanged (same API key / token); only the URL gains the project. - client.py: add required `project_id`, build a project-scoped reference and thread it through `user`/`organization`. - utils/reference.py: turn the path reference into a project-scoped factory (validates non-empty UUID); add inbox/devices/push/auth-method-session paths. - app/objects.py and app/models/**: accept and use the injected reference. - README: document construction with the project and the new URLs. --- README.md | 70 +++++++++++++++++++++++++ src/lunogram/app/models/objects.py | 22 ++++---- src/lunogram/app/models/organization.py | 23 ++++---- src/lunogram/app/models/user.py | 19 +++---- src/lunogram/app/objects.py | 52 +++++++++--------- src/lunogram/client.py | 18 +++++-- src/lunogram/utils/reference.py | 67 +++++++++++++++++++---- 7 files changed, 202 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index e69de29..f7ed2e5 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,70 @@ +# Lunogram Python SDK + +Python SDK for the Lunogram Client API. + +## Installation + +```bash +pip install lunogram-sdk +``` + +## Usage + +Every authenticated Client API endpoint is scoped to a **project**. You supply +the project UUID **once**, when constructing the client, and the SDK injects it +into every request path automatically. You never pass the project per call. + +```python +from lunogram import Lunogram, random_user + +client = Lunogram( + api_key="your-api-key", + project_id="11111111-2222-3333-4444-555555555555", # your project UUID +) + +# Upsert a user +result = client.user.upsert(random_user()) +print(result[0]) # API response + +# Send a user event +client.user.events.post({ + "identifier": [{"source": "default", "external_id": "user_123"}], + "name": "signed_in", +}) + +# Organizations work the same way +client.organization.upsert({ + "identifier": [{"source": "default", "external_id": "org_123"}], + "name": "Acme Inc.", +}) +``` + +> All SDK actions return a tuple: the API response on index `0`, and a possible +> error on index `1`. + +## Project-scoped URLs + +As of this release, every Client API path includes the project UUID as a path +segment: + +``` +/api/client/projects//... +``` + +| Resource | Path (under `/api/client/projects//`) | +| --- | --- | +| Users | `users` | +| User events | `users/events` | +| User scheduled | `users/scheduled` | +| User devices | `users/devices` | +| User inbox | `users/inbox` (`/count`, `/read`, `/archived`) | +| Organizations | `organizations` | +| Organization users | `organizations/users` | +| Organization events | `organizations/events` | +| Organization scheduled | `organizations/scheduled` | +| Organization inbox | `organizations/inbox` (`/count`, `/read`, `/archived`) | +| Push VAPID | `push/vapid` | +| Auth method sessions | `auth-methods//sessions` | + +Authentication is unchanged — the same API key / token is used. Only the URL +gained the project segment. diff --git a/src/lunogram/app/models/objects.py b/src/lunogram/app/models/objects.py index 608432b..9d85047 100644 --- a/src/lunogram/app/models/objects.py +++ b/src/lunogram/app/models/objects.py @@ -1,34 +1,34 @@ from typing import Literal from ..http import httphandler -from ...utils.reference import client as reference_client, default_client +from ...utils.reference import client as reference_client -from src.lunogram.app.types.event import * -from src.lunogram.app.types.scheduled import * +from ..types.event import * +from ..types.scheduled import * entities = Literal["user", "organization"] # .events and .scheduled objects are defined seperately and later integrated with the corresponding entities class events: - def __init__(self, api_key, entity: entities, reference: reference_client | None = None): + def __init__(self, api_key, reference: reference_client, entity: entities): self.entity = entity - self.reference = reference or default_client + self.reference = reference self.handler = httphandler(api_key) - + def post(self, data: Event): match(self.entity): case 'user': req = self.handler.post(self.reference.user.events, data) case 'organization': req = self.handler.post(self.reference.organization.events, data) - + return req class scheduled: - def __init__(self, api_key, entity: entities, reference: reference_client | None = None): + def __init__(self, api_key, reference: reference_client, entity: entities): self.entity = entity - self.reference = reference or default_client + self.reference = reference self.handler = httphandler(api_key) def post(self, data: UpsertScheduled): @@ -39,7 +39,7 @@ def post(self, data: UpsertScheduled): req = self.handler.post(self.reference.organization.scheduled, data) return req - + def delete(self, data: DeleteScheduled): match(self.entity): case 'user': @@ -47,4 +47,4 @@ def delete(self, data: DeleteScheduled): case 'organization': req = self.handler.delete(self.reference.organization.scheduled, data) - return req \ No newline at end of file + return req diff --git a/src/lunogram/app/models/organization.py b/src/lunogram/app/models/organization.py index 80d34bd..5cecb98 100644 --- a/src/lunogram/app/models/organization.py +++ b/src/lunogram/app/models/organization.py @@ -1,21 +1,22 @@ -from src.lunogram.app.models.objects import events, scheduled -from src.lunogram.app.http import httphandler -from src.lunogram.app.types.organization import * +from .objects import events, scheduled +from ..http import httphandler +from ..types.organization import * -from ...utils.reference import client +from ...utils.reference import client as reference_client class organization: - def __init__(self, api_key): - self.events = events(api_key, entity='organization') - self.scheduled = scheduled(api_key, entity='organization') + def __init__(self, api_key, reference: reference_client): + self.reference = reference + self.events = events(api_key, reference, entity='organization') + self.scheduled = scheduled(api_key, reference, entity='organization') self.handler = httphandler(api_key) def upsert(self, data: UpsertOrganization): - req = self.handler.post(client.organization.base, data) + req = self.handler.post(self.reference.organization.base, data) return req - + def delete(self, data: DeleteOrganization): - req = self.handler.delete(client.organization.base, data) + req = self.handler.delete(self.reference.organization.base, data) - return req \ No newline at end of file + return req diff --git a/src/lunogram/app/models/user.py b/src/lunogram/app/models/user.py index 8d61343..a35d0e2 100644 --- a/src/lunogram/app/models/user.py +++ b/src/lunogram/app/models/user.py @@ -1,21 +1,22 @@ -from objects import events, scheduled +from .objects import events, scheduled from ..http import httphandler -from ...utils.reference import client -from src.lunogram.app.types.user import * +from ...utils.reference import client as reference_client +from ..types.user import * class user: - def __init__(self, api_key): - self.events = events(api_key, entity='user') - self.scheduled = scheduled(api_key, entity='user') + def __init__(self, api_key, reference: reference_client): + self.reference = reference + self.events = events(api_key, reference, entity='user') + self.scheduled = scheduled(api_key, reference, entity='user') self.handler = httphandler(api_key) def upsert(self, data: UpsertUser): - req = self.handler.post(client.user.base, data) + req = self.handler.post(self.reference.user.base, data) return req def delete(self, data: DeleteUser): - req = self.handler.delete(client.user.base, data) + req = self.handler.delete(self.reference.user.base, data) - return req \ No newline at end of file + return req diff --git a/src/lunogram/app/objects.py b/src/lunogram/app/objects.py index eb30761..93f87d0 100644 --- a/src/lunogram/app/objects.py +++ b/src/lunogram/app/objects.py @@ -8,70 +8,74 @@ # .events and .scheduled objects are defined seperately and later integrated with the corresponding entities class events: - def __init__(self, api_key, entity: entities): + def __init__(self, api_key, reference: client, entity: entities): self.entity = entity + self.reference = reference self.handler = httphandler(api_key) - + def post(self, data): match(self.entity): case 'user': - req = self.handler.post(client.user.events, data) + req = self.handler.post(self.reference.user.events, data) case 'organization': - req = self.handler.post(client.organization.events, data) - + req = self.handler.post(self.reference.organization.events, data) + return req class scheduled: - def __init__(self, api_key, entity: entities): + def __init__(self, api_key, reference: client, entity: entities): self.entity = entity + self.reference = reference self.handler = httphandler(api_key) def post(self, data): match(self.entity): case 'user': - req = self.handler.post(client.user.scheduled, data) + req = self.handler.post(self.reference.user.scheduled, data) case 'organization': - req = self.handler.post(client.organization.scheduled, data) + req = self.handler.post(self.reference.organization.scheduled, data) return req - + def delete(self, data): match(self.entity): case 'user': - req = self.handler.delete(client.user.scheduled, data) + req = self.handler.delete(self.reference.user.scheduled, data) case 'organization': - req = self.handler.delete(client.organization.scheduled, data) + req = self.handler.delete(self.reference.organization.scheduled, data) return req - + class user: - def __init__(self, api_key): - self.events = events(api_key, entity='user') - self.scheduled = scheduled(api_key, entity='user') + def __init__(self, api_key, reference: client): + self.reference = reference + self.events = events(api_key, reference, entity='user') + self.scheduled = scheduled(api_key, reference, entity='user') self.handler = httphandler(api_key) def upsert(self, data): - req = self.handler.post(client.user.base, data) + req = self.handler.post(self.reference.user.base, data) return req def delete(self, data): - req = self.handler.delete(client.user.base, data) + req = self.handler.delete(self.reference.user.base, data) return req class organization: - def __init__(self, api_key): - self.events = events(api_key, entity='organization') - self.scheduled = scheduled(api_key, entity='organization') + def __init__(self, api_key, reference: client): + self.reference = reference + self.events = events(api_key, reference, entity='organization') + self.scheduled = scheduled(api_key, reference, entity='organization') self.handler = httphandler(api_key) def upsert(self, data): - req = self.handler.post(client.organization.base, data) + req = self.handler.post(self.reference.organization.base, data) return req - + def delete(self, data): - req = self.handler.delete(client.organization.base, data) + req = self.handler.delete(self.reference.organization.base, data) - return req \ No newline at end of file + return req diff --git a/src/lunogram/client.py b/src/lunogram/client.py index ef7f9ae..f52ce29 100644 --- a/src/lunogram/client.py +++ b/src/lunogram/client.py @@ -1,12 +1,17 @@ from random import randint from .utils import seed +from .utils.reference import client as Reference from .app import user, organization class Lunogram: - def __init__(self, api_key): - self.user = user(api_key) - self.organization = organization(api_key) + def __init__(self, api_key, project_id): + # Every Client API endpoint is scoped to a project. The project UUID is + # supplied once here and injected into every request path automatically; + # callers never pass it per request. + self.reference = Reference(project_id) + self.user = user(api_key, self.reference) + self.organization = organization(api_key, self.reference) # Seeder data to generate a random user should you need it, this is mainly for testing purposes def random_user(): @@ -41,8 +46,11 @@ def random_user(): def main() -> None: """ Example use -> - - client = Lunogram("Your API key here") + + client = Lunogram("Your API key here", "your-project-uuid-here") + + > The project UUID is required and is injected into every Client API path, + > e.g. /api/client/projects//users > All sdk actions return a list with the api response on index 0, possible errors on index 1 diff --git a/src/lunogram/utils/reference.py b/src/lunogram/utils/reference.py index 060cd1b..d122c32 100644 --- a/src/lunogram/utils/reference.py +++ b/src/lunogram/utils/reference.py @@ -1,20 +1,69 @@ +from uuid import UUID + url = "https://console.lunogram.com/" api = "/api/client/" destination = url + api +# Every authenticated Client API endpoint is scoped to a single project. The +# project UUID is supplied once when the Lunogram client is constructed and is +# injected here as a `/projects//` path segment, so callers never +# pass it per request. + + +def _validate_project_id(project_id: str) -> str: + if not isinstance(project_id, str) or not project_id.strip(): + raise ValueError("project_id is required and must be a non-empty string") + + try: + UUID(project_id) + except (ValueError, AttributeError, TypeError): + raise ValueError(f"project_id must be a valid UUID, got: {project_id!r}") + + return project_id + + # API reference class _user: - base = destination + 'users' - events = destination + 'users/events' - scheduled = destination + 'users/scheduled' + def __init__(self, project: str): + self.base = project + 'users' + self.events = project + 'users/events' + self.scheduled = project + 'users/scheduled' + self.devices = project + 'users/devices' + self.inbox = project + 'users/inbox' + self.inbox_count = project + 'users/inbox/count' + self.inbox_read = project + 'users/inbox/read' + self.inbox_archived = project + 'users/inbox/archived' class _org: - base = destination + 'organizations' - events = destination + 'organizations/events' - users = destination + 'organizations/users' - scheduled = destination + 'organizations/scheduled' + def __init__(self, project: str): + self.base = project + 'organizations' + self.events = project + 'organizations/events' + self.users = project + 'organizations/users' + self.scheduled = project + 'organizations/scheduled' + self.inbox = project + 'organizations/inbox' + self.inbox_count = project + 'organizations/inbox/count' + self.inbox_read = project + 'organizations/inbox/read' + self.inbox_archived = project + 'organizations/inbox/archived' + +class _push: + def __init__(self, project: str): + self.vapid = project + 'push/vapid' + +class _auth_methods: + def __init__(self, project: str): + self._project = project + + def sessions(self, auth_method_id: str) -> str: + return self._project + f'auth-methods/{auth_method_id}/sessions' class client: - user = _user() - organization = _org() + def __init__(self, project_id: str): + self.project_id = _validate_project_id(project_id) + # e.g. https://console.lunogram.com/api/client/projects// + project = destination + f'projects/{self.project_id}/' + + self.user = _user(project) + self.organization = _org(project) + self.push = _push(project) + self.auth_methods = _auth_methods(project) From ff6499b106ac0bdd6752a8d36d005e0ecc282fbb Mon Sep 17 00:00:00 2001 From: Jeroen Rinzema Date: Tue, 23 Jun 2026 11:26:48 +0200 Subject: [PATCH 2/9] refactor!: generate the client models from the OpenAPI spec Migrate the SDK to spec-driven code generation with a layered design: the low-level model layer is generated from the vendored OpenAPI spec, while the public facade (Lunogram, the project-scoped path factory, UUID validation, auth header, transport) stays hand-written and unchanged. - Vendor spec/client.yaml from the platform repo at a pinned commit; spec/SOURCE.md records repo, path and ref. - Generate src/lunogram/gen/models.py with datamodel-code-generator (Pydantic v2). scripts/generate.sh + `make generate` are the single source of truth for generator flags; the generator is pinned in the dev extra. - Wire the generated models into the facade as request-body types; Python is snake_case end-to-end so models flow through with no key-mapping layer. Methods accept a model or a plain dict. - Remove the dead, broken hand-written app/types/** and app/models/** duplicates that the generated models replace. - CI: ci.yml regenerates + drift-checks src/lunogram/gen, builds the wheel, import-smoke-tests and runs pytest; spec-sync.yml re-fetches the spec weekly and opens a PR on change. - Add pytest smoke tests for the path factory, UUID validation and model serialization. The public API is unchanged from the project-in-URL PR. --- .github/workflows/ci.yml | 37 + .github/workflows/spec-sync.yml | 56 + Makefile | 20 + README.md | 52 + pyproject.toml | 9 + scripts/generate.sh | 31 + spec/SOURCE.md | 36 + spec/client.yaml | 1779 +++++++++++++++++++++++ src/lunogram/app/models/objects.py | 50 - src/lunogram/app/models/organization.py | 22 - src/lunogram/app/models/user.py | 22 - src/lunogram/app/objects.py | 54 +- src/lunogram/app/types/event.py | 8 - src/lunogram/app/types/identifier.py | 9 - src/lunogram/app/types/organization.py | 10 - src/lunogram/app/types/scheduled.py | 14 - src/lunogram/app/types/user.py | 17 - src/lunogram/gen/__init__.py | 5 + src/lunogram/gen/models.py | 501 +++++++ tests/test_models.py | 37 + tests/test_reference.py | 76 + 21 files changed, 2681 insertions(+), 164 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/spec-sync.yml create mode 100644 Makefile create mode 100755 scripts/generate.sh create mode 100644 spec/SOURCE.md create mode 100644 spec/client.yaml delete mode 100644 src/lunogram/app/models/objects.py delete mode 100644 src/lunogram/app/models/organization.py delete mode 100644 src/lunogram/app/models/user.py delete mode 100644 src/lunogram/app/types/event.py delete mode 100644 src/lunogram/app/types/identifier.py delete mode 100644 src/lunogram/app/types/organization.py delete mode 100644 src/lunogram/app/types/scheduled.py delete mode 100644 src/lunogram/app/types/user.py create mode 100644 src/lunogram/gen/__init__.py create mode 100644 src/lunogram/gen/models.py create mode 100644 tests/test_models.py create mode 100644 tests/test_reference.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..454fd3b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI + +on: + push: + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" build + + - name: Regenerate models from the spec + run: ./scripts/generate.sh + + - name: Check generated code is not stale (drift check) + run: git diff --exit-code src/lunogram/gen + + - name: Build wheel + run: python -m build + + - name: Import smoke test + run: | + python -c "from lunogram import Lunogram, random_user; print('import OK')" + + - name: Run tests + run: python -m pytest diff --git a/.github/workflows/spec-sync.yml b/.github/workflows/spec-sync.yml new file mode 100644 index 0000000..fd9dccb --- /dev/null +++ b/.github/workflows/spec-sync.yml @@ -0,0 +1,56 @@ +name: Spec sync + +on: + schedule: + # Weekly, Monday 06:00 UTC. + - cron: "0 6 * * 1" + workflow_dispatch: + +jobs: + sync: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" + + - name: Re-fetch the spec from the pinned ref in SOURCE.md + run: | + REF="$(grep -oE '[0-9a-f]{40}' spec/SOURCE.md | head -n1)" + if [ -z "$REF" ]; then + REF="$(grep -oE 'Pinned ref \| `[^`]+`' spec/SOURCE.md | sed -E 's/.*`([^`]+)`.*/\1/')" + fi + echo "Fetching spec at ref: $REF" + curl -fsSL \ + "https://raw.githubusercontent.com/lunogram/platform/$REF/internal/http/controllers/v1/client/oapi/resources.yml" \ + -o spec/client.yaml + + - name: Regenerate models + run: ./scripts/generate.sh + + - name: Open a PR if the spec or generated code changed + uses: peter-evans/create-pull-request@v6 + with: + branch: spec-sync/update + title: "chore: sync OpenAPI spec and regenerate models" + commit-message: "chore: sync OpenAPI spec and regenerate models" + body: | + Automated spec sync. + + Re-fetched `spec/client.yaml` from the pinned ref in `spec/SOURCE.md` + and regenerated `src/lunogram/gen/models.py`. Review the diff before + merging; bump the pinned ref in `spec/SOURCE.md` if appropriate. + add-paths: | + spec/client.yaml + src/lunogram/gen/models.py diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..83f889b --- /dev/null +++ b/Makefile @@ -0,0 +1,20 @@ +PYTHON ?= python3.12 + +.PHONY: install generate check-drift build test + +install: + $(PYTHON) -m pip install -e ".[dev]" + +# Regenerate the low-level model layer from the vendored spec. +generate: + ./scripts/generate.sh + +# Fail if the committed generated code is stale relative to the spec. +check-drift: generate + git diff --exit-code src/lunogram/gen + +build: + $(PYTHON) -m build + +test: + $(PYTHON) -m pytest diff --git a/README.md b/README.md index f7ed2e5..90b264b 100644 --- a/README.md +++ b/README.md @@ -68,3 +68,55 @@ segment: Authentication is unchanged — the same API key / token is used. Only the URL gained the project segment. + +## Architecture: spec-driven, layered + +The SDK is split into a **generated low-level layer** and a **hand-written +facade**: + +| Layer | Location | How it's produced | +| --- | --- | --- | +| Models / payload types | `src/lunogram/gen/models.py` | **Generated** from the OpenAPI spec (Pydantic v2 models) | +| Facade (`Lunogram`, path factory, UUID validation, auth header, transport) | `src/lunogram/client.py`, `app/**`, `utils/reference.py` | **Hand-written** | + +Cross-cutting concerns live in the facade in a single place: the +`/api/client/projects/{project_id}` prefix (in `utils/reference.py`) and the +`Bearer`-style auth header (in `app/http.py`). Resource methods never repeat the +project scoping. + +Because Python is snake_case end-to-end — matching the spec — the generated +models flow straight through to the request body with **no camelCase↔snake_case +mapping layer**. A generated model and a plain dict are both accepted by every +method; models are dumped with `model_dump(mode="json", exclude_none=True)`. + +### The spec is vendored (no platform release needed) + +`spec/client.yaml` is copied verbatim from the platform repo at a **pinned +commit**. `spec/SOURCE.md` records the source repo, spec path and ref. Pinning to +a commit means any ref is fetchable from the public repo via the raw URL, so no +platform release is required (flip to a `v*.*.*` tag once the platform cuts one). + +### Regenerating the models + +The generator is [`datamodel-code-generator`](https://github.com/koxudaxi/datamodel-code-generator) +(pinned in the `dev` extra). Install it and run the generate step: + +```bash +make install # pip install -e ".[dev]" +make generate # ./scripts/generate.sh -> src/lunogram/gen/models.py +``` + +`scripts/generate.sh` is the single source of truth for the generator flags; +the `make generate` target and CI both call it. The output carries a +"`DO NOT EDIT`" header — never hand-edit it; change the spec (or the script) and +regenerate. + +### CI + +- **`ci.yml`** (push / PR, Python 3.12) — installs deps, regenerates, runs + `git diff --exit-code src/lunogram/gen` as a **drift check** (fails if the + committed generated code is stale), builds the wheel, import-smoke-tests, and + runs `pytest`. +- **`spec-sync.yml`** (weekly cron + manual dispatch) — re-fetches the spec from + the ref in `spec/SOURCE.md`, regenerates, and opens a PR via + `peter-evans/create-pull-request` when anything changed. diff --git a/pyproject.toml b/pyproject.toml index ce04195..b2039cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,15 @@ requires-python = ">=3.10" dependencies = [ "python-dotenv>=1.0.0", "requests>=2.31.0", + "pydantic[email]>=2.6", +] + +[project.optional-dependencies] +# Code generation toolchain. Pinned so generated output is deterministic and +# the CI drift check is stable. Run `make generate` after bumping. +dev = [ + "datamodel-code-generator==0.26.5", + "pytest>=8.0", ] [project.urls] diff --git a/scripts/generate.sh b/scripts/generate.sh new file mode 100755 index 0000000..30d6c9c --- /dev/null +++ b/scripts/generate.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Generate the low-level Pydantic model layer from the vendored OpenAPI spec. +# +# Input : spec/client.yaml (vendored from the platform repo; see spec/SOURCE.md) +# Output: src/lunogram/gen/models.py (committed; "DO NOT EDIT" header) +# +# This is the single source of truth for codegen flags. It is invoked by the +# `make generate` target and by CI. Requires datamodel-code-generator (pinned in +# the project's `dev` extra) on a Python 3.10+ interpreter. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +datamodel-codegen \ + --input spec/client.yaml \ + --input-file-type openapi \ + --output src/lunogram/gen/models.py \ + --output-model-type pydantic_v2.BaseModel \ + --target-python-version 3.10 \ + --use-standard-collections \ + --use-union-operator \ + --snake-case-field \ + --use-schema-description \ + --use-field-description \ + --field-constraints \ + --disable-timestamp \ + --custom-file-header "# generated by datamodel-codegen; DO NOT EDIT. +# source: spec/client.yaml (run \`make generate\` to regenerate)" + +echo "Generated src/lunogram/gen/models.py from spec/client.yaml" diff --git a/spec/SOURCE.md b/spec/SOURCE.md new file mode 100644 index 0000000..f7ce808 --- /dev/null +++ b/spec/SOURCE.md @@ -0,0 +1,36 @@ +# Spec source + +`client.yaml` in this directory is **vendored** (copied verbatim) from the +platform repository's OpenAPI spec. It is the single input to the code generator +(`make generate` → `src/lunogram/gen/models.py`). + +| | | +| --- | --- | +| Source repo | https://github.com/lunogram/platform | +| Spec path | `internal/http/controllers/v1/client/oapi/resources.yml` | +| Pinned ref | `a12f901dc98e7ced44efbad27a388e8bf5ee0f3a` | + +The pinned ref is the head of platform PR #262 (the project-in-URL change). It is +a **branch/commit pin during development** — pinning to a commit means the spec +is fetchable from the public repo via the `raw.githubusercontent.com` URL with +no platform release required. Flip this to a `v*.*.*` release tag once the +platform cuts one. + +## Raw URL + +``` +https://raw.githubusercontent.com/lunogram/platform/a12f901dc98e7ced44efbad27a388e8bf5ee0f3a/internal/http/controllers/v1/client/oapi/resources.yml +``` + +## Refreshing the spec + +To pull a newer spec, update the pinned ref above and re-fetch: + +```bash +REF=a12f901dc98e7ced44efbad27a388e8bf5ee0f3a +curl -fsSL "https://raw.githubusercontent.com/lunogram/platform/$REF/internal/http/controllers/v1/client/oapi/resources.yml" -o spec/client.yaml +make generate +``` + +The `spec-sync` GitHub Actions workflow automates exactly this on a weekly +schedule and opens a PR when the spec or generated code changes. diff --git a/spec/client.yaml b/spec/client.yaml new file mode 100644 index 0000000..5bdf2ca --- /dev/null +++ b/spec/client.yaml @@ -0,0 +1,1779 @@ +openapi: 3.0.3 +info: + title: Lunogram Public API + description: API for ingesting data such as user profiles and events. + version: 1.0.0 + +paths: + /api/client/projects/{projectID}/users: + parameters: + - $ref: "#/components/parameters/ProjectID" + post: + summary: Upsert user + description: Used by client libraries to create or update a user profile. The project is taken from the projectID path parameter. + operationId: upsertUserClient + tags: + - Client + security: + - HttpBearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/IdentifyRequest" + responses: + "200": + description: User upserted successfully + content: + application/json: + schema: + $ref: "#/components/schemas/User" + default: + $ref: "#/components/responses/Error" + delete: + summary: Delete user + description: | + Used by client libraries to delete a user by external_id or anonymous_id. + The project is taken from the projectID path parameter. + operationId: deleteUserClient + tags: + - Client + security: + - HttpBearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteUserRequest" + responses: + "204": + description: User deleted successfully + default: + $ref: "#/components/responses/Error" + + /api/client/projects/{projectID}/users/events: + parameters: + - $ref: "#/components/parameters/ProjectID" + post: + summary: Post user events + description: | + Used by client libraries to trigger events for a user that can be used to execute a step in a journey or update a virtual list. + Multiple events can be sent in a single request and are processed asynchronously. + Each event is handled independently: if one event fails to be accepted or processed, other events in the same request may still be published successfully. + Clients requiring deterministic retry or recovery behavior must submit events individually. + The project is taken from the projectID path parameter. + operationId: postUserEvents + tags: + - Client + security: + - HttpBearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PostEventsRequest" + responses: + "202": + description: Events accepted for asynchronous processing + default: + $ref: "#/components/responses/Error" + + /api/client/projects/{projectID}/users/inbox: + parameters: + - $ref: "#/components/parameters/ProjectID" + post: + summary: Create user inbox messages + description: Creates one or more inbox messages for users identified by external identifiers. Messages are processed asynchronously. + operationId: postUserInboxMessages + tags: + - Client + security: + - HttpBearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PostUserInboxMessagesRequest" + responses: + "202": + description: Inbox messages accepted for asynchronous processing + default: + $ref: "#/components/responses/Error" + get: + summary: Query user inbox messages + description: Returns visible, non-expired inbox messages for a user identified by source and external_id. + operationId: getUserInbox + tags: + - Client + security: + - HttpBearerAuth: [] + parameters: + - name: source + in: query + required: true + schema: + type: string + - name: external_id + in: query + required: true + schema: + type: string + - name: status + in: query + schema: + type: string + enum: [unread, read, archived] + - name: tags + in: query + schema: + type: string + description: Comma-separated tag filter. All listed tags must be present. + - name: message_source + in: query + schema: + type: string + - name: priority + in: query + schema: + type: integer + - name: channel + in: query + required: true + schema: + $ref: "#/components/schemas/Channel" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Offset" + responses: + "200": + description: User inbox messages retrieved successfully + content: + application/json: + schema: + $ref: "#/components/schemas/InboxMessageList" + default: + $ref: "#/components/responses/Error" + + /api/client/projects/{projectID}/users/inbox/count: + parameters: + - $ref: "#/components/parameters/ProjectID" + get: + summary: Count user inbox messages + description: Returns unread and total visible inbox message counts for a user. + operationId: getUserInboxCount + tags: + - Client + security: + - HttpBearerAuth: [] + parameters: + - name: source + in: query + required: true + schema: + type: string + - name: external_id + in: query + required: true + schema: + type: string + - name: channel + in: query + required: true + schema: + $ref: "#/components/schemas/Channel" + responses: + "200": + description: User inbox count retrieved successfully + content: + application/json: + schema: + $ref: "#/components/schemas/InboxCount" + default: + $ref: "#/components/responses/Error" + + /api/client/projects/{projectID}/users/inbox/read: + parameters: + - $ref: "#/components/parameters/ProjectID" + post: + summary: Mark user inbox messages read + description: Marks one or more user inbox messages as read. Processed asynchronously. + operationId: postUserInboxRead + tags: + - Client + security: + - HttpBearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UserInboxMessageEvents" + responses: + "202": + description: Inbox read events accepted for asynchronous processing + default: + $ref: "#/components/responses/Error" + + /api/client/projects/{projectID}/users/inbox/archived: + parameters: + - $ref: "#/components/parameters/ProjectID" + post: + summary: Mark user inbox messages archived + description: Marks one or more user inbox messages as archived. Processed asynchronously. + operationId: postUserInboxArchived + tags: + - Client + security: + - HttpBearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UserInboxMessageEvents" + responses: + "202": + description: Inbox archived events accepted for asynchronous processing + default: + $ref: "#/components/responses/Error" + + /api/client/projects/{projectID}/organizations: + parameters: + - $ref: "#/components/parameters/ProjectID" + post: + summary: Upsert organization + description: | + Used by client libraries to create or update an organization by external_id. + The project is taken from the projectID path parameter. + operationId: upsertOrganizationClient + tags: + - Client + security: + - HttpBearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/OrganizationRequest" + responses: + "200": + description: Organization upserted successfully + content: + application/json: + schema: + $ref: "#/components/schemas/Organization" + default: + $ref: "#/components/responses/Error" + delete: + summary: Delete organization + description: | + Used by client libraries to delete an organization by external_id. + This will also remove all organization memberships. + The project is taken from the projectID path parameter. + operationId: deleteOrganizationClient + tags: + - Client + security: + - HttpBearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteOrganizationRequest" + responses: + "204": + description: Organization deleted successfully + default: + $ref: "#/components/responses/Error" + + /api/client/projects/{projectID}/organizations/users: + parameters: + - $ref: "#/components/parameters/ProjectID" + post: + summary: Add user to organization + description: | + Used by client libraries to add a user to an organization with optional org-specific data. + Both organization and user are identified by their external_id. + The project is taken from the projectID path parameter. + operationId: addOrganizationUserClient + tags: + - Client + security: + - HttpBearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/OrganizationUserRequest" + responses: + "200": + description: User added to organization successfully + default: + $ref: "#/components/responses/Error" + + delete: + summary: Remove user from organization + description: | + Used by client libraries to remove a user from an organization. + Both organization and user are identified by their external_id. + The project is taken from the projectID path parameter. + operationId: removeOrganizationUserClient + tags: + - Client + security: + - HttpBearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RemoveOrganizationUserRequest" + responses: + "204": + description: User removed from organization successfully + default: + $ref: "#/components/responses/Error" + + /api/client/projects/{projectID}/users/scheduled: + parameters: + - $ref: "#/components/parameters/ProjectID" + post: + summary: Upsert user scheduled + description: | + Used by client libraries to create or update a scheduled resource for a user. + The user is identified by external_id or anonymous_id. + This triggers journey entrance recalculation for any journeys that depend on the scheduled resource. + The project is taken from the projectID path parameter. + operationId: upsertUserScheduledClient + tags: + - Client + security: + - HttpBearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpsertUserScheduledRequest" + responses: + "202": + description: Scheduled resource accepted for asynchronous processing + content: + application/json: + schema: + $ref: "#/components/schemas/ScheduledAccepted" + default: + $ref: "#/components/responses/Error" + delete: + summary: Delete user scheduled + description: | + Used by client libraries to delete all scheduled instances for a user with a given scheduled resource name. + The user is identified by external_id or anonymous_id. + This cancels any pending journey entrance states that depend on the scheduled resource. + The project is taken from the projectID path parameter. + operationId: deleteUserScheduledClient + tags: + - Client + security: + - HttpBearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteUserScheduledRequest" + responses: + "200": + description: User scheduled deleted successfully + default: + $ref: "#/components/responses/Error" + + /api/client/projects/{projectID}/organizations/scheduled: + parameters: + - $ref: "#/components/parameters/ProjectID" + post: + summary: Upsert organization scheduled + description: | + Used by client libraries to create or update a scheduled resource for an organization. + The organization is identified by external_id. + This triggers journey entrance recalculation for any journeys that depend on the scheduled resource. + The project is taken from the projectID path parameter. + operationId: upsertOrganizationScheduledClient + tags: + - Client + security: + - HttpBearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpsertOrganizationScheduledRequest" + responses: + "202": + description: Scheduled resource accepted for asynchronous processing + content: + application/json: + schema: + $ref: "#/components/schemas/ScheduledAccepted" + default: + $ref: "#/components/responses/Error" + delete: + summary: Delete organization scheduled + description: | + Used by client libraries to delete all scheduled instances for an organization with a given scheduled resource name. + The organization is identified by external_id. + This cancels any pending journey entrance states that depend on the scheduled resource. + The project is taken from the projectID path parameter. + operationId: deleteOrganizationScheduledClient + tags: + - Client + security: + - HttpBearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteOrganizationScheduledRequest" + responses: + "200": + description: Organization scheduled deleted successfully + default: + $ref: "#/components/responses/Error" + + /api/client/projects/{projectID}/organizations/events: + parameters: + - $ref: "#/components/parameters/ProjectID" + post: + summary: Post organization events + description: | + Used by client libraries to trigger events on an organization. + The organization is identified by external_id. + Events are processed asynchronously and can trigger journeys for all users in the organization. + The project is taken from the projectID path parameter. + operationId: postOrganizationEventsClient + tags: + - Client + security: + - HttpBearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PostOrganizationEventsRequest" + responses: + "202": + description: Events accepted for asynchronous processing + default: + $ref: "#/components/responses/Error" + + /api/client/projects/{projectID}/organizations/inbox: + parameters: + - $ref: "#/components/parameters/ProjectID" + post: + summary: Create organization inbox messages + description: Creates one or more inbox messages for organizations identified by external identifiers. Messages are processed asynchronously. + operationId: postOrganizationInboxMessages + tags: + - Client + security: + - HttpBearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PostOrganizationInboxMessagesRequest" + responses: + "202": + description: Inbox messages accepted for asynchronous processing + default: + $ref: "#/components/responses/Error" + get: + summary: Query organization inbox messages + description: Returns visible, non-expired inbox messages for an organization identified by source and external_id. + operationId: getOrganizationInbox + tags: + - Client + security: + - HttpBearerAuth: [] + parameters: + - name: source + in: query + required: true + schema: + type: string + - name: external_id + in: query + required: true + schema: + type: string + - name: status + in: query + schema: + type: string + enum: [unread, read, archived] + - name: tags + in: query + schema: + type: string + description: Comma-separated tag filter. All listed tags must be present. + - name: message_source + in: query + schema: + type: string + - name: priority + in: query + schema: + type: integer + - name: channel + in: query + required: true + schema: + $ref: "#/components/schemas/Channel" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Offset" + responses: + "200": + description: Organization inbox messages retrieved successfully + content: + application/json: + schema: + $ref: "#/components/schemas/InboxMessageList" + default: + $ref: "#/components/responses/Error" + + /api/client/projects/{projectID}/organizations/inbox/count: + parameters: + - $ref: "#/components/parameters/ProjectID" + get: + summary: Count organization inbox messages + description: Returns unread and total visible inbox message counts for an organization. + operationId: getOrganizationInboxCount + tags: + - Client + security: + - HttpBearerAuth: [] + parameters: + - name: source + in: query + required: true + schema: + type: string + - name: external_id + in: query + required: true + schema: + type: string + - name: channel + in: query + required: true + schema: + $ref: "#/components/schemas/Channel" + responses: + "200": + description: Organization inbox count retrieved successfully + content: + application/json: + schema: + $ref: "#/components/schemas/InboxCount" + default: + $ref: "#/components/responses/Error" + + /api/client/projects/{projectID}/organizations/inbox/read: + parameters: + - $ref: "#/components/parameters/ProjectID" + post: + summary: Mark organization inbox messages read + description: Marks one or more organization inbox messages as read. Processed asynchronously. + operationId: postOrganizationInboxRead + tags: + - Client + security: + - HttpBearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/OrganizationInboxMessageEvents" + responses: + "202": + description: Inbox read events accepted for asynchronous processing + default: + $ref: "#/components/responses/Error" + + /api/client/projects/{projectID}/organizations/inbox/archived: + parameters: + - $ref: "#/components/parameters/ProjectID" + post: + summary: Mark organization inbox messages archived + description: Marks one or more organization inbox messages as archived. Processed asynchronously. + operationId: postOrganizationInboxArchived + tags: + - Client + security: + - HttpBearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/OrganizationInboxMessageEvents" + responses: + "202": + description: Inbox archived events accepted for asynchronous processing + default: + $ref: "#/components/responses/Error" + + /unsubscribe/email: + get: + summary: Email unsubscribe page + description: | + Public endpoint for email unsubscribe links. Extracts user and campaign information + from the encoded URL and displays an unsubscribe confirmation page. + operationId: emailUnsubscribe + tags: + - Subscriptions + parameters: + - name: link + in: query + required: true + schema: + type: string + description: Encoded unsubscribe link with user and campaign data + responses: + "200": + description: Unsubscribe confirmation page + content: + text/html: + schema: + type: string + + /preferences/{projectID}/{userID}: + get: + summary: Subscription preferences page + description: | + Public endpoint for users to view and manage their subscription preferences. + Shows all public subscriptions with current user state. + operationId: getPreferencesPage + tags: + - Subscriptions + parameters: + - name: projectID + in: path + required: true + schema: + type: string + format: uuid + description: The project ID + - name: userID + in: path + required: true + schema: + type: string + format: uuid + description: The user ID + responses: + "200": + description: Subscription preferences page + content: + text/html: + schema: + type: string + + post: + summary: Update subscription preferences + description: | + Handles form submission to update user subscription preferences. + Redirects back to preferences page with success indicator. + operationId: updatePreferences + tags: + - Subscriptions + parameters: + - name: projectID + in: path + required: true + schema: + type: string + format: uuid + description: The project ID + - name: userID + in: path + required: true + schema: + type: string + format: uuid + description: The user ID + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + subscriptionIds: + type: array + items: + type: string + format: uuid + description: Array of subscription IDs to keep subscribed + responses: + "302": + description: Redirect to preferences page with success indicator + "200": + description: Success response (for htmx partial updates) + content: + text/html: + schema: + type: string + + /api/client/projects/{projectID}/users/devices: + parameters: + - $ref: "#/components/parameters/ProjectID" + post: + summary: Register device + description: Register or update a device's push subscription. The project is taken from the projectID path parameter. + operationId: registerDevice + tags: + - Client + security: + - HttpBearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DeviceRegistration" + responses: + "201": + description: Device registered successfully + default: + $ref: "#/components/responses/Error" + + /api/client/projects/{projectID}/push/vapid: + parameters: + - $ref: "#/components/parameters/ProjectID" + get: + summary: Get VAPID public key + description: Retrieves the VAPID public key for push notifications + operationId: getVapidPublicKey + tags: + - Client + security: + - HttpBearerAuth: [] + responses: + "200": + description: VAPID public key retrieved successfully + content: + application/json: + schema: + $ref: "#/components/schemas/VapidPublicKey" + default: + $ref: "#/components/responses/Error" + + /api/client/projects/{projectID}/auth-methods/{authMethodID}/sessions: + parameters: + - $ref: "#/components/parameters/ProjectID" + post: + summary: Mint a session token + description: | + Mints a short-lived session token for an end user, to be used from the + client. Called server-side with an API key; the session's permissions are + defined by the session auth method (policy) named in the path. + operationId: createSession + tags: + - Client + security: + - HttpBearerAuth: [] + parameters: + - name: authMethodID + in: path + required: true + description: The session auth method (policy) that defines what the session may do. + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateSession" + responses: + "201": + description: Session token minted successfully + content: + application/json: + schema: + $ref: "#/components/schemas/SessionToken" + default: + $ref: "#/components/responses/Error" + +components: + parameters: + ProjectID: + name: projectID + in: path + required: true + schema: + type: string + format: uuid + description: The project the request acts on. Must match the project the presented credential is authorized for. + + Limit: + name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + x-go-type: PaginationLimit + description: Maximum number of items to return + + Offset: + name: offset + in: query + required: false + schema: + type: integer + minimum: 0 + default: 0 + x-go-type: PaginationOffset + description: Number of items to skip + + responses: + Error: + description: Error response + content: + application/json: + schema: + $ref: "#/components/schemas/Problem" + + schemas: + CreateSession: + type: object + description: Request to mint a session token for an end user. + required: + - user_id + properties: + user_id: + type: string + description: The end user's external identifier (the session subject). + example: "user_12345" + + SessionToken: + type: object + required: + - token + - expires_at + properties: + token: + type: string + description: The signed session token (a bearer token for the Client API). + expires_at: + type: string + format: date-time + + Channel: + type: string + enum: [email, sms, push, inbox] + + Problem: + type: object + required: + - title + - detail + properties: + title: + type: string + description: | + A short summary of the problem type. Written in English and readable for engineers, usually not suited for non technical stakeholders and not localized. + example: some title for the error situation + detail: + type: string + description: | + A human readable explanation specific to this occurrence of the problem that is helpful to locate the problem and give advice on how to proceed. Written in English and readable for engineers, usually not suited for non technical stakeholders and not localized. + example: some description for the error situation + + ExternalID: + type: object + description: An external identifier with source and optional metadata + required: + - external_id + properties: + source: + type: string + default: "default" + example: "default" + description: Source of the identifier (e.g. "default", "anonymous", or a custom source). Defaults to "default" if not provided. + external_id: + type: string + minLength: 1 + example: "user_12345" + description: The external identifier value + metadata: + type: object + additionalProperties: true + nullable: true + x-go-type: map[string]any + description: Optional metadata associated with this identifier + + ExternalIDResponse: + type: object + description: An external identifier as returned in responses, including database ID and timestamps + required: + - id + - source + - external_id + - created_at + - updated_at + properties: + id: + type: string + format: uuid + example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + source: + type: string + example: "default" + external_id: + type: string + example: "user_12345" + metadata: + type: object + additionalProperties: true + nullable: true + x-go-type: map[string]any + created_at: + type: string + format: date-time + example: "2025-11-19T14:18:42.960Z" + updated_at: + type: string + format: date-time + example: "2025-11-23T17:20:00.021Z" + + User: + type: object + required: + - id + - project_id + - identifier + - data + - has_push_device + - version + - created_at + - updated_at + properties: + id: + type: string + format: uuid + example: "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d" + project_id: + type: string + format: uuid + example: "4c9d3163-7b64-4f9e-9068-d2e4b96be56b" + identifier: + type: array + items: + $ref: "#/components/schemas/ExternalIDResponse" + description: External identifiers associated with this user + email: + type: string + format: email + example: "user@example.com" + x-go-type: string + phone: + type: string + example: "+1234567890" + description: E.164 formatted phone number + x-go-type: string + format: phone + data: + type: object + additionalProperties: true + x-go-type: map[string]any + example: + first_name: "John" + last_name: "Doe" + timezone: + type: string + example: "America/New_York" + locale: + type: string + example: "en" + has_push_device: + type: boolean + example: false + version: + type: integer + example: 1 + x-go-type: int32 + created_at: + type: string + format: date-time + example: "2025-11-19T14:18:42.960Z" + updated_at: + type: string + format: date-time + example: "2025-11-23T17:20:00.021Z" + + UserIdentifier: + type: array + description: One or more external identifiers to identify the user + minItems: 1 + items: + $ref: "#/components/schemas/ExternalID" + + OrganizationIdentifier: + type: array + description: One or more external identifiers to identify the organization + minItems: 1 + items: + $ref: "#/components/schemas/ExternalID" + + DeviceRegistration: + type: object + required: + - identifier + - device_id + - config + properties: + identifier: + $ref: "#/components/schemas/UserIdentifier" + device_id: + type: string + os: + type: string + enum: [web, ios, android] + os_version: + type: string + model: + type: string + app_version: + type: string + data: + type: object + additionalProperties: true + nullable: true + x-go-type: json.RawMessage + example: + app_channel: "beta" + locale: "en-US" + config: + type: object + properties: + token: + type: string + description: Device token for FCM or APNs + endpoint: + type: string + description: Web Push subscription endpoint URL + expiration_time: + type: string + format: date-time + keys: + type: object + required: [p256dh, auth] + properties: + p256dh: + type: string + auth: + type: string + + VapidPublicKey: + type: object + required: + - public_key + properties: + public_key: + type: string + description: The VAPID public key + + IdentifyRequest: + type: object + required: + - identifier + properties: + identifier: + $ref: "#/components/schemas/UserIdentifier" + email: + type: string + nullable: true + format: email + example: "user@example.com" + x-go-type: string + phone: + type: string + nullable: true + example: "+31612345678" + description: E.164 formatted phone number + x-go-type: string + format: phone + timezone: + type: string + nullable: true + example: "Europe/Amsterdam" + locale: + type: string + nullable: true + example: "nl-NL" + data: + type: object + additionalProperties: true + nullable: true + x-go-type: map[string]any + example: + first_name: "John" + last_name: "Smith" + has_completed_onboarding: true + description: User-specific attributes + + PostEventsRequest: + type: array + minItems: 1 + items: + $ref: "#/components/schemas/Event" + + Event: + type: object + required: + - name + - data + properties: + name: + type: string + example: "purchase_completed" + description: The name of the event + identifier: + $ref: "#/components/schemas/UserIdentifier" + match: + type: object + additionalProperties: true + nullable: true + x-go-type: map[string]any + description: >- + JSONB containment filter to match users by their data attributes. + Mutually exclusive with identifier. When set, the event is delivered + to every user whose data column contains the given key/value pairs. + example: + plan: "enterprise" + data: + type: object + additionalProperties: true + x-go-type: map[string]any + example: + product_id: "prod_789" + amount: 99.99 + description: Event-specific data + + InboxMessageCreate: + type: object + required: + - target + - channel + - identifier + properties: + target: + $ref: "#/components/schemas/UserIdentifier" + identifier: + $ref: "#/components/schemas/ExternalID" + description: External identifier for the message. Required for idempotency and traceability back to the origin source. + channel: + $ref: "#/components/schemas/Channel" + sender_identity_id: + type: string + format: uuid + nullable: true + description: Required for email and sms messages. Push uses project push provider settings. + campaign_id: + type: string + format: uuid + nullable: true + broadcast_id: + type: string + format: uuid + nullable: true + content: + type: object + additionalProperties: true + nullable: true + x-go-type: json.RawMessage + description: Channel-specific payload content. + data: + type: object + additionalProperties: true + nullable: true + x-go-type: map[string]any + tags: + type: array + items: + type: string + priority: + type: integer + minimum: 1 + maximum: 5 + default: 3 + x-go-type: int16 + source: + type: string + nullable: true + scheduled_at: + type: string + format: date-time + nullable: true + expires_at: + type: string + format: date-time + nullable: true + + OrganizationInboxMessageCreate: + type: object + required: + - target + - channel + - identifier + properties: + target: + $ref: "#/components/schemas/OrganizationIdentifier" + identifier: + $ref: "#/components/schemas/ExternalID" + description: External identifier for the message. Required for idempotency and traceability back to the origin source. + channel: + $ref: "#/components/schemas/Channel" + sender_identity_id: + type: string + format: uuid + nullable: true + description: Required for email and sms messages. Push uses project push provider settings. + campaign_id: + type: string + format: uuid + nullable: true + broadcast_id: + type: string + format: uuid + nullable: true + content: + type: object + additionalProperties: true + nullable: true + x-go-type: json.RawMessage + description: Channel-specific payload content. + data: + type: object + additionalProperties: true + nullable: true + x-go-type: map[string]any + tags: + type: array + items: + type: string + priority: + type: integer + minimum: 1 + maximum: 5 + default: 3 + x-go-type: int16 + source: + type: string + nullable: true + scheduled_at: + type: string + format: date-time + nullable: true + expires_at: + type: string + format: date-time + nullable: true + + PostUserInboxMessagesRequest: + type: array + minItems: 1 + maxItems: 100 + items: + $ref: "#/components/schemas/InboxMessageCreate" + + PostOrganizationInboxMessagesRequest: + type: array + minItems: 1 + maxItems: 100 + items: + $ref: "#/components/schemas/OrganizationInboxMessageCreate" + + UserInboxMessageRef: + type: object + required: + - target + - message_id + properties: + target: + $ref: "#/components/schemas/UserIdentifier" + message_id: + type: string + format: uuid + + OrganizationInboxMessageRef: + type: object + required: + - target + - message_id + properties: + target: + $ref: "#/components/schemas/OrganizationIdentifier" + message_id: + type: string + format: uuid + + UserInboxMessageEvents: + type: array + minItems: 1 + maxItems: 100 + items: + $ref: "#/components/schemas/UserInboxMessageRef" + + OrganizationInboxMessageEvents: + type: array + minItems: 1 + maxItems: 100 + items: + $ref: "#/components/schemas/OrganizationInboxMessageRef" + + InboxMessage: + type: object + required: + - id + - project_id + - channel + - content + - data + - tags + - priority + - scheduled_at + - created_at + - updated_at + properties: + id: + type: string + format: uuid + project_id: + type: string + format: uuid + user_id: + type: string + format: uuid + organization_id: + type: string + format: uuid + external_id: + type: string + nullable: true + description: External identifier for the message, if one was provided at creation time. + channel: + $ref: "#/components/schemas/Channel" + sender_identity_id: + type: string + format: uuid + nullable: true + campaign_id: + type: string + format: uuid + nullable: true + broadcast_id: + type: string + format: uuid + nullable: true + content: + type: object + additionalProperties: true + x-go-type: json.RawMessage + data: + type: object + additionalProperties: true + x-go-type: json.RawMessage + tags: + type: array + items: + type: string + priority: + type: integer + minimum: 1 + maximum: 5 + x-go-type: int16 + source: + type: string + nullable: true + scheduled_at: + type: string + format: date-time + expires_at: + type: string + format: date-time + nullable: true + read_at: + type: string + format: date-time + nullable: true + archived_at: + type: string + format: date-time + nullable: true + sent_at: + type: string + format: date-time + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + InboxMessageList: + type: object + required: + - results + - total + - limit + - offset + properties: + results: + type: array + items: + $ref: "#/components/schemas/InboxMessage" + total: + type: integer + limit: + type: integer + offset: + type: integer + + InboxCount: + type: object + required: + - unread + - total + properties: + unread: + type: integer + total: + type: integer + + Organization: + type: object + required: + - id + - project_id + - identifier + - data + - version + - created_at + - updated_at + properties: + id: + type: string + format: uuid + example: "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d" + project_id: + type: string + format: uuid + example: "4c9d3163-7b64-4f9e-9068-d2e4b96be56b" + identifier: + type: array + items: + $ref: "#/components/schemas/ExternalIDResponse" + description: External identifiers associated with this organization + name: + type: string + example: "Acme Corp" + data: + type: object + additionalProperties: true + x-go-type: map[string]any + example: + industry: "technology" + size: "enterprise" + version: + type: integer + example: 1 + x-go-type: int32 + created_at: + type: string + format: date-time + example: "2025-11-19T14:18:42.960Z" + updated_at: + type: string + format: date-time + example: "2025-11-23T17:20:00.021Z" + + OrganizationRequest: + type: object + required: + - identifier + properties: + identifier: + $ref: "#/components/schemas/OrganizationIdentifier" + name: + type: string + nullable: true + example: "Acme Corp" + data: + type: object + additionalProperties: true + nullable: true + x-go-type: map[string]any + example: + industry: "technology" + size: "enterprise" + + OrganizationUserRequest: + type: object + required: + - organization + - user + properties: + organization: + type: object + required: + - identifier + properties: + identifier: + $ref: "#/components/schemas/OrganizationIdentifier" + user: + type: object + required: + - identifier + properties: + identifier: + $ref: "#/components/schemas/UserIdentifier" + data: + type: object + additionalProperties: true + nullable: true + x-go-type: map[string]any + example: + role: "admin" + department: "engineering" + description: Organization-specific data for this user + + DeleteUserRequest: + type: object + required: + - identifier + properties: + identifier: + $ref: "#/components/schemas/UserIdentifier" + + DeleteOrganizationRequest: + type: object + required: + - identifier + properties: + identifier: + $ref: "#/components/schemas/OrganizationIdentifier" + + RemoveOrganizationUserRequest: + type: object + required: + - organization + - user + properties: + organization: + type: object + required: + - identifier + properties: + identifier: + $ref: "#/components/schemas/OrganizationIdentifier" + user: + type: object + required: + - identifier + properties: + identifier: + $ref: "#/components/schemas/UserIdentifier" + + PostOrganizationEventsRequest: + type: array + minItems: 1 + items: + $ref: "#/components/schemas/OrganizationEvent" + + OrganizationEvent: + type: object + required: + - name + properties: + identifier: + $ref: "#/components/schemas/OrganizationIdentifier" + match: + type: object + additionalProperties: true + nullable: true + x-go-type: map[string]any + description: >- + JSONB containment filter to match organizations by their data attributes. + Mutually exclusive with identifier. When set, the event is delivered + to every organization whose data column contains the given key/value pairs. + example: + plan: "enterprise" + name: + type: string + example: "subscription_upgraded" + description: The name of the event + data: + type: object + additionalProperties: true + nullable: true + x-go-type: map[string]any + example: + plan: "enterprise" + seats: 100 + description: Event-specific data + + UpsertUserScheduledRequest: + type: object + required: + - name + properties: + name: + type: string + example: "renewal_date" + description: The name of the scheduled resource + identifier: + $ref: "#/components/schemas/UserIdentifier" + scheduled_at: + type: string + format: date-time + nullable: true + example: "2025-12-25T10:00:00Z" + description: The time at which the scheduled resource is set to trigger. Required for single schedules. + start_at: + type: string + format: date-time + nullable: true + example: "2025-01-01T00:00:00Z" + description: Start time for recurring schedules. If omitted for recurring schedules, defaults to now. + interval: + type: string + nullable: true + example: "24h" + description: Interval for recurring schedules. When set, the schedule type is automatically set to recurring. + data: + type: object + additionalProperties: true + nullable: true + x-go-type: map[string]any + example: + plan: "pro" + amount: 29.99 + description: Scheduled resource data + + UpsertOrganizationScheduledRequest: + type: object + required: + - name + - identifier + properties: + name: + type: string + example: "contract_renewal" + description: The name of the scheduled resource + identifier: + $ref: "#/components/schemas/OrganizationIdentifier" + scheduled_at: + type: string + format: date-time + nullable: true + example: "2025-12-25T10:00:00Z" + description: The time at which the scheduled resource is set to trigger. Required for single schedules. + start_at: + type: string + format: date-time + nullable: true + example: "2025-01-01T00:00:00Z" + description: Start time for recurring schedules. If omitted for recurring schedules, defaults to now. + interval: + type: string + nullable: true + example: "24h" + description: Interval for recurring schedules. When set, the schedule type is automatically set to recurring. + data: + type: object + additionalProperties: true + nullable: true + x-go-type: map[string]any + example: + contract_type: "enterprise" + seats: 100 + description: Scheduled resource data + + DeleteUserScheduledRequest: + type: object + required: + - name + properties: + name: + type: string + example: "renewal_date" + description: The name of the scheduled resource to delete + identifier: + $ref: "#/components/schemas/UserIdentifier" + + ScheduledAccepted: + type: object + required: + - id + - name + - scheduled_at + properties: + id: + type: string + format: uuid + example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + description: The unique identifier for the scheduled instance + name: + type: string + example: "trial_end" + description: The name of the scheduled resource + scheduled_at: + type: string + format: date-time + example: "2025-01-15T09:00:00Z" + description: The time at which the scheduled resource is set to trigger + data: + type: object + additionalProperties: true + nullable: true + x-go-type: "map[string]any" + description: Scheduled resource data + + DeleteOrganizationScheduledRequest: + type: object + required: + - name + - identifier + properties: + name: + type: string + example: "contract_renewal" + description: The name of the scheduled resource to delete + identifier: + $ref: "#/components/schemas/OrganizationIdentifier" + + securitySchemes: + HttpBearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: JWT token for API authentication diff --git a/src/lunogram/app/models/objects.py b/src/lunogram/app/models/objects.py deleted file mode 100644 index 9d85047..0000000 --- a/src/lunogram/app/models/objects.py +++ /dev/null @@ -1,50 +0,0 @@ -from typing import Literal - -from ..http import httphandler -from ...utils.reference import client as reference_client - -from ..types.event import * -from ..types.scheduled import * - -entities = Literal["user", "organization"] - -# .events and .scheduled objects are defined seperately and later integrated with the corresponding entities - -class events: - def __init__(self, api_key, reference: reference_client, entity: entities): - self.entity = entity - self.reference = reference - self.handler = httphandler(api_key) - - def post(self, data: Event): - match(self.entity): - case 'user': - req = self.handler.post(self.reference.user.events, data) - case 'organization': - req = self.handler.post(self.reference.organization.events, data) - - return req - -class scheduled: - def __init__(self, api_key, reference: reference_client, entity: entities): - self.entity = entity - self.reference = reference - self.handler = httphandler(api_key) - - def post(self, data: UpsertScheduled): - match(self.entity): - case 'user': - req = self.handler.post(self.reference.user.scheduled, data) - case 'organization': - req = self.handler.post(self.reference.organization.scheduled, data) - - return req - - def delete(self, data: DeleteScheduled): - match(self.entity): - case 'user': - req = self.handler.delete(self.reference.user.scheduled, data) - case 'organization': - req = self.handler.delete(self.reference.organization.scheduled, data) - - return req diff --git a/src/lunogram/app/models/organization.py b/src/lunogram/app/models/organization.py deleted file mode 100644 index 5cecb98..0000000 --- a/src/lunogram/app/models/organization.py +++ /dev/null @@ -1,22 +0,0 @@ -from .objects import events, scheduled -from ..http import httphandler -from ..types.organization import * - -from ...utils.reference import client as reference_client - -class organization: - def __init__(self, api_key, reference: reference_client): - self.reference = reference - self.events = events(api_key, reference, entity='organization') - self.scheduled = scheduled(api_key, reference, entity='organization') - self.handler = httphandler(api_key) - - def upsert(self, data: UpsertOrganization): - req = self.handler.post(self.reference.organization.base, data) - - return req - - def delete(self, data: DeleteOrganization): - req = self.handler.delete(self.reference.organization.base, data) - - return req diff --git a/src/lunogram/app/models/user.py b/src/lunogram/app/models/user.py deleted file mode 100644 index a35d0e2..0000000 --- a/src/lunogram/app/models/user.py +++ /dev/null @@ -1,22 +0,0 @@ -from .objects import events, scheduled -from ..http import httphandler - -from ...utils.reference import client as reference_client -from ..types.user import * - -class user: - def __init__(self, api_key, reference: reference_client): - self.reference = reference - self.events = events(api_key, reference, entity='user') - self.scheduled = scheduled(api_key, reference, entity='user') - self.handler = httphandler(api_key) - - def upsert(self, data: UpsertUser): - req = self.handler.post(self.reference.user.base, data) - - return req - - def delete(self, data: DeleteUser): - req = self.handler.delete(self.reference.user.base, data) - - return req diff --git a/src/lunogram/app/objects.py b/src/lunogram/app/objects.py index 93f87d0..0377c79 100644 --- a/src/lunogram/app/objects.py +++ b/src/lunogram/app/objects.py @@ -1,10 +1,37 @@ -from typing import Literal +from typing import Literal, Union + +from pydantic import BaseModel from .http import httphandler from ..utils.reference import client +from ..gen.models import ( + IdentifyRequest, + DeleteUserRequest, + OrganizationRequest, + DeleteOrganizationRequest, + Event, + OrganizationEvent, + UpsertUserScheduledRequest, + UpsertOrganizationScheduledRequest, + DeleteUserScheduledRequest, + DeleteOrganizationScheduledRequest, +) entities = Literal["user", "organization"] +# Request bodies accept either a generated Pydantic model or a plain dict. +Body = Union[BaseModel, dict] + + +def _serialize(data): + # The generated models mirror the spec's snake_case payloads exactly, so a + # model can be dumped straight to the JSON body with no key mapping. Plain + # dicts are passed through unchanged. + if isinstance(data, BaseModel): + return data.model_dump(mode="json", exclude_none=True) + return data + + # .events and .scheduled objects are defined seperately and later integrated with the corresponding entities class events: @@ -13,7 +40,8 @@ def __init__(self, api_key, reference: client, entity: entities): self.reference = reference self.handler = httphandler(api_key) - def post(self, data): + def post(self, data: Union[Event, OrganizationEvent, list, Body]): + data = _serialize(data) match(self.entity): case 'user': req = self.handler.post(self.reference.user.events, data) @@ -28,7 +56,8 @@ def __init__(self, api_key, reference: client, entity: entities): self.reference = reference self.handler = httphandler(api_key) - def post(self, data): + def post(self, data: Union[UpsertUserScheduledRequest, UpsertOrganizationScheduledRequest, Body]): + data = _serialize(data) match(self.entity): case 'user': req = self.handler.post(self.reference.user.scheduled, data) @@ -37,7 +66,8 @@ def post(self, data): return req - def delete(self, data): + def delete(self, data: Union[DeleteUserScheduledRequest, DeleteOrganizationScheduledRequest, Body]): + data = _serialize(data) match(self.entity): case 'user': req = self.handler.delete(self.reference.user.scheduled, data) @@ -53,13 +83,13 @@ def __init__(self, api_key, reference: client): self.scheduled = scheduled(api_key, reference, entity='user') self.handler = httphandler(api_key) - def upsert(self, data): - req = self.handler.post(self.reference.user.base, data) + def upsert(self, data: Union[IdentifyRequest, Body]): + req = self.handler.post(self.reference.user.base, _serialize(data)) return req - def delete(self, data): - req = self.handler.delete(self.reference.user.base, data) + def delete(self, data: Union[DeleteUserRequest, Body]): + req = self.handler.delete(self.reference.user.base, _serialize(data)) return req @@ -70,12 +100,12 @@ def __init__(self, api_key, reference: client): self.scheduled = scheduled(api_key, reference, entity='organization') self.handler = httphandler(api_key) - def upsert(self, data): - req = self.handler.post(self.reference.organization.base, data) + def upsert(self, data: Union[OrganizationRequest, Body]): + req = self.handler.post(self.reference.organization.base, _serialize(data)) return req - def delete(self, data): - req = self.handler.delete(self.reference.organization.base, data) + def delete(self, data: Union[DeleteOrganizationRequest, Body]): + req = self.handler.delete(self.reference.organization.base, _serialize(data)) return req diff --git a/src/lunogram/app/types/event.py b/src/lunogram/app/types/event.py deleted file mode 100644 index 76cb0b9..0000000 --- a/src/lunogram/app/types/event.py +++ /dev/null @@ -1,8 +0,0 @@ -from typing import TypedDict, Required, NotRequired -from identifier import Identifier - -class Event(TypedDict): - identifier: list[Identifier] - name: Required[str] - data: NotRequired[object] - match: NotRequired[object] \ No newline at end of file diff --git a/src/lunogram/app/types/identifier.py b/src/lunogram/app/types/identifier.py deleted file mode 100644 index c5f5bd7..0000000 --- a/src/lunogram/app/types/identifier.py +++ /dev/null @@ -1,9 +0,0 @@ -from typing import TypedDict, NotRequired - -class Identifier(TypedDict): - id: str - source: str - external_id: str - metadata: NotRequired[None] - created_at: str - updated_at: str \ No newline at end of file diff --git a/src/lunogram/app/types/organization.py b/src/lunogram/app/types/organization.py deleted file mode 100644 index f73f79b..0000000 --- a/src/lunogram/app/types/organization.py +++ /dev/null @@ -1,10 +0,0 @@ -from typing import TypedDict, NotRequired, Required -from identifier import Identifier - -class UpsertOrganization(TypedDict): - identifier: list[Identifier] - data: NotRequired[object | None] - name: NotRequired[str | None] - -class DeleteOrganization(TypedDict): - identifier: list[Identifier] \ No newline at end of file diff --git a/src/lunogram/app/types/scheduled.py b/src/lunogram/app/types/scheduled.py deleted file mode 100644 index 05f6a47..0000000 --- a/src/lunogram/app/types/scheduled.py +++ /dev/null @@ -1,14 +0,0 @@ -from typing import TypedDict, Required, NotRequired -from identifier import Identifier - -class UpsertScheduled(TypedDict): - identifier: list[Identifier] - name: Required[str] - data: NotRequired[object] - interval: NotRequired[str | None] - scheduled_at: NotRequired[str | None] - start_at: NotRequired[str | None] - -class DeleteScheduled(TypedDict): - name: Required[str] - identifier: list[Identifier] \ No newline at end of file diff --git a/src/lunogram/app/types/user.py b/src/lunogram/app/types/user.py deleted file mode 100644 index 94c9565..0000000 --- a/src/lunogram/app/types/user.py +++ /dev/null @@ -1,17 +0,0 @@ -from typing import TypedDict, NotRequired, Required -from identifier import Identifier - -class FirstLastName(TypedDict): - first_name: str - last_name: str - -class UpsertUser(TypedDict): - identifier: list[Identifier] - data: list[FirstLastName] - email: str - locale: str - phone: str - timezone: str - -class DeleteUser(TypedDict): - Identifier: list[Identifier] \ No newline at end of file diff --git a/src/lunogram/gen/__init__.py b/src/lunogram/gen/__init__.py new file mode 100644 index 0000000..96ee4ff --- /dev/null +++ b/src/lunogram/gen/__init__.py @@ -0,0 +1,5 @@ +# The `gen` package holds code generated from the vendored OpenAPI spec +# (`spec/client.yaml`). Do not hand-edit; run `make generate` to regenerate. +from . import models # noqa: F401 + +__all__ = ["models"] diff --git a/src/lunogram/gen/models.py b/src/lunogram/gen/models.py new file mode 100644 index 0000000..c179648 --- /dev/null +++ b/src/lunogram/gen/models.py @@ -0,0 +1,501 @@ +# generated by datamodel-codegen; DO NOT EDIT. +# source: spec/client.yaml (run `make generate` to regenerate) + +from __future__ import annotations + +from datetime import datetime +from enum import Enum +from typing import Any +from uuid import UUID + +from pydantic import BaseModel, EmailStr, Field, RootModel + + +class CreateSession(BaseModel): + """ + Request to mint a session token for an end user. + """ + + user_id: str = Field(..., examples=['user_12345']) + """ + The end user's external identifier (the session subject). + """ + + +class SessionToken(BaseModel): + token: str + """ + The signed session token (a bearer token for the Client API). + """ + expires_at: datetime + + +class Channel(Enum): + email = 'email' + sms = 'sms' + push = 'push' + inbox = 'inbox' + + +class Problem(BaseModel): + title: str = Field(..., examples=['some title for the error situation']) + """ + A short summary of the problem type. Written in English and readable for engineers, usually not suited for non technical stakeholders and not localized. + + """ + detail: str = Field(..., examples=['some description for the error situation']) + """ + A human readable explanation specific to this occurrence of the problem that is helpful to locate the problem and give advice on how to proceed. Written in English and readable for engineers, usually not suited for non technical stakeholders and not localized. + + """ + + +class ExternalID(BaseModel): + """ + An external identifier with source and optional metadata + """ + + source: str | None = Field('default', examples=['default']) + """ + Source of the identifier (e.g. "default", "anonymous", or a custom source). Defaults to "default" if not provided. + """ + external_id: str = Field(..., examples=['user_12345'], min_length=1) + """ + The external identifier value + """ + metadata: dict[str, Any] | None = None + """ + Optional metadata associated with this identifier + """ + + +class ExternalIDResponse(BaseModel): + """ + An external identifier as returned in responses, including database ID and timestamps + """ + + id: UUID = Field(..., examples=['a1b2c3d4-e5f6-7890-abcd-ef1234567890']) + source: str = Field(..., examples=['default']) + external_id: str = Field(..., examples=['user_12345']) + metadata: dict[str, Any] | None = None + created_at: datetime = Field(..., examples=['2025-11-19T14:18:42.960Z']) + updated_at: datetime = Field(..., examples=['2025-11-23T17:20:00.021Z']) + + +class User(BaseModel): + id: UUID = Field(..., examples=['9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d']) + project_id: UUID = Field(..., examples=['4c9d3163-7b64-4f9e-9068-d2e4b96be56b']) + identifier: list[ExternalIDResponse] + """ + External identifiers associated with this user + """ + email: EmailStr | None = Field(None, examples=['user@example.com']) + phone: str | None = Field(None, examples=['+1234567890']) + """ + E.164 formatted phone number + """ + data: dict[str, Any] = Field( + ..., examples=[{'first_name': 'John', 'last_name': 'Doe'}] + ) + timezone: str | None = Field(None, examples=['America/New_York']) + locale: str | None = Field(None, examples=['en']) + has_push_device: bool = Field(..., examples=[False]) + version: int = Field(..., examples=[1]) + created_at: datetime = Field(..., examples=['2025-11-19T14:18:42.960Z']) + updated_at: datetime = Field(..., examples=['2025-11-23T17:20:00.021Z']) + + +class UserIdentifier(RootModel[list[ExternalID]]): + """ + One or more external identifiers to identify the user + """ + + root: list[ExternalID] = Field(..., min_length=1) + """ + One or more external identifiers to identify the user + """ + + +class OrganizationIdentifier(RootModel[list[ExternalID]]): + """ + One or more external identifiers to identify the organization + """ + + root: list[ExternalID] = Field(..., min_length=1) + """ + One or more external identifiers to identify the organization + """ + + +class Os(Enum): + web = 'web' + ios = 'ios' + android = 'android' + + +class Keys(BaseModel): + p256dh: str + auth: str + + +class Config(BaseModel): + token: str | None = None + """ + Device token for FCM or APNs + """ + endpoint: str | None = None + """ + Web Push subscription endpoint URL + """ + expiration_time: datetime | None = None + keys: Keys | None = None + + +class DeviceRegistration(BaseModel): + identifier: UserIdentifier + device_id: str + os: Os | None = None + os_version: str | None = None + model: str | None = None + app_version: str | None = None + data: dict[str, Any] | None = Field( + None, examples=[{'app_channel': 'beta', 'locale': 'en-US'}] + ) + config: Config + + +class VapidPublicKey(BaseModel): + public_key: str + """ + The VAPID public key + """ + + +class IdentifyRequest(BaseModel): + identifier: UserIdentifier + email: EmailStr | None = Field(None, examples=['user@example.com']) + phone: str | None = Field(None, examples=['+31612345678']) + """ + E.164 formatted phone number + """ + timezone: str | None = Field(None, examples=['Europe/Amsterdam']) + locale: str | None = Field(None, examples=['nl-NL']) + data: dict[str, Any] | None = Field( + None, + examples=[ + { + 'first_name': 'John', + 'last_name': 'Smith', + 'has_completed_onboarding': True, + } + ], + ) + """ + User-specific attributes + """ + + +class Event(BaseModel): + name: str = Field(..., examples=['purchase_completed']) + """ + The name of the event + """ + identifier: UserIdentifier | None = None + match: dict[str, Any] | None = Field(None, examples=[{'plan': 'enterprise'}]) + """ + JSONB containment filter to match users by their data attributes. Mutually exclusive with identifier. When set, the event is delivered to every user whose data column contains the given key/value pairs. + """ + data: dict[str, Any] = Field( + ..., examples=[{'product_id': 'prod_789', 'amount': 99.99}] + ) + """ + Event-specific data + """ + + +class InboxMessageCreate(BaseModel): + target: UserIdentifier + identifier: ExternalID + """ + External identifier for the message. Required for idempotency and traceability back to the origin source. + """ + channel: Channel + sender_identity_id: UUID | None = None + """ + Required for email and sms messages. Push uses project push provider settings. + """ + campaign_id: UUID | None = None + broadcast_id: UUID | None = None + content: dict[str, Any] | None = None + """ + Channel-specific payload content. + """ + data: dict[str, Any] | None = None + tags: list[str] | None = None + priority: int | None = Field(3, ge=1, le=5) + source: str | None = None + scheduled_at: datetime | None = None + expires_at: datetime | None = None + + +class OrganizationInboxMessageCreate(BaseModel): + target: OrganizationIdentifier + identifier: ExternalID + """ + External identifier for the message. Required for idempotency and traceability back to the origin source. + """ + channel: Channel + sender_identity_id: UUID | None = None + """ + Required for email and sms messages. Push uses project push provider settings. + """ + campaign_id: UUID | None = None + broadcast_id: UUID | None = None + content: dict[str, Any] | None = None + """ + Channel-specific payload content. + """ + data: dict[str, Any] | None = None + tags: list[str] | None = None + priority: int | None = Field(3, ge=1, le=5) + source: str | None = None + scheduled_at: datetime | None = None + expires_at: datetime | None = None + + +class PostUserInboxMessagesRequest(RootModel[list[InboxMessageCreate]]): + root: list[InboxMessageCreate] = Field(..., max_length=100, min_length=1) + + +class PostOrganizationInboxMessagesRequest( + RootModel[list[OrganizationInboxMessageCreate]] +): + root: list[OrganizationInboxMessageCreate] = Field( + ..., max_length=100, min_length=1 + ) + + +class UserInboxMessageRef(BaseModel): + target: UserIdentifier + message_id: UUID + + +class OrganizationInboxMessageRef(BaseModel): + target: OrganizationIdentifier + message_id: UUID + + +class UserInboxMessageEvents(RootModel[list[UserInboxMessageRef]]): + root: list[UserInboxMessageRef] = Field(..., max_length=100, min_length=1) + + +class OrganizationInboxMessageEvents(RootModel[list[OrganizationInboxMessageRef]]): + root: list[OrganizationInboxMessageRef] = Field(..., max_length=100, min_length=1) + + +class InboxMessage(BaseModel): + id: UUID + project_id: UUID + user_id: UUID | None = None + organization_id: UUID | None = None + external_id: str | None = None + """ + External identifier for the message, if one was provided at creation time. + """ + channel: Channel + sender_identity_id: UUID | None = None + campaign_id: UUID | None = None + broadcast_id: UUID | None = None + content: dict[str, Any] + data: dict[str, Any] + tags: list[str] + priority: int = Field(..., ge=1, le=5) + source: str | None = None + scheduled_at: datetime + expires_at: datetime | None = None + read_at: datetime | None = None + archived_at: datetime | None = None + sent_at: datetime | None = None + created_at: datetime + updated_at: datetime + + +class InboxMessageList(BaseModel): + results: list[InboxMessage] + total: int + limit: int + offset: int + + +class InboxCount(BaseModel): + unread: int + total: int + + +class Organization(BaseModel): + id: UUID = Field(..., examples=['9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d']) + project_id: UUID = Field(..., examples=['4c9d3163-7b64-4f9e-9068-d2e4b96be56b']) + identifier: list[ExternalIDResponse] + """ + External identifiers associated with this organization + """ + name: str | None = Field(None, examples=['Acme Corp']) + data: dict[str, Any] = Field( + ..., examples=[{'industry': 'technology', 'size': 'enterprise'}] + ) + version: int = Field(..., examples=[1]) + created_at: datetime = Field(..., examples=['2025-11-19T14:18:42.960Z']) + updated_at: datetime = Field(..., examples=['2025-11-23T17:20:00.021Z']) + + +class OrganizationRequest(BaseModel): + identifier: OrganizationIdentifier + name: str | None = Field(None, examples=['Acme Corp']) + data: dict[str, Any] | None = Field( + None, examples=[{'industry': 'technology', 'size': 'enterprise'}] + ) + + +class Organization1(BaseModel): + identifier: OrganizationIdentifier + + +class User1(BaseModel): + identifier: UserIdentifier + + +class OrganizationUserRequest(BaseModel): + organization: Organization1 + user: User1 + data: dict[str, Any] | None = Field( + None, examples=[{'role': 'admin', 'department': 'engineering'}] + ) + """ + Organization-specific data for this user + """ + + +class DeleteUserRequest(BaseModel): + identifier: UserIdentifier + + +class DeleteOrganizationRequest(BaseModel): + identifier: OrganizationIdentifier + + +class RemoveOrganizationUserRequest(BaseModel): + organization: Organization1 + user: User1 + + +class OrganizationEvent(BaseModel): + identifier: OrganizationIdentifier | None = None + match: dict[str, Any] | None = Field(None, examples=[{'plan': 'enterprise'}]) + """ + JSONB containment filter to match organizations by their data attributes. Mutually exclusive with identifier. When set, the event is delivered to every organization whose data column contains the given key/value pairs. + """ + name: str = Field(..., examples=['subscription_upgraded']) + """ + The name of the event + """ + data: dict[str, Any] | None = Field( + None, examples=[{'plan': 'enterprise', 'seats': 100}] + ) + """ + Event-specific data + """ + + +class UpsertUserScheduledRequest(BaseModel): + name: str = Field(..., examples=['renewal_date']) + """ + The name of the scheduled resource + """ + identifier: UserIdentifier | None = None + scheduled_at: datetime | None = Field(None, examples=['2025-12-25T10:00:00Z']) + """ + The time at which the scheduled resource is set to trigger. Required for single schedules. + """ + start_at: datetime | None = Field(None, examples=['2025-01-01T00:00:00Z']) + """ + Start time for recurring schedules. If omitted for recurring schedules, defaults to now. + """ + interval: str | None = Field(None, examples=['24h']) + """ + Interval for recurring schedules. When set, the schedule type is automatically set to recurring. + """ + data: dict[str, Any] | None = Field( + None, examples=[{'plan': 'pro', 'amount': 29.99}] + ) + """ + Scheduled resource data + """ + + +class UpsertOrganizationScheduledRequest(BaseModel): + name: str = Field(..., examples=['contract_renewal']) + """ + The name of the scheduled resource + """ + identifier: OrganizationIdentifier + scheduled_at: datetime | None = Field(None, examples=['2025-12-25T10:00:00Z']) + """ + The time at which the scheduled resource is set to trigger. Required for single schedules. + """ + start_at: datetime | None = Field(None, examples=['2025-01-01T00:00:00Z']) + """ + Start time for recurring schedules. If omitted for recurring schedules, defaults to now. + """ + interval: str | None = Field(None, examples=['24h']) + """ + Interval for recurring schedules. When set, the schedule type is automatically set to recurring. + """ + data: dict[str, Any] | None = Field( + None, examples=[{'contract_type': 'enterprise', 'seats': 100}] + ) + """ + Scheduled resource data + """ + + +class DeleteUserScheduledRequest(BaseModel): + name: str = Field(..., examples=['renewal_date']) + """ + The name of the scheduled resource to delete + """ + identifier: UserIdentifier | None = None + + +class ScheduledAccepted(BaseModel): + id: UUID = Field(..., examples=['a1b2c3d4-e5f6-7890-abcd-ef1234567890']) + """ + The unique identifier for the scheduled instance + """ + name: str = Field(..., examples=['trial_end']) + """ + The name of the scheduled resource + """ + scheduled_at: datetime = Field(..., examples=['2025-01-15T09:00:00Z']) + """ + The time at which the scheduled resource is set to trigger + """ + data: dict[str, Any] | None = None + """ + Scheduled resource data + """ + + +class DeleteOrganizationScheduledRequest(BaseModel): + name: str = Field(..., examples=['contract_renewal']) + """ + The name of the scheduled resource to delete + """ + identifier: OrganizationIdentifier + + +class PostEventsRequest(RootModel[list[Event]]): + root: list[Event] = Field(..., min_length=1) + + +class PostOrganizationEventsRequest(RootModel[list[OrganizationEvent]]): + root: list[OrganizationEvent] = Field(..., min_length=1) diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..b8b95d6 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,37 @@ +"""Smoke tests for the generated model layer and its use in the facade.""" + +from lunogram.app.objects import _serialize +from lunogram.gen.models import IdentifyRequest, Event + + +def test_generated_model_serializes_snake_case(): + # Python is snake_case end-to-end, matching the spec, so a generated model + # dumps straight to the request body with no key-mapping layer. + req = IdentifyRequest.model_validate( + { + "identifier": [{"source": "default", "external_id": "user_123"}], + "email": "a@b.com", + } + ) + body = _serialize(req) + assert body["identifier"][0]["external_id"] == "user_123" + assert body["email"] == "a@b.com" + # exclude_none drops unset optional fields + assert "phone" not in body + + +def test_serialize_passes_dicts_through(): + data = {"name": "signed_in", "identifier": []} + assert _serialize(data) is data + + +def test_event_model_fields(): + ev = Event.model_validate( + { + "name": "signed_in", + "identifier": [{"source": "default", "external_id": "user_123"}], + "data": {"plan": "pro"}, + } + ) + assert ev.name == "signed_in" + assert ev.data == {"plan": "pro"} diff --git a/tests/test_reference.py b/tests/test_reference.py new file mode 100644 index 0000000..884747e --- /dev/null +++ b/tests/test_reference.py @@ -0,0 +1,76 @@ +"""Smoke tests for the hand-written facade. + +These cover the cross-cutting concerns the facade owns on top of the generated +model layer: project_id UUID validation and the project-scoped path factory +(exactly one `/projects//` prefix per resource). +""" + +import pytest + +from lunogram import Lunogram +from lunogram.utils.reference import client as Reference + +PROJECT_ID = "11111111-2222-3333-4444-555555555555" +API_KEY = "test-key" + + +# --- UUID validation ------------------------------------------------------- + +def test_valid_uuid_accepted(): + ref = Reference(PROJECT_ID) + assert ref.project_id == PROJECT_ID + + +@pytest.mark.parametrize("bad", ["", "not-a-uuid", "1234", None, 123]) +def test_invalid_project_id_rejected(bad): + with pytest.raises(ValueError): + Reference(bad) + + +def test_client_validates_project_id(): + with pytest.raises(ValueError): + Lunogram(api_key=API_KEY, project_id="nope") + + +# --- project-scoped path factory ------------------------------------------ + +ALL_PATHS = [ + ("user.base", lambda r: r.user.base), + ("user.events", lambda r: r.user.events), + ("user.scheduled", lambda r: r.user.scheduled), + ("user.devices", lambda r: r.user.devices), + ("user.inbox", lambda r: r.user.inbox), + ("user.inbox_count", lambda r: r.user.inbox_count), + ("user.inbox_read", lambda r: r.user.inbox_read), + ("user.inbox_archived", lambda r: r.user.inbox_archived), + ("organization.base", lambda r: r.organization.base), + ("organization.events", lambda r: r.organization.events), + ("organization.users", lambda r: r.organization.users), + ("organization.scheduled", lambda r: r.organization.scheduled), + ("organization.inbox", lambda r: r.organization.inbox), + ("organization.inbox_count", lambda r: r.organization.inbox_count), + ("organization.inbox_read", lambda r: r.organization.inbox_read), + ("organization.inbox_archived", lambda r: r.organization.inbox_archived), + ("push.vapid", lambda r: r.push.vapid), + ("auth_methods.sessions", lambda r: r.auth_methods.sessions("aaaa")), +] + + +@pytest.mark.parametrize("name,getter", ALL_PATHS, ids=[n for n, _ in ALL_PATHS]) +def test_exactly_one_project_prefix(name, getter): + ref = Reference(PROJECT_ID) + url = getter(ref) + expected = f"/projects/{PROJECT_ID}/" + # The load-bearing invariant: exactly one project-scoped prefix per resource. + assert url.count(expected) == 1, f"{name}: expected exactly one prefix in {url!r}" + # NOTE: a pre-existing bug (from PR #1) produces a double slash after the + # host (`...com//api/client/...`) because the base URL has a trailing slash + # and the API path a leading one. That is unrelated to this codegen change + # and left alone; we assert the path segment rather than the exact host join. + assert f"/api/client/projects/{PROJECT_ID}/" in url + + +def test_paths_are_under_client_api(): + ref = Reference(PROJECT_ID) + assert ref.user.base.endswith(f"/projects/{PROJECT_ID}/users") + assert ref.organization.events.endswith(f"/projects/{PROJECT_ID}/organizations/events") From 64d8c8423ccd53891622f5b4fd83da2541edac97 Mon Sep 17 00:00:00 2001 From: Jeroen Rinzema Date: Thu, 25 Jun 2026 12:25:54 +0200 Subject: [PATCH 3/9] feat: add inbox, devices, push, sessions and org membership to the facade Wire the remaining Client API endpoints into the hand-written facade on top of the generated models: - user & organization inbox (create, query, count, mark_read, mark_archived) - user device registration for push subscriptions - project VAPID public key (client.push) - end-user session token minting (client.sessions) - organization membership (organization.add_user / remove_user) Also fix two transport bugs surfaced by the wider surface: - send the Authorization header as a Bearer token (was the bare key) - treat an empty 2xx body (202/204) as success instead of a JSON error Pin the vendored spec to the v0.1.0-rc.1 release asset and drop the spec-sync workflow. --- .github/workflows/spec-sync.yml | 56 -------------- README.md | 59 ++++++++++++-- spec/SOURCE.md | 32 ++++---- src/lunogram/app/__init__.py | 4 +- src/lunogram/app/http.py | 27 ++++--- src/lunogram/app/objects.py | 110 ++++++++++++++++++++++++++- src/lunogram/client.py | 4 +- tests/test_facade.py | 131 ++++++++++++++++++++++++++++++++ 8 files changed, 328 insertions(+), 95 deletions(-) delete mode 100644 .github/workflows/spec-sync.yml create mode 100644 tests/test_facade.py diff --git a/.github/workflows/spec-sync.yml b/.github/workflows/spec-sync.yml deleted file mode 100644 index fd9dccb..0000000 --- a/.github/workflows/spec-sync.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: Spec sync - -on: - schedule: - # Weekly, Monday 06:00 UTC. - - cron: "0 6 * * 1" - workflow_dispatch: - -jobs: - sync: - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - steps: - - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install -e ".[dev]" - - - name: Re-fetch the spec from the pinned ref in SOURCE.md - run: | - REF="$(grep -oE '[0-9a-f]{40}' spec/SOURCE.md | head -n1)" - if [ -z "$REF" ]; then - REF="$(grep -oE 'Pinned ref \| `[^`]+`' spec/SOURCE.md | sed -E 's/.*`([^`]+)`.*/\1/')" - fi - echo "Fetching spec at ref: $REF" - curl -fsSL \ - "https://raw.githubusercontent.com/lunogram/platform/$REF/internal/http/controllers/v1/client/oapi/resources.yml" \ - -o spec/client.yaml - - - name: Regenerate models - run: ./scripts/generate.sh - - - name: Open a PR if the spec or generated code changed - uses: peter-evans/create-pull-request@v6 - with: - branch: spec-sync/update - title: "chore: sync OpenAPI spec and regenerate models" - commit-message: "chore: sync OpenAPI spec and regenerate models" - body: | - Automated spec sync. - - Re-fetched `spec/client.yaml` from the pinned ref in `spec/SOURCE.md` - and regenerated `src/lunogram/gen/models.py`. Review the diff before - merging; bump the pinned ref in `spec/SOURCE.md` if appropriate. - add-paths: | - spec/client.yaml - src/lunogram/gen/models.py diff --git a/README.md b/README.md index 90b264b..a6555a8 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,51 @@ segment: Authentication is unchanged — the same API key / token is used. Only the URL gained the project segment. +## More resources + +The same `Lunogram` client exposes the full Client API surface. Every method +accepts either a plain dict or the matching generated model from +`lunogram.gen.models`. + +```python +# Inbox — users and organizations share the same surface +# (client.organization.inbox.* mirrors client.user.inbox.*) +client.user.inbox.create([ + { + "target": [{"external_id": "user_123"}], + "identifier": {"external_id": "msg-1"}, + "channel": "inbox", + "content": {"title": "Welcome", "body": "Thanks for joining!"}, + }, +]) +messages = client.user.inbox.query(source="default", external_id="user_123", channel="inbox", status="unread") +counts = client.user.inbox.count(source="default", external_id="user_123", channel="inbox") +client.user.inbox.mark_read([{"target": [{"external_id": "user_123"}], "message_id": "msg-1"}]) +client.user.inbox.mark_archived([{"target": [{"external_id": "user_123"}], "message_id": "msg-1"}]) + +# Organization membership +client.organization.add_user({ + "organization": {"identifier": [{"external_id": "org_123"}]}, + "user": {"identifier": [{"external_id": "user_123"}]}, +}) +client.organization.remove_user({ + "organization": {"identifier": [{"external_id": "org_123"}]}, + "user": {"identifier": [{"external_id": "user_123"}]}, +}) + +# Devices & push +vapid = client.push.get_vapid_public_key() +client.user.devices.register({ + "identifier": [{"external_id": "user_123"}], + "device_id": "device-1", + "os": "web", + "config": {"endpoint": "https://push.example.com/...", "keys": {"p256dh": "...", "auth": "..."}}, +}) + +# Sessions — mint a short-lived token for an end user (server-side) +session = client.sessions.create("auth-method-uuid", {"user_id": "user_123"}) +``` + ## Architecture: spec-driven, layered The SDK is split into a **generated low-level layer** and a **hand-written @@ -89,12 +134,13 @@ models flow straight through to the request body with **no camelCase↔snake_cas mapping layer**. A generated model and a plain dict are both accepted by every method; models are dumped with `model_dump(mode="json", exclude_none=True)`. -### The spec is vendored (no platform release needed) +### The spec is vendored from a platform release -`spec/client.yaml` is copied verbatim from the platform repo at a **pinned -commit**. `spec/SOURCE.md` records the source repo, spec path and ref. Pinning to -a commit means any ref is fetchable from the public repo via the raw URL, so no -platform release is required (flip to a `v*.*.*` tag once the platform cuts one). +`spec/client.yaml` is copied verbatim from a Lunogram platform **release**. +`spec/SOURCE.md` records the source repo, pinned tag and asset URL. Every tagged +release publishes the client OpenAPI spec as a `client.yaml` asset, so the spec +is fetched from a stable, versioned, immutable source. Bumping the pin is a +manual edit to `spec/SOURCE.md` followed by `make generate`. ### Regenerating the models @@ -117,6 +163,3 @@ regenerate. `git diff --exit-code src/lunogram/gen` as a **drift check** (fails if the committed generated code is stale), builds the wheel, import-smoke-tests, and runs `pytest`. -- **`spec-sync.yml`** (weekly cron + manual dispatch) — re-fetches the spec from - the ref in `spec/SOURCE.md`, regenerates, and opens a PR via - `peter-evans/create-pull-request` when anything changed. diff --git a/spec/SOURCE.md b/spec/SOURCE.md index f7ce808..ab87a71 100644 --- a/spec/SOURCE.md +++ b/spec/SOURCE.md @@ -1,36 +1,34 @@ # Spec source -`client.yaml` in this directory is **vendored** (copied verbatim) from the -platform repository's OpenAPI spec. It is the single input to the code generator -(`make generate` → `src/lunogram/gen/models.py`). +`client.yaml` in this directory is **vendored** (copied verbatim) from a Lunogram +platform **release**. It is the single input to the code generator +(`make generate` → `src/lunogram/gen/models.py`). Do not hand-edit either file. | | | | --- | --- | | Source repo | https://github.com/lunogram/platform | -| Spec path | `internal/http/controllers/v1/client/oapi/resources.yml` | -| Pinned ref | `a12f901dc98e7ced44efbad27a388e8bf5ee0f3a` | +| Pinned tag | `v0.1.0-rc.1` | +| Spec asset | `client.yaml` | -The pinned ref is the head of platform PR #262 (the project-in-URL change). It is -a **branch/commit pin during development** — pinning to a commit means the spec -is fetchable from the public repo via the `raw.githubusercontent.com` URL with -no platform release required. Flip this to a `v*.*.*` release tag once the -platform cuts one. +Every tagged platform release publishes the client OpenAPI spec as a `client.yaml` +asset (see the platform's `.github/workflows/release.yml` → `openapi-specs` job), +so the spec is fetched from a stable, versioned, immutable source — no platform +checkout or branch pin required. -## Raw URL +## Release asset URL ``` -https://raw.githubusercontent.com/lunogram/platform/a12f901dc98e7ced44efbad27a388e8bf5ee0f3a/internal/http/controllers/v1/client/oapi/resources.yml +https://github.com/lunogram/platform/releases/download/v0.1.0-rc.1/client.yaml ``` ## Refreshing the spec -To pull a newer spec, update the pinned ref above and re-fetch: +To track a newer release, update the **Pinned tag** above and re-fetch: ```bash -REF=a12f901dc98e7ced44efbad27a388e8bf5ee0f3a -curl -fsSL "https://raw.githubusercontent.com/lunogram/platform/$REF/internal/http/controllers/v1/client/oapi/resources.yml" -o spec/client.yaml +TAG=v0.1.0-rc.1 +curl -fsSL "https://github.com/lunogram/platform/releases/download/$TAG/client.yaml" -o spec/client.yaml make generate ``` -The `spec-sync` GitHub Actions workflow automates exactly this on a weekly -schedule and opens a PR when the spec or generated code changes. +Commit `spec/client.yaml` and `src/lunogram/gen/models.py` together. diff --git a/src/lunogram/app/__init__.py b/src/lunogram/app/__init__.py index 0c996fb..51968a2 100644 --- a/src/lunogram/app/__init__.py +++ b/src/lunogram/app/__init__.py @@ -1,3 +1,3 @@ -from .objects import user, organization +from .objects import user, organization, inbox, devices, push, sessions -__all__ = ["user", "organization"] +__all__ = ["user", "organization", "inbox", "devices", "push", "sessions"] diff --git a/src/lunogram/app/http.py b/src/lunogram/app/http.py index ef932fb..cb51cbf 100644 --- a/src/lunogram/app/http.py +++ b/src/lunogram/app/http.py @@ -9,34 +9,43 @@ class httphandler: def __init__(self, api_key): self.api_key = api_key - def request(self, method: http_methods, url, data = None): + def request(self, method: http_methods, url, data=None, params=None): head = { 'Content-Type': 'application/json', - 'Authorization': f"{self.api_key}", + # The Client API authenticates with a JWT bearer token. + 'Authorization': f"Bearer {self.api_key}", } - req = requests.request(method, url, data=json.dumps(data), headers=head) + # Only send a JSON body when there is one; GET requests carry their + # arguments as query params instead. + body = None if data is None else json.dumps(data) + req = requests.request(method, url, data=body, params=params, headers=head) if not (200 <= req.status_code < 300): return (req, f"HTTP {req.status_code}") + # Many Client API endpoints accept work for async processing and reply + # 202/204 with no body; treat an empty success body as success. + if not req.content: + return req + try: return (req.json()) except json.JSONDecodeError: return (req, "Response is not a JSON object") except Exception as e: return (req, e) - - def get(self, url: str): - req = self.request("GET", url) - + + def get(self, url: str, params=None): + req = self.request("GET", url, params=params) + return req - + def post(self, url: str, data): req = self.request("POST", url, data) return req - + def delete(self, url: str, data): req = self.request("DELETE", url, data) diff --git a/src/lunogram/app/objects.py b/src/lunogram/app/objects.py index 0377c79..738d624 100644 --- a/src/lunogram/app/objects.py +++ b/src/lunogram/app/objects.py @@ -5,16 +5,25 @@ from .http import httphandler from ..utils.reference import client from ..gen.models import ( + Channel, IdentifyRequest, DeleteUserRequest, OrganizationRequest, DeleteOrganizationRequest, + OrganizationUserRequest, + RemoveOrganizationUserRequest, Event, OrganizationEvent, UpsertUserScheduledRequest, UpsertOrganizationScheduledRequest, DeleteUserScheduledRequest, DeleteOrganizationScheduledRequest, + InboxMessageCreate, + OrganizationInboxMessageCreate, + UserInboxMessageRef, + OrganizationInboxMessageRef, + DeviceRegistration, + CreateSession, ) entities = Literal["user", "organization"] @@ -25,13 +34,26 @@ def _serialize(data): # The generated models mirror the spec's snake_case payloads exactly, so a - # model can be dumped straight to the JSON body with no key mapping. Plain - # dicts are passed through unchanged. + # model can be dumped straight to the JSON body with no key mapping. Lists + # are serialized element-wise (array request bodies), and plain dicts are + # passed through unchanged. if isinstance(data, BaseModel): return data.model_dump(mode="json", exclude_none=True) + if isinstance(data, list): + return [_serialize(item) for item in data] return data +def _prune(params: dict) -> dict: + # Drop unset query parameters so they never reach the query string. + return {key: value for key, value in params.items() if value is not None} + + +def _channel(channel) -> str: + # Accept either the generated Channel enum or a plain string. + return channel.value if isinstance(channel, Channel) else channel + + # .events and .scheduled objects are defined seperately and later integrated with the corresponding entities class events: @@ -76,11 +98,84 @@ def delete(self, data: Union[DeleteUserScheduledRequest, DeleteOrganizationSched return req + +class inbox: + def __init__(self, api_key, reference: client, entity: entities): + self.entity = entity + self.reference = reference + self.handler = httphandler(api_key) + + def _ref(self): + return self.reference.user if self.entity == 'user' else self.reference.organization + + def create(self, messages: Union[list, Body]): + return self.handler.post(self._ref().inbox, _serialize(messages)) + + def query(self, source, external_id, channel, status=None, tags=None, + message_source=None, priority=None, limit=None, offset=None): + params = _prune({ + 'source': source, + 'external_id': external_id, + 'channel': _channel(channel), + 'status': status, + 'tags': tags, + 'message_source': message_source, + 'priority': priority, + 'limit': limit, + 'offset': offset, + }) + return self.handler.get(self._ref().inbox, params=params) + + def count(self, source, external_id, channel): + params = _prune({ + 'source': source, + 'external_id': external_id, + 'channel': _channel(channel), + }) + return self.handler.get(self._ref().inbox_count, params=params) + + def mark_read(self, messages: Union[list, Body]): + return self.handler.post(self._ref().inbox_read, _serialize(messages)) + + def mark_archived(self, messages: Union[list, Body]): + return self.handler.post(self._ref().inbox_archived, _serialize(messages)) + + +class devices: + def __init__(self, api_key, reference: client): + self.reference = reference + self.handler = httphandler(api_key) + + def register(self, data: Union[DeviceRegistration, Body]): + return self.handler.post(self.reference.user.devices, _serialize(data)) + + +class push: + def __init__(self, api_key, reference: client): + self.reference = reference + self.handler = httphandler(api_key) + + def get_vapid_public_key(self): + return self.handler.get(self.reference.push.vapid) + + +class sessions: + def __init__(self, api_key, reference: client): + self.reference = reference + self.handler = httphandler(api_key) + + def create(self, auth_method_id: str, data: Union[CreateSession, Body]): + url = self.reference.auth_methods.sessions(auth_method_id) + return self.handler.post(url, _serialize(data)) + + class user: def __init__(self, api_key, reference: client): self.reference = reference self.events = events(api_key, reference, entity='user') self.scheduled = scheduled(api_key, reference, entity='user') + self.inbox = inbox(api_key, reference, entity='user') + self.devices = devices(api_key, reference) self.handler = httphandler(api_key) def upsert(self, data: Union[IdentifyRequest, Body]): @@ -98,6 +193,7 @@ def __init__(self, api_key, reference: client): self.reference = reference self.events = events(api_key, reference, entity='organization') self.scheduled = scheduled(api_key, reference, entity='organization') + self.inbox = inbox(api_key, reference, entity='organization') self.handler = httphandler(api_key) def upsert(self, data: Union[OrganizationRequest, Body]): @@ -109,3 +205,13 @@ def delete(self, data: Union[DeleteOrganizationRequest, Body]): req = self.handler.delete(self.reference.organization.base, _serialize(data)) return req + + def add_user(self, data: Union[OrganizationUserRequest, Body]): + req = self.handler.post(self.reference.organization.users, _serialize(data)) + + return req + + def remove_user(self, data: Union[RemoveOrganizationUserRequest, Body]): + req = self.handler.delete(self.reference.organization.users, _serialize(data)) + + return req diff --git a/src/lunogram/client.py b/src/lunogram/client.py index f52ce29..0d45966 100644 --- a/src/lunogram/client.py +++ b/src/lunogram/client.py @@ -2,7 +2,7 @@ from .utils import seed from .utils.reference import client as Reference -from .app import user, organization +from .app import user, organization, push, sessions class Lunogram: def __init__(self, api_key, project_id): @@ -12,6 +12,8 @@ def __init__(self, api_key, project_id): self.reference = Reference(project_id) self.user = user(api_key, self.reference) self.organization = organization(api_key, self.reference) + self.push = push(api_key, self.reference) + self.sessions = sessions(api_key, self.reference) # Seeder data to generate a random user should you need it, this is mainly for testing purposes def random_user(): diff --git a/tests/test_facade.py b/tests/test_facade.py new file mode 100644 index 0000000..0369eaa --- /dev/null +++ b/tests/test_facade.py @@ -0,0 +1,131 @@ +"""Facade tests: verify each resource hits the right method/URL/body. + +These mock the underlying ``requests.request`` so no network is involved, and +assert the cross-cutting concerns the facade owns: the Bearer auth header, the +project-scoped paths, snake_case query params, and model/dict body serialization. +""" + +import json +from unittest.mock import patch, MagicMock + +import pytest + +from lunogram import Lunogram +from lunogram.gen.models import CreateSession + +PROJECT_ID = "11111111-2222-3333-4444-555555555555" +API_KEY = "test-key" +PREFIX = f"/projects/{PROJECT_ID}/" + + +@pytest.fixture +def captured(): + calls = [] + + def fake_request(method, url, data=None, params=None, headers=None): + calls.append({ + "method": method, + "url": url, + "data": data, + "params": params, + "headers": headers, + }) + resp = MagicMock() + resp.status_code = 200 + resp.content = b"{}" + resp.json.return_value = {} + return resp + + with patch("lunogram.app.http.requests.request", side_effect=fake_request): + yield calls + + +def last(captured): + return captured[-1] + + +def test_sends_bearer_auth_header(captured): + client = Lunogram(API_KEY, PROJECT_ID) + client.user.upsert({"identifier": [{"external_id": "u"}]}) + assert last(captured)["headers"]["Authorization"] == f"Bearer {API_KEY}" + + +def test_user_inbox_endpoints(captured): + client = Lunogram(API_KEY, PROJECT_ID) + + client.user.inbox.create([ + {"target": [{"external_id": "u"}], "identifier": {"external_id": "m1"}, "channel": "inbox"}, + ]) + assert last(captured)["method"] == "POST" + assert last(captured)["url"].endswith(f"{PREFIX}users/inbox") + + client.user.inbox.query(source="default", external_id="u", channel="inbox", message_source="campaign") + assert last(captured)["method"] == "GET" + assert last(captured)["url"].endswith(f"{PREFIX}users/inbox") + assert last(captured)["params"]["external_id"] == "u" + assert last(captured)["params"]["message_source"] == "campaign" + # unset optionals are pruned from the query string + assert "status" not in last(captured)["params"] + + client.user.inbox.count(source="default", external_id="u", channel="inbox") + assert last(captured)["url"].endswith(f"{PREFIX}users/inbox/count") + + client.user.inbox.mark_read([{"target": [{"external_id": "u"}], "message_id": "m1"}]) + assert last(captured)["method"] == "POST" + assert last(captured)["url"].endswith(f"{PREFIX}users/inbox/read") + + client.user.inbox.mark_archived([{"target": [{"external_id": "u"}], "message_id": "m1"}]) + assert last(captured)["url"].endswith(f"{PREFIX}users/inbox/archived") + + +def test_user_device_registration(captured): + client = Lunogram(API_KEY, PROJECT_ID) + client.user.devices.register({ + "identifier": [{"external_id": "u"}], + "device_id": "device-1", + "config": {"token": "tok"}, + }) + assert last(captured)["method"] == "POST" + assert last(captured)["url"].endswith(f"{PREFIX}users/devices") + + +def test_organization_membership_and_inbox(captured): + client = Lunogram(API_KEY, PROJECT_ID) + + client.organization.add_user({ + "organization": {"identifier": [{"external_id": "o"}]}, + "user": {"identifier": [{"external_id": "u"}]}, + }) + assert last(captured)["method"] == "POST" + assert last(captured)["url"].endswith(f"{PREFIX}organizations/users") + + client.organization.remove_user({ + "organization": {"identifier": [{"external_id": "o"}]}, + "user": {"identifier": [{"external_id": "u"}]}, + }) + assert last(captured)["method"] == "DELETE" + assert last(captured)["url"].endswith(f"{PREFIX}organizations/users") + + client.organization.inbox.query(source="default", external_id="o", channel="inbox") + assert last(captured)["url"].endswith(f"{PREFIX}organizations/inbox") + + +def test_push_vapid_public_key(captured): + client = Lunogram(API_KEY, PROJECT_ID) + client.push.get_vapid_public_key() + assert last(captured)["method"] == "GET" + assert last(captured)["url"].endswith(f"{PREFIX}push/vapid") + + +def test_session_minting(captured): + client = Lunogram(API_KEY, PROJECT_ID) + client.sessions.create("auth-method-1", {"user_id": "user-1"}) + assert last(captured)["method"] == "POST" + assert last(captured)["url"].endswith(f"{PREFIX}auth-methods/auth-method-1/sessions") + assert json.loads(last(captured)["data"]) == {"user_id": "user-1"} + + +def test_session_minting_accepts_a_generated_model(captured): + client = Lunogram(API_KEY, PROJECT_ID) + client.sessions.create("auth-method-1", CreateSession(user_id="user-1")) + assert json.loads(last(captured)["data"]) == {"user_id": "user-1"} From ce90476649bd28355fa6b00e42853b884410eee6 Mon Sep 17 00:00:00 2001 From: Jeroen Rinzema Date: Thu, 25 Jun 2026 16:57:18 +0200 Subject: [PATCH 4/9] test(e2e): add live integration suite and E2E workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the JS SDK's e2e coverage (users, organizations, events, scheduled, membership, cleanup) against a real Client API. The suite is skipped unless LUNOGRAM_API_KEY + LUNOGRAM_PROJECT_ID are set, so the offline unit run stays green; the new e2e.yml workflow supplies the secrets on push. Add an optional `url_endpoint` override (Lunogram(api_key, project_id, url_endpoint=...)) so the suite can target the configured environment — the same role as the JS SDK's `urlEndpoint`. Building the base URL from it also removes the long-standing double slash after the host. --- .github/workflows/e2e.yml | 30 +++++++ README.md | 1 + src/lunogram/client.py | 7 +- src/lunogram/utils/reference.py | 18 ++-- tests/test_e2e.py | 149 ++++++++++++++++++++++++++++++++ tests/test_reference.py | 16 +++- 6 files changed, 209 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/e2e.yml create mode 100644 tests/test_e2e.py diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..99a2152 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,30 @@ +name: E2E Tests + +on: + push: + +jobs: + e2e: + name: "E2E Tests" + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" + + - name: Run E2E tests + run: python -m pytest tests/test_e2e.py -v + env: + LUNOGRAM_API_KEY: ${{ secrets.LUNOGRAM_API_KEY }} + LUNOGRAM_API_URL: ${{ secrets.LUNOGRAM_API_URL }} + LUNOGRAM_PROJECT_ID: ${{ secrets.LUNOGRAM_PROJECT_ID }} diff --git a/README.md b/README.md index a6555a8..4f9d47e 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ from lunogram import Lunogram, random_user client = Lunogram( api_key="your-api-key", project_id="11111111-2222-3333-4444-555555555555", # your project UUID + # url_endpoint="https://console.lunogram.com/api", # optional host override ) # Upsert a user diff --git a/src/lunogram/client.py b/src/lunogram/client.py index 0d45966..bbc3d51 100644 --- a/src/lunogram/client.py +++ b/src/lunogram/client.py @@ -5,11 +5,12 @@ from .app import user, organization, push, sessions class Lunogram: - def __init__(self, api_key, project_id): + def __init__(self, api_key, project_id, url_endpoint=None): # Every Client API endpoint is scoped to a project. The project UUID is # supplied once here and injected into every request path automatically; - # callers never pass it per request. - self.reference = Reference(project_id) + # callers never pass it per request. `url_endpoint` optionally overrides + # the API host (e.g. for a staging environment). + self.reference = Reference(project_id, url_endpoint) self.user = user(api_key, self.reference) self.organization = organization(api_key, self.reference) self.push = push(api_key, self.reference) diff --git a/src/lunogram/utils/reference.py b/src/lunogram/utils/reference.py index d122c32..3f94764 100644 --- a/src/lunogram/utils/reference.py +++ b/src/lunogram/utils/reference.py @@ -1,8 +1,9 @@ from uuid import UUID -url = "https://console.lunogram.com/" -api = "/api/client/" -destination = url + api +# Default Client API endpoint. Includes the `/api` prefix; the `/client/...` +# path is appended onto it. Override per-client with `url_endpoint` (e.g. to +# point at a staging environment) — the same role as the JS SDK's `urlEndpoint`. +DEFAULT_ENDPOINT = "https://console.lunogram.com/api" # Every authenticated Client API endpoint is scoped to a single project. The # project UUID is supplied once when the Lunogram client is constructed and is @@ -10,6 +11,13 @@ # pass it per request. +def _client_base(url_endpoint: str | None) -> str: + # Normalize the endpoint to a single trailing-slash `.../client/` base, so + # the resource paths join cleanly with no doubled or missing slashes. + endpoint = (url_endpoint or DEFAULT_ENDPOINT).rstrip("/") + return f"{endpoint}/client/" + + def _validate_project_id(project_id: str) -> str: if not isinstance(project_id, str) or not project_id.strip(): raise ValueError("project_id is required and must be a non-empty string") @@ -58,10 +66,10 @@ def sessions(self, auth_method_id: str) -> str: return self._project + f'auth-methods/{auth_method_id}/sessions' class client: - def __init__(self, project_id: str): + def __init__(self, project_id: str, url_endpoint: str | None = None): self.project_id = _validate_project_id(project_id) # e.g. https://console.lunogram.com/api/client/projects// - project = destination + f'projects/{self.project_id}/' + project = _client_base(url_endpoint) + f'projects/{self.project_id}/' self.user = _user(project) self.organization = _org(project) diff --git a/tests/test_e2e.py b/tests/test_e2e.py new file mode 100644 index 0000000..3c1f761 --- /dev/null +++ b/tests/test_e2e.py @@ -0,0 +1,149 @@ +"""Live integration tests against a real Lunogram Client API. + +Mirrors the JS SDK's e2e suite. These run only when live credentials are +configured (``LUNOGRAM_API_KEY`` + ``LUNOGRAM_PROJECT_ID``); otherwise the whole +module is skipped, so the regular unit `pytest` run stays offline and green. The +dedicated `e2e.yml` workflow supplies the secrets. +""" + +import os +import time +from datetime import datetime, timedelta, timezone + +import pytest + +from lunogram import Lunogram + +API_KEY = os.environ.get("LUNOGRAM_API_KEY") +PROJECT_ID = os.environ.get("LUNOGRAM_PROJECT_ID") +API_URL = os.environ.get("LUNOGRAM_API_URL") + +pytestmark = pytest.mark.skipif( + not (API_KEY and PROJECT_ID), + reason="live credentials (LUNOGRAM_API_KEY, LUNOGRAM_PROJECT_ID) not configured", +) + +_STAMP = int(time.time()) +USER_ID = f"test-user-{_STAMP}" +ORG_ID = f"test-org-{_STAMP}" + + +def _future(days: int) -> str: + return (datetime.now(timezone.utc) + timedelta(days=days)).isoformat().replace("+00:00", "Z") + + +@pytest.fixture(scope="module") +def client(): + return Lunogram(API_KEY, PROJECT_ID, API_URL) + + +def ok(result): + # The facade returns the parsed JSON body (dict) or a Response on success, + # and a ``(response, error)`` tuple on failure. Fail loudly with the body. + assert not isinstance(result, tuple), f"request failed: {result}" + return result + + +# --- Users ----------------------------------------------------------------- + +def test_upsert_user(client): + user = ok(client.user.upsert({ + "identifier": [{"external_id": USER_ID}], + "email": f"{USER_ID}@test.example.com", + "data": {"first_name": "Test", "last_name": "User"}, + })) + assert user["id"] + assert user["email"] == f"{USER_ID}@test.example.com" + + +def test_upsert_user_with_multiple_identifiers(client): + user = ok(client.user.upsert({ + "identifier": [ + {"external_id": USER_ID}, + {"source": "test-suite", "external_id": f"suite-{USER_ID}"}, + ], + "data": {"has_completed_onboarding": True}, + })) + assert len(user["identifier"]) >= 2 + + +def test_post_user_events(client): + ok(client.user.events.post([ + {"name": "test_event", "identifier": [{"external_id": USER_ID}], "data": {"source": "integration_test"}}, + ])) + + +def test_upsert_user_scheduled(client): + scheduled = ok(client.user.scheduled.post({ + "name": "test_reminder", + "identifier": [{"external_id": USER_ID}], + "scheduled_at": _future(7), + "data": {"reason": "integration_test"}, + })) + assert scheduled["name"] == "test_reminder" + + +def test_delete_user_scheduled(client): + ok(client.user.scheduled.delete({ + "name": "test_reminder", + "identifier": [{"external_id": USER_ID}], + })) + + +# --- Organizations --------------------------------------------------------- + +def test_upsert_organization(client): + org = ok(client.organization.upsert({ + "identifier": [{"external_id": ORG_ID}], + "name": "Test Organization", + "data": {"industry": "testing"}, + })) + assert org["id"] + assert org["name"] == "Test Organization" + + +def test_add_user_to_organization(client): + ok(client.organization.add_user({ + "organization": {"identifier": [{"external_id": ORG_ID}]}, + "user": {"identifier": [{"external_id": USER_ID}]}, + "data": {"role": "admin"}, + })) + + +def test_post_organization_events(client): + ok(client.organization.events.post([ + {"identifier": [{"external_id": ORG_ID}], "name": "test_org_event", "data": {"source": "integration_test"}}, + ])) + + +def test_upsert_organization_scheduled(client): + scheduled = ok(client.organization.scheduled.post({ + "name": "test_contract_renewal", + "identifier": [{"external_id": ORG_ID}], + "scheduled_at": _future(30), + })) + assert scheduled["name"] == "test_contract_renewal" + + +def test_delete_organization_scheduled(client): + ok(client.organization.scheduled.delete({ + "name": "test_contract_renewal", + "identifier": [{"external_id": ORG_ID}], + })) + + +def test_remove_user_from_organization(client): + ok(client.organization.remove_user({ + "organization": {"identifier": [{"external_id": ORG_ID}]}, + "user": {"identifier": [{"external_id": USER_ID}]}, + })) + + +# --- Cleanup --------------------------------------------------------------- + +def test_delete_organization(client): + ok(client.organization.delete({"identifier": [{"external_id": ORG_ID}]})) + + +def test_delete_user(client): + ok(client.user.delete({"identifier": [{"external_id": USER_ID}]})) diff --git a/tests/test_reference.py b/tests/test_reference.py index 884747e..6c62433 100644 --- a/tests/test_reference.py +++ b/tests/test_reference.py @@ -63,14 +63,22 @@ def test_exactly_one_project_prefix(name, getter): expected = f"/projects/{PROJECT_ID}/" # The load-bearing invariant: exactly one project-scoped prefix per resource. assert url.count(expected) == 1, f"{name}: expected exactly one prefix in {url!r}" - # NOTE: a pre-existing bug (from PR #1) produces a double slash after the - # host (`...com//api/client/...`) because the base URL has a trailing slash - # and the API path a leading one. That is unrelated to this codegen change - # and left alone; we assert the path segment rather than the exact host join. assert f"/api/client/projects/{PROJECT_ID}/" in url + # The endpoint joins cleanly — no doubled slash after the host. + assert "//api/client/" not in url def test_paths_are_under_client_api(): ref = Reference(PROJECT_ID) assert ref.user.base.endswith(f"/projects/{PROJECT_ID}/users") assert ref.organization.events.endswith(f"/projects/{PROJECT_ID}/organizations/events") + + +def test_url_endpoint_override(): + ref = Reference(PROJECT_ID, "https://staging.example.com/api") + assert ref.user.base == f"https://staging.example.com/api/client/projects/{PROJECT_ID}/users" + + +def test_default_endpoint(): + ref = Reference(PROJECT_ID) + assert ref.user.base == f"https://console.lunogram.com/api/client/projects/{PROJECT_ID}/users" From 1942d348168cd6093f810f054420ffd31e1309a0 Mon Sep 17 00:00:00 2001 From: Jeroen Rinzema Date: Thu, 25 Jun 2026 16:59:28 +0200 Subject: [PATCH 5/9] fix: always scope the endpoint under /api/client Accept url_endpoint with or without a trailing /api and always re-add it, matching the JS SDK. Without this, an endpoint lacking /api built paths that missed the API prefix and hit the frontend catch-all (200 HTML), which the e2e suite surfaced. --- src/lunogram/utils/reference.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/lunogram/utils/reference.py b/src/lunogram/utils/reference.py index 3f94764..7ff2865 100644 --- a/src/lunogram/utils/reference.py +++ b/src/lunogram/utils/reference.py @@ -12,10 +12,14 @@ def _client_base(url_endpoint: str | None) -> str: - # Normalize the endpoint to a single trailing-slash `.../client/` base, so - # the resource paths join cleanly with no doubled or missing slashes. + # The Client API always lives under `/api/client/...`. Accept the endpoint + # with or without a trailing `/api` (mirrors the JS SDK's `urlEndpoint` + # handling) and always re-add it, so the resource paths join cleanly with no + # missing or doubled segments. endpoint = (url_endpoint or DEFAULT_ENDPOINT).rstrip("/") - return f"{endpoint}/client/" + if endpoint.endswith("/api"): + endpoint = endpoint[: -len("/api")] + return f"{endpoint}/api/client/" def _validate_project_id(project_id: str) -> str: From 3faabf82a83733e0e05edd11012b39d3aaaf5976 Mon Sep 17 00:00:00 2001 From: Jeroen Rinzema Date: Thu, 25 Jun 2026 17:01:22 +0200 Subject: [PATCH 6/9] test(e2e): surface server response body on failure --- tests/test_e2e.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 3c1f761..d480c1c 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -39,8 +39,12 @@ def client(): def ok(result): # The facade returns the parsed JSON body (dict) or a Response on success, - # and a ``(response, error)`` tuple on failure. Fail loudly with the body. - assert not isinstance(result, tuple), f"request failed: {result}" + # and a ``(response, error)`` tuple on failure. Fail loudly with the + # server's response body to make the reason debuggable. + if isinstance(result, tuple): + response, error = result[0], result[1] + body = getattr(response, "text", "") + raise AssertionError(f"request failed: {error} — {body[:600]}") return result From 6d38ea3e87e1b91cf54dfb3d9964416322c9270b Mon Sep 17 00:00:00 2001 From: Jeroen Rinzema Date: Thu, 25 Jun 2026 17:03:10 +0200 Subject: [PATCH 7/9] test(e2e): include url/redirects/auth diagnostics on failure --- tests/test_e2e.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index d480c1c..4f4dfa9 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -44,7 +44,14 @@ def ok(result): if isinstance(result, tuple): response, error = result[0], result[1] body = getattr(response, "text", "") - raise AssertionError(f"request failed: {error} — {body[:600]}") + url = getattr(response, "url", "?") + history = getattr(response, "history", []) + sent = getattr(response, "request", None) + auth_sent = bool(sent and sent.headers.get("Authorization")) if sent else None + raise AssertionError( + f"request failed: {error} — url={url} redirects={len(history)} " + f"auth_sent={auth_sent} — {body[:400]}" + ) return result From e6be2c282f5c60108b3b381f0941bc618efbf98e Mon Sep 17 00:00:00 2001 From: Jeroen Rinzema Date: Thu, 25 Jun 2026 17:04:57 +0200 Subject: [PATCH 8/9] test(e2e): trim failure diagnostics to the response body --- tests/test_e2e.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 4f4dfa9..d480c1c 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -44,14 +44,7 @@ def ok(result): if isinstance(result, tuple): response, error = result[0], result[1] body = getattr(response, "text", "") - url = getattr(response, "url", "?") - history = getattr(response, "history", []) - sent = getattr(response, "request", None) - auth_sent = bool(sent and sent.headers.get("Authorization")) if sent else None - raise AssertionError( - f"request failed: {error} — url={url} redirects={len(history)} " - f"auth_sent={auth_sent} — {body[:400]}" - ) + raise AssertionError(f"request failed: {error} — {body[:600]}") return result From bc65b36553d9607140d0bee21cab2f75405360eb Mon Sep 17 00:00:00 2001 From: Jeroen Rinzema Date: Thu, 25 Jun 2026 17:40:04 +0200 Subject: [PATCH 9/9] test: cover remaining facade methods and transport edge cases Extend test_facade.py with mocked coverage for the core methods that were only exercised live (user/org CRUD, events, scheduled, org inbox writes), plus model-vs-dict body acceptance and the empty-2xx (202/204) success path so it is not mistaken for an error tuple. --- tests/test_facade.py | 100 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 99 insertions(+), 1 deletion(-) diff --git a/tests/test_facade.py b/tests/test_facade.py index 0369eaa..7c2691f 100644 --- a/tests/test_facade.py +++ b/tests/test_facade.py @@ -11,7 +11,7 @@ import pytest from lunogram import Lunogram -from lunogram.gen.models import CreateSession +from lunogram.gen.models import CreateSession, IdentifyRequest PROJECT_ID = "11111111-2222-3333-4444-555555555555" API_KEY = "test-key" @@ -129,3 +129,101 @@ def test_session_minting_accepts_a_generated_model(captured): client = Lunogram(API_KEY, PROJECT_ID) client.sessions.create("auth-method-1", CreateSession(user_id="user-1")) assert json.loads(last(captured)["data"]) == {"user_id": "user-1"} + + +def test_user_crud_and_events(captured): + client = Lunogram(API_KEY, PROJECT_ID) + + client.user.upsert({"identifier": [{"external_id": "u"}], "email": "a@b.com"}) + assert last(captured)["method"] == "POST" + assert last(captured)["url"].endswith(f"{PREFIX}users") + + client.user.delete({"identifier": [{"external_id": "u"}]}) + assert last(captured)["method"] == "DELETE" + assert last(captured)["url"].endswith(f"{PREFIX}users") + + client.user.events.post([{"name": "evt", "identifier": [{"external_id": "u"}], "data": {}}]) + assert last(captured)["method"] == "POST" + assert last(captured)["url"].endswith(f"{PREFIX}users/events") + + +def test_user_scheduled(captured): + client = Lunogram(API_KEY, PROJECT_ID) + + client.user.scheduled.post({"name": "r", "identifier": [{"external_id": "u"}]}) + assert last(captured)["method"] == "POST" + assert last(captured)["url"].endswith(f"{PREFIX}users/scheduled") + + client.user.scheduled.delete({"name": "r", "identifier": [{"external_id": "u"}]}) + assert last(captured)["method"] == "DELETE" + assert last(captured)["url"].endswith(f"{PREFIX}users/scheduled") + + +def test_organization_crud_events_scheduled(captured): + client = Lunogram(API_KEY, PROJECT_ID) + + client.organization.upsert({"identifier": [{"external_id": "o"}], "name": "Acme"}) + assert last(captured)["method"] == "POST" + assert last(captured)["url"].endswith(f"{PREFIX}organizations") + + client.organization.delete({"identifier": [{"external_id": "o"}]}) + assert last(captured)["method"] == "DELETE" + assert last(captured)["url"].endswith(f"{PREFIX}organizations") + + client.organization.events.post([{"name": "evt", "identifier": [{"external_id": "o"}]}]) + assert last(captured)["url"].endswith(f"{PREFIX}organizations/events") + + client.organization.scheduled.post({"name": "s", "identifier": [{"external_id": "o"}]}) + assert last(captured)["method"] == "POST" + assert last(captured)["url"].endswith(f"{PREFIX}organizations/scheduled") + + client.organization.scheduled.delete({"name": "s", "identifier": [{"external_id": "o"}]}) + assert last(captured)["method"] == "DELETE" + assert last(captured)["url"].endswith(f"{PREFIX}organizations/scheduled") + + +def test_organization_inbox_write_endpoints(captured): + client = Lunogram(API_KEY, PROJECT_ID) + + client.organization.inbox.create([ + {"target": [{"external_id": "o"}], "identifier": {"external_id": "m1"}, "channel": "inbox"}, + ]) + assert last(captured)["url"].endswith(f"{PREFIX}organizations/inbox") + + client.organization.inbox.count(source="default", external_id="o", channel="inbox") + assert last(captured)["url"].endswith(f"{PREFIX}organizations/inbox/count") + + client.organization.inbox.mark_read([{"target": [{"external_id": "o"}], "message_id": "m1"}]) + assert last(captured)["url"].endswith(f"{PREFIX}organizations/inbox/read") + + client.organization.inbox.mark_archived([{"target": [{"external_id": "o"}], "message_id": "m1"}]) + assert last(captured)["url"].endswith(f"{PREFIX}organizations/inbox/archived") + + +def test_upsert_accepts_a_generated_model(captured): + client = Lunogram(API_KEY, PROJECT_ID) + client.user.upsert(IdentifyRequest.model_validate({ + "identifier": [{"source": "default", "external_id": "u"}], + "email": "a@b.com", + })) + body = json.loads(last(captured)["data"]) + assert body["identifier"][0]["external_id"] == "u" + assert body["email"] == "a@b.com" + # exclude_none drops unset optional fields + assert "phone" not in body + + +def test_empty_2xx_response_is_treated_as_success(): + # Async endpoints reply 202/204 with no body; that must not surface as the + # "(response, error)" failure tuple. + def fake_request(method, url, data=None, params=None, headers=None): + resp = MagicMock() + resp.status_code = 202 + resp.content = b"" + return resp + + with patch("lunogram.app.http.requests.request", side_effect=fake_request): + client = Lunogram(API_KEY, PROJECT_ID) + result = client.user.events.post([{"name": "e", "identifier": [{"external_id": "u"}], "data": {}}]) + assert not isinstance(result, tuple) + assert result.status_code == 202