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/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/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 e69de29..4f9d47e 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,166 @@ +# 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 + # url_endpoint="https://console.lunogram.com/api", # optional host override +) + +# 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. + +## 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 +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 from a platform release + +`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 + +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`. 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..ab87a71 --- /dev/null +++ b/spec/SOURCE.md @@ -0,0 +1,34 @@ +# Spec source + +`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 | +| Pinned tag | `v0.1.0-rc.1` | +| Spec asset | `client.yaml` | + +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. + +## Release asset URL + +``` +https://github.com/lunogram/platform/releases/download/v0.1.0-rc.1/client.yaml +``` + +## Refreshing the spec + +To track a newer release, update the **Pinned tag** above and re-fetch: + +```bash +TAG=v0.1.0-rc.1 +curl -fsSL "https://github.com/lunogram/platform/releases/download/$TAG/client.yaml" -o spec/client.yaml +make generate +``` + +Commit `spec/client.yaml` and `src/lunogram/gen/models.py` together. 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/__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/models/objects.py b/src/lunogram/app/models/objects.py deleted file mode 100644 index 608432b..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, default_client - -from src.lunogram.app.types.event import * -from src.lunogram.app.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): - self.entity = entity - self.reference = reference or default_client - 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): - self.entity = entity - self.reference = reference or default_client - 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 \ No newline at end of file diff --git a/src/lunogram/app/models/organization.py b/src/lunogram/app/models/organization.py deleted file mode 100644 index 80d34bd..0000000 --- a/src/lunogram/app/models/organization.py +++ /dev/null @@ -1,21 +0,0 @@ -from src.lunogram.app.models.objects import events, scheduled -from src.lunogram.app.http import httphandler -from src.lunogram.app.types.organization import * - -from ...utils.reference import client - -class organization: - def __init__(self, api_key): - self.events = events(api_key, entity='organization') - self.scheduled = scheduled(api_key, entity='organization') - self.handler = httphandler(api_key) - - def upsert(self, data: UpsertOrganization): - req = self.handler.post(client.organization.base, data) - - return req - - def delete(self, data: DeleteOrganization): - req = self.handler.delete(client.organization.base, data) - - return req \ No newline at end of file diff --git a/src/lunogram/app/models/user.py b/src/lunogram/app/models/user.py deleted file mode 100644 index 8d61343..0000000 --- a/src/lunogram/app/models/user.py +++ /dev/null @@ -1,21 +0,0 @@ -from objects import events, scheduled -from ..http import httphandler - -from ...utils.reference import client -from src.lunogram.app.types.user import * - -class user: - def __init__(self, api_key): - self.events = events(api_key, entity='user') - self.scheduled = scheduled(api_key, entity='user') - self.handler = httphandler(api_key) - - def upsert(self, data: UpsertUser): - req = self.handler.post(client.user.base, data) - - return req - - def delete(self, data: DeleteUser): - req = self.handler.delete(client.user.base, data) - - return req \ No newline at end of file diff --git a/src/lunogram/app/objects.py b/src/lunogram/app/objects.py index eb30761..738d624 100644 --- a/src/lunogram/app/objects.py +++ b/src/lunogram/app/objects.py @@ -1,77 +1,217 @@ -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 ( + Channel, + IdentifyRequest, + DeleteUserRequest, + OrganizationRequest, + DeleteOrganizationRequest, + OrganizationUserRequest, + RemoveOrganizationUserRequest, + Event, + OrganizationEvent, + UpsertUserScheduledRequest, + UpsertOrganizationScheduledRequest, + DeleteUserScheduledRequest, + DeleteOrganizationScheduledRequest, + InboxMessageCreate, + OrganizationInboxMessageCreate, + UserInboxMessageRef, + OrganizationInboxMessageRef, + DeviceRegistration, + CreateSession, +) 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. 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: - 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): + + def post(self, data: Union[Event, OrganizationEvent, list, Body]): + data = _serialize(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): + def post(self, data: Union[UpsertUserScheduledRequest, UpsertOrganizationScheduledRequest, Body]): + data = _serialize(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): + + def delete(self, data: Union[DeleteUserScheduledRequest, DeleteOrganizationScheduledRequest, Body]): + data = _serialize(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 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): - 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.inbox = inbox(api_key, reference, entity='user') + self.devices = devices(api_key, reference) self.handler = httphandler(api_key) - def upsert(self, data): - req = self.handler.post(client.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(client.user.base, data) + def delete(self, data: Union[DeleteUserRequest, Body]): + req = self.handler.delete(self.reference.user.base, _serialize(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.inbox = inbox(api_key, reference, entity='organization') self.handler = httphandler(api_key) - def upsert(self, data): - req = self.handler.post(client.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(client.organization.base, data) - return req \ No newline at end of file + 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/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/client.py b/src/lunogram/client.py index ef7f9ae..bbc3d51 100644 --- a/src/lunogram/client.py +++ b/src/lunogram/client.py @@ -1,12 +1,20 @@ from random import randint from .utils import seed -from .app import user, organization +from .utils.reference import client as Reference +from .app import user, organization, push, sessions class Lunogram: - def __init__(self, api_key): - self.user = user(api_key) - self.organization = organization(api_key) + 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. `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) + 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(): @@ -41,8 +49,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/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/src/lunogram/utils/reference.py b/src/lunogram/utils/reference.py index 060cd1b..7ff2865 100644 --- a/src/lunogram/utils/reference.py +++ b/src/lunogram/utils/reference.py @@ -1,20 +1,81 @@ -url = "https://console.lunogram.com/" -api = "/api/client/" -destination = url + api +from uuid import UUID + +# 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 +# injected here as a `/projects//` path segment, so callers never +# pass it per request. + + +def _client_base(url_endpoint: str | None) -> str: + # 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("/") + if endpoint.endswith("/api"): + endpoint = endpoint[: -len("/api")] + return f"{endpoint}/api/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") + + 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, url_endpoint: str | None = None): + self.project_id = _validate_project_id(project_id) + # e.g. https://console.lunogram.com/api/client/projects// + project = _client_base(url_endpoint) + f'projects/{self.project_id}/' + + self.user = _user(project) + self.organization = _org(project) + self.push = _push(project) + self.auth_methods = _auth_methods(project) diff --git a/tests/test_e2e.py b/tests/test_e2e.py new file mode 100644 index 0000000..d480c1c --- /dev/null +++ b/tests/test_e2e.py @@ -0,0 +1,153 @@ +"""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 + # 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 + + +# --- 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_facade.py b/tests/test_facade.py new file mode 100644 index 0000000..7c2691f --- /dev/null +++ b/tests/test_facade.py @@ -0,0 +1,229 @@ +"""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, IdentifyRequest + +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"} + + +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 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..6c62433 --- /dev/null +++ b/tests/test_reference.py @@ -0,0 +1,84 @@ +"""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}" + 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"