Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions .genignore
Original file line number Diff line number Diff line change
@@ -1,7 +1,18 @@
# NOTE: .genignore is Speakeasy's protection mechanism and is DORMANT while
# generation runs on openapi-python-client (scripts/generate.sh enforces the
# same hand-owned paths via rsync excludes). Kept current so a switch back to
# Speakeasy needs no rebuild.

# Hand-written webhook signature verification — never overwrite with generated
# code. It lives inside the generated src/convoy tree so the import path stays
# `from convoy.utils.webhook import Webhook`; only this file is hand-owned.
src/convoy/utils/webhook.py
# `from convoy.utils.webhook import Webhook`; only this dir is hand-owned.
src/convoy/utils/**
src/convoy/py.typed

# Hand-owned packaging + generator config.
pyproject.toml
.openapi-python-client.yml
scripts/**

# Shared signature contract + verify unit tests (hand-authored).
test/signature-vectors.json
Expand Down
23 changes: 5 additions & 18 deletions .github/workflows/run-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,11 @@ jobs:
python-version: "3.11"

- name: Install package
# Installation must succeed — a packaging regression (e.g. wheel
# omitting webhook verify) has to fail CI, not hide.
run: |
python -m pip install --upgrade pip
# pyproject.toml arrives with the first Speakeasy generation. Once
# it exists, installation must succeed — a packaging regression
# (e.g. wheel omitting webhook verify) has to fail CI, not hide.
if [ -f pyproject.toml ]; then
pip install -e .
else
echo "pre-generation: no installable package yet; tests import from src/"
fi
pip install -e .
pip install pytest

- name: Verify hand-written modules are present
Expand All @@ -38,17 +33,9 @@ jobs:
test -f test/test_shared_vectors.py

- name: Verify installed package exposes webhook verify
if: hashFiles('pyproject.toml') != ''
# No PYTHONPATH: this must resolve from the installed distribution.
run: python -c "from convoy.utils.webhook import Webhook"

- name: Execute verify + shared vector tests
run: |
if [ -f pyproject.toml ]; then
# Import from the installed distribution so packaging bugs surface.
pytest test/test_webhook.py test/test_shared_vectors.py -q
else
# PEP 420 namespace packages: resolve convoy.utils.webhook from
# src/ before generation adds real __init__.py files.
PYTHONPATH=src pytest test/test_webhook.py test/test_shared_vectors.py -q
fi
# Import from the installed distribution so packaging bugs surface.
run: pytest test/test_webhook.py test/test_shared_vectors.py -q
109 changes: 90 additions & 19 deletions .github/workflows/sdk_generation.yaml
Original file line number Diff line number Diff line change
@@ -1,21 +1,27 @@
name: SDK Generation

# Active generator: openapi-python-client (OSS). The Speakeasy pipeline is
# dormant in speakeasy_generation.yaml (free tier allows one generated SDK
# per workspace; convoy.js holds that slot).
#
# Keeps the same workflow filename and dispatch inputs as the Speakeasy
# version so the frain-dev/convoy dispatcher (speakeasy-sdk.yml) works
# unchanged.

on:
workflow_dispatch:
inputs:
force:
description: Force SDK generation even if no changes are detected
# Accepted for dispatcher compatibility. Generation is deterministic
# from the spec, so there is nothing to force: no diff means no PR.
description: Accepted for compatibility; regeneration is always run
required: false
default: "false"
type: string
feature_branch:
description: Branch for SDK changes
required: false
type: string
environment:
description: Environment variables (e.g., TAG=branch-name)
required: false
type: string
schedule:
- cron: "0 6 * * 1"

Expand All @@ -28,20 +34,85 @@ concurrency:
permissions:
contents: write
pull-requests: write
checks: write
statuses: write

jobs:
generate:
# Pin to commit SHA for v15 (mutable tags can be retargeted).
uses: speakeasy-api/sdk-generation-action/.github/workflows/workflow-executor.yaml@7276a3ae83eafb7fe37630fb1fefea3f2649debd # v15
with:
mode: pr
force: ${{ inputs.force || 'false' }}
feature_branch: ${{ inputs.feature_branch }}
environment: ${{ inputs.environment }}
secrets:
# Prefer a PAT: PRs opened with GITHUB_TOKEN do not trigger
# pull_request workflows, so verify CI would never run on them.
github_access_token: ${{ secrets.SDK_BOT_PAT || secrets.GITHUB_TOKEN }}
speakeasy_api_key: ${{ secrets.SPEAKEASY_API_KEY }}
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
# Prefer a PAT: PRs opened with GITHUB_TOKEN do not trigger
# pull_request workflows, so verify CI would never run on them.
token: ${{ secrets.SDK_BOT_PAT || secrets.GITHUB_TOKEN }}

- name: Setup Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: "3.11"

- name: Install generator
# Pin both so regeneration output is reproducible; ruff is the
# generator's post-processing formatter.
run: pip install openapi-python-client==0.29.0 ruff==0.15.22

- name: Regenerate client
run: ./scripts/generate.sh

- name: Detect changes
id: diff
run: |
if git diff --quiet && [ -z "$(git status --porcelain)" ]; then
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "No client changes; skipping PR." >> "$GITHUB_STEP_SUMMARY"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
fi

- name: Prepare feature branch
if: steps.diff.outputs.changed == 'true'
id: branch
env:
# Never interpolate free-form dispatch inputs into run: directly.
FEATURE_BRANCH_INPUT: ${{ inputs.feature_branch }}
run: |
if [ -n "$FEATURE_BRANCH_INPUT" ]; then
# SDK PRs must come from a reviewable feature branch, never a
# protected ref or an option-looking / metacharacter name.
case "$FEATURE_BRANCH_INPUT" in
main|master|release/*|-*|*[!a-zA-Z0-9._/-]*)
echo "::error::Invalid feature_branch '$FEATURE_BRANCH_INPUT'"
exit 1
;;
esac
echo "name=$FEATURE_BRANCH_INPUT" >> "$GITHUB_OUTPUT"
else
echo "name=sdk-regen-$(date -u +%Y%m%d)" >> "$GITHUB_OUTPUT"
fi

- name: Push branch and open PR
if: steps.diff.outputs.changed == 'true'
env:
GH_TOKEN: ${{ secrets.SDK_BOT_PAT || secrets.GITHUB_TOKEN }}
BRANCH: ${{ steps.branch.outputs.name }}
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

git checkout -B "$BRANCH"
git add -A
git commit -m "feat: regenerate API client from OpenAPI spec"
# Force push is safe: the regen branch is fully derived from main
# plus this deterministic generation; any previous content is stale.
git push --force origin "$BRANCH"

existing=$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number // empty')
if [ -z "$existing" ]; then
gh pr create \
--head "$BRANCH" \
--title "feat: regenerate API client from OpenAPI spec" \
--body "Automated regeneration via openapi-python-client from \`docs/v3/openapi3.yaml\` on frain-dev/convoy main. Hand-written webhook verify (\`src/convoy/utils/\`) is untouched by the sync script."
echo "Opened PR for $BRANCH" >> "$GITHUB_STEP_SUMMARY"
else
echo "Updated existing PR #$existing" >> "$GITHUB_STEP_SUMMARY"
fi
51 changes: 51 additions & 0 deletions .github/workflows/speakeasy_generation.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
name: Speakeasy SDK Generation (dormant)

# DORMANT: Python generation moved to openapi-python-client
# (.github/workflows/sdk_generation.yaml) because the Speakeasy free tier
# allows one generated SDK per workspace and convoy.js holds that slot.
# This workflow is kept manual-only so switching back is a trigger change,
# not a rebuild. Config lives on in .speakeasy/ and .genignore.

on:
workflow_dispatch:
inputs:
force:
description: Force SDK generation even if no changes are detected
required: false
default: "false"
type: string
feature_branch:
description: Branch for SDK changes
required: false
type: string
environment:
description: Environment variables (e.g., TAG=branch-name)
required: false
type: string

# Serialize generations: overlapping cron/dispatch runs race on the same
# branch/PR. Queue instead of cancel so a triggered regen is never dropped.
concurrency:
group: sdk-generation
cancel-in-progress: false

permissions:
contents: write
pull-requests: write
checks: write
statuses: write

jobs:
generate:
# Pin to commit SHA for v15 (mutable tags can be retargeted).
uses: speakeasy-api/sdk-generation-action/.github/workflows/workflow-executor.yaml@7276a3ae83eafb7fe37630fb1fefea3f2649debd # v15
with:
mode: pr
force: ${{ inputs.force || 'false' }}
feature_branch: ${{ inputs.feature_branch }}
environment: ${{ inputs.environment }}
secrets:
# Prefer a PAT: PRs opened with GITHUB_TOKEN do not trigger
# pull_request workflows, so verify CI would never run on them.
github_access_token: ${{ secrets.SDK_BOT_PAT || secrets.GITHUB_TOKEN }}
speakeasy_api_key: ${{ secrets.SPEAKEASY_API_KEY }}
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@ build
convoy_python.egg-info
dist/
__pycache__/
*.pyc
*.pyc
.venv-proof/
.ruff_cache/
5 changes: 5 additions & 0 deletions .openapi-python-client.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# openapi-python-client config (active generator).
# PyPI name stays convoy-python; imports stay `from convoy ...` so the
# hand-written verify path (convoy/utils/webhook.py) keeps working.
package_name_override: convoy
project_name_override: convoy-python
19 changes: 12 additions & 7 deletions MIGRATION.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
# convoy-python 1.x migration (Speakeasy)
# convoy-python 1.x migration

## What changed

- The **public HTTP API client** will be generated from Convoy's OpenAPI spec (`docs/v3/openapi3.yaml`) via [Speakeasy](https://www.speakeasy.com/).
- **Webhook signature verification stays hand-written.** Generators do not own crypto. `src/convoy/utils/webhook.py` and the shared `test/signature-vectors.json` contract remain the source of truth for verify (see `.genignore`).
- The **public HTTP API client** is generated from Convoy's OpenAPI spec (`docs/v3/openapi3.yaml`) via [openapi-python-client](https://github.com/openapi-generators/openapi-python-client).
- **Webhook signature verification stays hand-written.** Generators do not own crypto. `src/convoy/utils/webhook.py` and the shared `test/signature-vectors.json` contract remain the source of truth for verify. The generation sync script (`scripts/generate.sh`) never touches `src/convoy/utils/`.

## Generator choice

Generation originally bootstrapped on Speakeasy, but the Speakeasy free tier allows one generated SDK per workspace and `convoy.js` holds that slot. The Speakeasy pipeline is kept **dormant** (`.speakeasy/`, `.genignore`, `.github/workflows/speakeasy_generation.yaml`) so the provider can be switched back without a rebuild; the active pipeline is openapi-python-client (`.github/workflows/sdk_generation.yaml`).

## Breaking change policy

Shipping the Speakeasy client is an intentional **1.x** break from the hand-written `0.x` surfaces. Method shapes are **not** silently preserved.
Shipping the generated client is an intentional **1.x** break from the hand-written `0.x` surfaces. Method shapes are **not** silently preserved.

1. This bootstrap PR wires Speakeasy, removes the deprecated hand-written HTTP client, and relocates verify to `src/convoy/utils/webhook.py` (inside the generated module tree, so `from convoy.utils.webhook import Webhook` keeps resolving — `moduleName: convoy` in `.speakeasy/gen.yaml`).
1. This PR wires openapi-python-client generation; the hand-written HTTP client stays removed and verify lives at `src/convoy/utils/webhook.py` (inside the generated module tree, so `from convoy.utils.webhook import Webhook` keeps resolving — `package_name_override: convoy` in `.openapi-python-client.yml`).
2. The first `sdk_generation.yaml` run opens a PR that adds the OpenAPI-generated client and publishes as `1.x`.
3. Consumers pin `0.x` until they migrate call sites.

Expand All @@ -28,8 +32,9 @@ if not webhook.verify_signature(payload, signature):

## Regenerating the API client

CI on `frain-dev/convoy` triggers Speakeasy when OpenAPI artifacts change. Locally (requires `SPEAKEASY_API_KEY`):
CI on `frain-dev/convoy` dispatches `sdk_generation.yaml` when OpenAPI artifacts change. Locally:

```bash
speakeasy run
pip install openapi-python-client==0.29.0 ruff==0.15.22
./scripts/generate.sh
```
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,6 @@ Please see [CONTRIBUTING](CONTRIBUTING.MD) for details.

The MIT License (MIT). Please see [License File](LICENSE) for more information.

## Speakeasy-generated API client
## Generated API client

The HTTP API client is generated from Convoy OpenAPI via Speakeasy. **Webhook signature verification remains hand-written** (`convoy/utils/webhook.py`) and is covered by shared `test/signature-vectors.json`. See [MIGRATION.md](./MIGRATION.md).
The HTTP API client is generated from Convoy's OpenAPI spec via [openapi-python-client](https://github.com/openapi-generators/openapi-python-client). **Webhook signature verification remains hand-written** (`convoy/utils/webhook.py`) and is covered by shared `test/signature-vectors.json`. See [MIGRATION.md](./MIGRATION.md).
28 changes: 28 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
[project]
name = "convoy-python"
version = "1.0.0a1"
description = "Convoy Python SDK: OpenAPI-generated API client and hand-written webhook signature verification"
readme = "README.md"
license = { text = "MIT" }
authors = [{ name = "Frain Inc." }]
requires-python = ">=3.11"
dependencies = [
"httpx>=0.23.1,<0.29.0",
"attrs>=22.2.0",
]

[project.urls]
Homepage = "https://github.com/frain-dev/convoy-python"
Documentation = "https://getconvoy.io/docs"

[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"

# namespaces=true (the default) so `convoy` resolves both before the first
# generation run (only convoy/utils exists) and after (generated __init__.py).
[tool.setuptools.packages.find]
where = ["src"]

[tool.setuptools.package-data]
convoy = ["py.typed"]
38 changes: 38 additions & 0 deletions scripts/generate.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
set -euo pipefail

# Regenerate the API client from Convoy's OpenAPI spec with
# openapi-python-client, then sync it into src/convoy/ without touching
# hand-owned paths (webhook verify, py.typed).
#
# Requires: openapi-python-client, rsync, curl. Run from the repo root.

SPEC_URL="${SPEC_URL:-https://raw.githubusercontent.com/frain-dev/convoy/main/docs/v3/openapi3.yaml}"

tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT

curl -fsSL "$SPEC_URL" -o "$tmp/openapi3.yaml"

# --meta none emits only the package contents; project metadata is hand-owned
# in pyproject.toml.
# --fail-on-warning: a warning means the generator skipped part of the spec;
# never mirror a partial client into src/convoy (rsync --delete would drop
# previously generated modules).
openapi-python-client generate \
--path "$tmp/openapi3.yaml" \
--config .openapi-python-client.yml \
--meta none \
--fail-on-warning \
--output-path "$tmp/gen"

# --delete keeps src/convoy an exact mirror of generator output; excluded
# paths are hand-written and never created or removed by this script.
# .ruff_cache is a side effect of the generator's post-processing formatter.
rsync -a --delete \
--exclude 'utils/' \
--exclude 'py.typed' \
--exclude '.ruff_cache/' \
"$tmp/gen/" src/convoy/

echo "Generated client synced into src/convoy/"
Empty file added src/convoy/py.typed
Empty file.
1 change: 1 addition & 0 deletions src/convoy/utils/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Hand-written utilities: webhook signature verification lives here."""