From df6e2a72131c85de5ff0e00c0d359e9012c9ef27 Mon Sep 17 00:00:00 2001 From: convoy-bot Date: Sun, 19 Jul 2026 16:06:36 +0000 Subject: [PATCH] feat: bootstrap Speakeasy API client generation (PDE-755) Wire Speakeasy generation from convoy docs/v3/openapi3.yaml. Keep webhook signature verify hand-written and covered by shared signature-vectors.json. First Speakeasy generation ships as a new major so hand-written API shapes are not silently broken. --- .genignore | 10 +-- .github/workflows/run-tests.yml | 10 ++- MIGRATION.md | 6 +- convoy/__init__.py | 1 - convoy/api/__init__.py | 7 -- convoy/api/delivery_attempts.py | 20 ----- convoy/api/endpoint.py | 85 ------------------ convoy/api/event.py | 71 --------------- convoy/api/event_delivery.py | 54 ------------ convoy/api/project.py | 39 --------- convoy/api/source.py | 92 -------------------- convoy/api/subscription.py | 93 -------------------- convoy/client/__init__.py | 1 - convoy/client/client.py | 81 ----------------- convoy/convoy.py | 29 ------- convoy/data.py | 53 ------------ convoy/utils/__init__.py | 3 - convoy/utils/helpers.py | 50 ----------- {convoy => src/convoy}/utils/webhook.py | 0 test/test_client.py | 60 ------------- test/test_routes.py | 110 ------------------------ 21 files changed, 15 insertions(+), 860 deletions(-) delete mode 100644 convoy/__init__.py delete mode 100644 convoy/api/__init__.py delete mode 100644 convoy/api/delivery_attempts.py delete mode 100644 convoy/api/endpoint.py delete mode 100644 convoy/api/event.py delete mode 100644 convoy/api/event_delivery.py delete mode 100644 convoy/api/project.py delete mode 100644 convoy/api/source.py delete mode 100644 convoy/api/subscription.py delete mode 100644 convoy/client/__init__.py delete mode 100644 convoy/client/client.py delete mode 100644 convoy/convoy.py delete mode 100644 convoy/data.py delete mode 100644 convoy/utils/__init__.py delete mode 100644 convoy/utils/helpers.py rename {convoy => src/convoy}/utils/webhook.py (100%) delete mode 100644 test/test_client.py delete mode 100644 test/test_routes.py diff --git a/.genignore b/.genignore index c665f59..8d6d495 100644 --- a/.genignore +++ b/.genignore @@ -1,9 +1,7 @@ -# Hand-written webhook signature verification — never overwrite with generated code. -# helpers.py must stay too: utils/__init__.py eagerly imports it, so dropping it -# would break `from convoy.utils.webhook import Webhook` at package init. -convoy/utils/webhook.py -convoy/utils/helpers.py -convoy/utils/__init__.py +# 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 # Shared signature contract + verify unit tests (hand-authored). test/signature-vectors.json diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 8889672..37ccd44 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -21,14 +21,20 @@ jobs: - name: Install package run: | python -m pip install --upgrade pip - pip install -e . + # Pre-generation the repo has no installable package yet; the verify + # tests import from src/ directly (PYTHONPATH below). + pip install -e . || echo "editable install unavailable (pre-generation); continuing" pip install pytest - name: Verify hand-written modules are present run: | - test -f convoy/utils/webhook.py + test -f src/convoy/utils/webhook.py test -f test/signature-vectors.json test -f test/test_shared_vectors.py - name: Execute verify + shared vector tests + env: + # PEP 420 namespace packages: `convoy.utils.webhook` resolves from + # src/ before generation adds real __init__.py files. + PYTHONPATH: src run: pytest test/test_webhook.py test/test_shared_vectors.py -q diff --git a/MIGRATION.md b/MIGRATION.md index 878dc4b..2a2faa4 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -3,14 +3,14 @@ ## 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. `convoy/utils/webhook.py` and the shared `test/signature-vectors.json` contract remain the source of truth for verify (see `.genignore`). +- **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`). ## 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. -1. This bootstrap PR wires Speakeasy + protects verify. -2. The first `sdk_generation.yaml` run opens a PR that replaces the hand-written HTTP client with generated code and publishes as `1.x`. Generation keeps the `convoy` import root (`moduleName: convoy` in `.speakeasy/gen.yaml`); that PR **must relocate `convoy/utils/webhook.py` into the generated module tree unchanged** so `from convoy.utils.webhook import Webhook` keeps resolving. +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`). +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. ## Verify (unchanged) diff --git a/convoy/__init__.py b/convoy/__init__.py deleted file mode 100644 index 786d124..0000000 --- a/convoy/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from convoy.convoy import Convoy \ No newline at end of file diff --git a/convoy/api/__init__.py b/convoy/api/__init__.py deleted file mode 100644 index 3d60842..0000000 --- a/convoy/api/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from convoy.api.delivery_attempts import DeliveryAttempt -from convoy.api.endpoint import Endpoint -from convoy.api.event import Event -from convoy.api.event_delivery import EventDelivery -from convoy.api.project import Project -from convoy.api.source import Source -from convoy.api.subscription import Subscription diff --git a/convoy/api/delivery_attempts.py b/convoy/api/delivery_attempts.py deleted file mode 100644 index a610f0f..0000000 --- a/convoy/api/delivery_attempts.py +++ /dev/null @@ -1,20 +0,0 @@ -from convoy.client import Client - -class DeliveryAttempt(): - """Initializes a DeliveryAttempt object to make calls to the /deliveryattempts endpoint. - - Parameters - ---------- - config : dict of config values - """ - def __init__(self, config): - self.client = Client(config) - - def all(self, event_delivery_id, query): - response = self.client.http_get("/eventdeliveries/%s/deliveryattempts" % event_delivery_id, query) - return response - - def find(self, event_delivery_id, delivery_attempt_id, query): - response = self.client.http_get("/eventdeliveries/%s/deliveryattempts/%s" % (event_delivery_id, delivery_attempt_id), query) - return response - diff --git a/convoy/api/endpoint.py b/convoy/api/endpoint.py deleted file mode 100644 index 579c45c..0000000 --- a/convoy/api/endpoint.py +++ /dev/null @@ -1,85 +0,0 @@ -from convoy.client import Client - -class Endpoint(): - """Initializes an Endpoint object to make calls to the /endpoints endpoint. - - Parameters - ---------- - config : dict of config values - """ - def __init__(self, config): - self.client = Client(config) - - def all(self, query): - """ - Get all endpoints for a project. - """ - response = self.client.http_get("/endpoints", query) - return response - - def create(self, query, data): - """ - Create a new endpoint. - Parameters - ---------- - data = { - "url": "", - "description": "", - "secret": "", - "events": [], - "content_type": "", - } - """ - response = self.client.http_post("/endpoints", query, data) - return response - - def find(self, endpoint_id, query): - """ - Find a particular endpoint. - """ - response = self.client.http_get("/endpoints/%s" % endpoint_id, query) - return response - - def update(self, endpoint_id, query, data): - """ - Update an endpoint. - Parameters - ---------- - data = { - "url": "", - "description": "", - "secret": "", - "events": [], - "content_type": "", - } - """ - response = self.client.http_put("/endpoints/%s" % endpoint_id, query, data) - return response - - def delete(self, endpoint_id, query, data): - """ - Delete an endpoint. - """ - response = self.client.http_delete("/endpoints/%s" % endpoint_id, query, data) - return response - - def pause(self, endpoint_id): - """ - Toggle an endpoint between paused and active. - """ - response = self.client.http_put("/endpoints/%s/pause" % endpoint_id, {}, {}) - return response - - def expire_secret(self, endpoint_id, data): - """ - Roll the endpoint secret. - Parameters - ---------- - data = { - "expiration": int, # hours before the old secret expires - "secret": "", # optional, generated when omitted - } - """ - response = self.client.http_put("/endpoints/%s/expire_secret" % endpoint_id, {}, data) - return response - diff --git a/convoy/api/event.py b/convoy/api/event.py deleted file mode 100644 index e94d6d2..0000000 --- a/convoy/api/event.py +++ /dev/null @@ -1,71 +0,0 @@ -from convoy.client import Client - -class Event(): - """Initializes an Event object to make calls to the /event endpoint. - - Parameters - ---------- - config : dict of config values - """ - def __init__(self, config): - self.client = Client(config) - - def all(self, query): - response = self.client.http_get("/events", query) - return response - - def create(self, query, data): - """ - Create a new event. - Parameters - ---------- - data = { - endpoint_id: "", - event_type: "", - data: { - "event": "", - "data": {}, - } - } - """ - response = self.client.http_post("/events", query, data) - return response - - def find(self, id, query): - response = self.client.http_get("/events/%s" % id, query) - return response - - def fanout(self, query, data): - """ - Send an event to all endpoints with the given owner_id. - Parameters - ---------- - data = { - "owner_id": "", - "event_type": "", - "data": {}, - } - """ - response = self.client.http_post("/events/fanout", query, data) - return response - - def broadcast(self, query, data): - """ - Send an event to all endpoints in the project. - Parameters - ---------- - data = { - "event_type": "", - "data": {}, - } - """ - response = self.client.http_post("/events/broadcast", query, data) - return response - - def replay(self, id): - """ - Replay a previously ingested event. - """ - response = self.client.http_put("/events/%s/replay" % id, {}, {}) - return response - diff --git a/convoy/api/event_delivery.py b/convoy/api/event_delivery.py deleted file mode 100644 index f52be2b..0000000 --- a/convoy/api/event_delivery.py +++ /dev/null @@ -1,54 +0,0 @@ -from convoy.client import Client - -class EventDelivery(): - """Initializes an EventDelivery object to make calls to the /eventdeliveries endpoint. - - Parameters - ---------- - config : dict of config values - """ - def __init__(self, config): - self.client = Client(config) - - def all(self, query): - """ - Get all eventdeliveries. - """ - response = self.client.http_get("/eventdeliveries", query) - return response - - def find(self, id, query): - """ - Find a particular eventdelivery. - """ - response = self.client.http_get("/eventdeliveries/%s" % id, query) - return response - - def resend(self, id, query): - """ - Resend an eventdelivery. - """ - response = self.client.http_put("/eventdeliveries/%s/resend" % id, query, {}) - return response - - def batchresend(self, query): - """ - Batch retry eventdeliveries matching the query filters. The server - reads filters from query params only, e.g. - query = {"endpointId": [], "eventId": "", "status": []} - """ - response = self.client.http_post("/eventdeliveries/batchretry", query, {}) - return response - - def forceresend(self, query, data): - """ - Force resend successful eventdeliveries. - Parameters - ---------- - data = { - "ids": [] - } - """ - response = self.client.http_post("/eventdeliveries/forceresend", query, data) - return response - diff --git a/convoy/api/project.py b/convoy/api/project.py deleted file mode 100644 index db5bb52..0000000 --- a/convoy/api/project.py +++ /dev/null @@ -1,39 +0,0 @@ -from convoy.client import Client - -class Project(): - """Initializes a Project object scoped to the configured project. - - Parameters - ---------- - config : dict of config values - """ - def __init__(self, config): - self.client = Client(config) - - def find(self, query): - """ - Retrieve the configured project. - """ - response = self.client.http_get("", query) - return response - - def update(self, query, data): - """ - Update the configured project. - Parameters - ---------- - data = { - "name": "", - "logo_url": "", - "config": {}, - } - """ - response = self.client.http_put("", query, data) - return response - - def delete(self, query): - """ - Delete the configured project. - """ - response = self.client.http_delete("", query, {}) - return response diff --git a/convoy/api/source.py b/convoy/api/source.py deleted file mode 100644 index 0a7a6c8..0000000 --- a/convoy/api/source.py +++ /dev/null @@ -1,92 +0,0 @@ -from convoy.client import Client - -class Source(): - """Initializes a Source object to make calls to the source endpoint. - - Parameters - ---------- - config: dict of config values - """ - - def __init__(self, config): - self.client = Client(config) - - def all(self, query): - """ - Get all sources for a project. - """ - - response = self.client.http_get("/sources", query) - return response - - def create(self, query, data): - """ - Create a new source. - - Paramters - --------- - data = { - { - "name": "", - "type": "", - "provider": "", - "verifier": { - "hmac": { - "secret": "" - } - } - } - """ - - response = self.client.http_post("/sources", query, data) - return response - - def find(self, source_id, query): - """ - Find a source by supplied ID. - """ - - response = self.client.http_get("/sources/%s" % source_id, query) - return response - - def update(self, source_id, query, data): - """ - Update a source. - - Parameters - ---------- - data = { - "is_disabled": true | false, - "name": "", - "provider": "", - "type": "", - "verifier": { - "api_key": { - "header_name": "", - "header_value": "" - }, - "basic_auth": { - "password": "", - "username": "" - }, - "hmac": { - "encoding": "", - "hash": "", - "header": "", - "secret": "" - }, - "type": "" - } - } - """ - - response = self.client.http_put("/sources/%s" % source_id, query, data) - return response - - def delete(self, source_id, query, data): - """ - Delete a source. - """ - - response = self.client.http_delete("/sources/%s" % source_id, query, data) - return response \ No newline at end of file diff --git a/convoy/api/subscription.py b/convoy/api/subscription.py deleted file mode 100644 index b45d318..0000000 --- a/convoy/api/subscription.py +++ /dev/null @@ -1,93 +0,0 @@ -from convoy.client import Client - -class Subscription(): - """Initializes a Subscription object to make calls to the subscription endpoint. - - Parameters - ---------- - config: dict of config values - - """ - - def __init__(self, config): - self.client = Client(config) - - def all(self, query): - """ - Get all subscriptions. - """ - - response = self.client.http_get("/subscriptions", query) - return response - - def create(self, query, data): - """ - Create a new subscription. - - Parameters - ---------- - data = { - "alert_config": { - "count": 0, - "threshold": "" - }, - "endpoint_id": "", - "filter_config": { - "event_types": [] - }, - "name": "string", - "retry_config": { - "duration": "", - "retry_count": 0, - "type": "" - }, - "source_id": "" - } - """ - - response = self.client.http_post("/subscriptions", query, data) - return response - - def find(self, subscription_id, query): - """ - Find a subscription by supplied ID. - """ - - response = self.client.http_get("/subscriptions/%s" % subscription_id, query) - return response - - def update(self, subscription_id, query, data): - """ - Update a subscription. - - Parameters - ---------- - data = { - "alert_config": { - "count": 0, - "threshold": "" - }, - "endpoint_id": "", - "filter_config": { - "event_types": [] - }, - "name": "string", - "retry_config": { - "duration": "", - "retry_count": 0, - "type": "" - }, - "source_id": "" - } - """ - - response = self.client.http_put("/subscriptions/%s" % subscription_id, query, data) - return response - - def delete(self, subscription_id, query, data): - """ - Delete a subscription. - """ - - response = self.client.http_delete("/subscriptions/%s" % subscription_id, query, data) - return response diff --git a/convoy/client/__init__.py b/convoy/client/__init__.py deleted file mode 100644 index aa8ae3f..0000000 --- a/convoy/client/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from convoy.client.client import Client \ No newline at end of file diff --git a/convoy/client/client.py b/convoy/client/client.py deleted file mode 100644 index 5923577..0000000 --- a/convoy/client/client.py +++ /dev/null @@ -1,81 +0,0 @@ -from dataclasses import dataclass -from typing import Optional -import requests -import json -from convoy.utils import response_helper - - -@dataclass -class Config: - api_key: Optional[str] = "" - uri: Optional[str] = "" - project_id: Optional[str] = "" - -class Client(): - """ - Initializes a Client Object. - """ - def __init__(self, config: dict): - config = Config(**config) - - if not config.api_key: - raise ValueError("api_key is required") - if not config.uri: - raise ValueError("uri is required, e.g. https://us.getconvoy.cloud/api/v1") - if not config.project_id: - raise ValueError("project_id is required") - if "/projects/" in config.uri or config.uri.rstrip("/").endswith("/projects"): - raise ValueError("uri must be the instance API base without a project path; pass project_id separately") - - self.api_key = config.api_key - self.base_uri = "%s/projects/%s" % (config.uri.rstrip("/"), config.project_id) - self.headers = { - "Authorization": "Bearer %s" % self.api_key, - "Content-Type": "application/json; charset=utf-8", - # Pin the API version this SDK release was built against; the - # server's request migrations translate for older/newer instances. - "X-Convoy-Version": "2025-11-24", - } - - def http_get(self, path, query): - try: - response = requests.get(self.build_path(path), headers=self.headers, params=query) - return self.parse_response(response) - except requests.RequestException as e: - return response_helper(e) - - def http_post(self, path, query, data): - try: - response = requests.post(self.build_path(path), data=json.dumps(data, separators=(",", ":")), headers=self.headers, params=query) - return self.parse_response(response) - except requests.RequestException as e: - return response_helper(e) - - def http_put(self, path, query, data): - try: - response = requests.put(self.build_path(path), data=json.dumps(data, separators=(",", ":")), headers=self.headers, params=query) - return self.parse_response(response) - except requests.RequestException as e: - return response_helper(e) - - def http_delete(self, path, query, data): - try: - response = requests.delete(self.build_path(path), data=json.dumps(data, separators=(",", ":")), headers=self.headers, params=query) - return self.parse_response(response) - except requests.RequestException as e: - return response_helper(e) - - @staticmethod - def parse_response(response): - # Proxies or routers can return non-JSON error bodies; surface them - # in the standard (body, status_code) shape instead of crashing. - try: - return response.json(), response.status_code - except ValueError: - return {"status": False, "message": response.text}, response.status_code - - def get_base_url(self): - return self.base_uri - - def build_path(self, path): - return "%s%s" % (self.base_uri, path) diff --git a/convoy/convoy.py b/convoy/convoy.py deleted file mode 100644 index eb4e2f9..0000000 --- a/convoy/convoy.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Speakeasy migration: hand-written HTTP API is deprecated; next major replaces this with OpenAPI-generated clients. Webhook verify stays hand-written.""" -from convoy.api import delivery_attempts, endpoint, event, event_delivery, project, source, subscription -from convoy.utils import webhook - -class Convoy(): - """Initializes the main Convoy Object. - - Parameters - ---------- - config : dict of config values, see example config below; - - config = { - #API Key used for bearer token authentication - "api_key": "", - #Convoy instance API base, e.g. https://us.getconvoy.cloud/api/v1 - "uri": "", - #Project ID from your project settings page - "project_id": "" - } - """ - def __init__(self, config): - self.delivery_attempt = delivery_attempts.DeliveryAttempt(config) - self.endpoint = endpoint.Endpoint(config) - self.event_delivery = event_delivery.EventDelivery(config) - self.event = event.Event(config) - self.project = project.Project(config) - self.source = source.Source(config) - self.subscription = subscription.Subscription(config) - self.webhook = webhook.Webhook() diff --git a/convoy/data.py b/convoy/data.py deleted file mode 100644 index bebb146..0000000 --- a/convoy/data.py +++ /dev/null @@ -1,53 +0,0 @@ -""" -You can use these dict objects to create your own data. -""" - -Config = { - #API Key used for bearer token authentication - "api_key": "", - #Convoy instance API base, e.g. https://us.getconvoy.cloud/api/v1 - "uri": "", - #Project ID from your project settings page - "project_id": "" - } - -NewEvent = { - "endpoint_id": "", - "event_type": "", - "data": {}, - } - -NewFanoutEvent = { - "owner_id": "", - "event_type": "", - "data": {}, - } - -NewBroadcastEvent = { - "event_type": "", - "data": {}, - } - -NewEndpoint = { - "name": "", - "url": "", - "description": "", - "secret": "", - "content_type": "", - } - -NewSubscription = { - "name": "", - "endpoint_id": "", - } - -# Body for event_delivery.forceresend; batchresend takes query filters only. -ForceResend = { - "ids": [] - } - -UpdateProject = { - "name": "", - "logo_url": "", - "config": {}, - } diff --git a/convoy/utils/__init__.py b/convoy/utils/__init__.py deleted file mode 100644 index 0546dfe..0000000 --- a/convoy/utils/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from convoy.utils.helpers import response_helper -from convoy.utils.helpers import verify_signature -from convoy.utils.helpers import hash_string \ No newline at end of file diff --git a/convoy/utils/helpers.py b/convoy/utils/helpers.py deleted file mode 100644 index 9195240..0000000 --- a/convoy/utils/helpers.py +++ /dev/null @@ -1,50 +0,0 @@ -from logging import exception -import requests -import hmac -import hashlib -import base64 - -def response_helper(e): - if isinstance(e, requests.exceptions.ConnectionError): - raise Exception("Error Connecting: %s" % e) - if isinstance(e, requests.exceptions.Timeout): - raise Exception("Timeout Error: %s" % e) - raise Exception(e) - -def verify_signature(algorithm, hmac, payload, secret): - """ - algorithm: hash algorithm e.g SHA256 - hmac: the signed payload - payload: the unsigned payload - secret: secret that was used for hashing - """ - dec = hash_string(algorithm, payload, secret) - return dec == hash - -def hash_string(algorithm, msg, secret): - alg = get_hash_function(str.lower(algorithm)) - dig = hmac.new(bytes(secret, "utf-8"), msg=bytes(msg, "utf-8"), digestmod=alg).digest() - dec = base64.b64encode(dig).decode() - return dec - -def get_hash_function(hash): - if str.lower(hash) == "sha256": - return hashlib.sha256 - if str.lower(hash) == "md5": - return hashlib.md5 - if str.lower(hash) == "sha384": - return hashlib.sha384 - if str.lower(hash) == "sha224": - return hashlib.sha224 - if str.lower(hash) == "sha512": - return hashlib.sha512 - if str.lower(hash) == "sha1": - return hashlib.sha1 - if str.lower(hash) == "sha3_256": - return hashlib.sha3_256 - if str.lower(hash) == "sha3_224": - return hashlib.sha3_224 - if str.lower(hash) == "sha3_512": - return hashlib.sha3_512 - else: - raise Exception("algorithm not available.") \ No newline at end of file diff --git a/convoy/utils/webhook.py b/src/convoy/utils/webhook.py similarity index 100% rename from convoy/utils/webhook.py rename to src/convoy/utils/webhook.py diff --git a/test/test_client.py b/test/test_client.py deleted file mode 100644 index deff433..0000000 --- a/test/test_client.py +++ /dev/null @@ -1,60 +0,0 @@ -import pytest - -from convoy import Convoy -from convoy.client import Client - - -def make_config(**overrides): - config = { - "api_key": "test-api-key", - "uri": "https://us.getconvoy.cloud/api/v1", - "project_id": "test-project-id", - } - config.update(overrides) - return config - - -def test_client_builds_project_scoped_base_uri(): - client = Client(make_config()) - assert client.get_base_url() == "https://us.getconvoy.cloud/api/v1/projects/test-project-id" - - -def test_client_strips_trailing_slash_from_uri(): - client = Client(make_config(uri="https://us.getconvoy.cloud/api/v1/")) - assert client.get_base_url() == "https://us.getconvoy.cloud/api/v1/projects/test-project-id" - - -def test_client_builds_resource_paths(): - client = Client(make_config()) - assert client.build_path("/endpoints") == "https://us.getconvoy.cloud/api/v1/projects/test-project-id/endpoints" - assert client.build_path("") == "https://us.getconvoy.cloud/api/v1/projects/test-project-id" - - -def test_client_sets_bearer_authorization_header(): - client = Client(make_config()) - assert client.headers["Authorization"] == "Bearer test-api-key" - - -@pytest.mark.parametrize("missing", ["api_key", "uri", "project_id"]) -def test_client_requires_config_values(missing): - config = make_config() - config[missing] = "" - with pytest.raises(ValueError): - Client(config) - - -@pytest.mark.parametrize("uri", [ - "https://us.getconvoy.cloud/api/v1/projects/test-project-id", - "https://us.getconvoy.cloud/api/v1/projects", - "https://us.getconvoy.cloud/api/v1/projects/", -]) -def test_client_rejects_project_scoped_uri(uri): - with pytest.raises(ValueError): - Client(make_config(uri=uri)) - - -def test_convoy_exposes_project_scoped_resources(): - convoy = Convoy(make_config()) - for resource in ["project", "endpoint", "event", "event_delivery", "delivery_attempt", "source", "subscription", "webhook"]: - assert hasattr(convoy, resource) - assert not hasattr(convoy, "group") diff --git a/test/test_routes.py b/test/test_routes.py deleted file mode 100644 index 48f4db4..0000000 --- a/test/test_routes.py +++ /dev/null @@ -1,110 +0,0 @@ -import json - -import pytest - -from convoy import Convoy - -BASE = "https://us.getconvoy.cloud/api/v1/projects/test-project-id" - - -class FakeResponse: - status_code = 200 - - def json(self): - return {"status": True, "message": "ok", "data": None} - - -@pytest.fixture -def calls(monkeypatch): - """Capture every outgoing request the client makes.""" - captured = [] - - def recorder(method): - def fake(url, headers=None, params=None, data=None): - captured.append({ - "method": method, - "url": url, - "params": params, - "body": json.loads(data) if data else None, - }) - return FakeResponse() - return fake - - monkeypatch.setattr("requests.get", recorder("GET")) - monkeypatch.setattr("requests.post", recorder("POST")) - monkeypatch.setattr("requests.put", recorder("PUT")) - monkeypatch.setattr("requests.delete", recorder("DELETE")) - return captured - - -@pytest.fixture -def convoy(): - return Convoy({ - "api_key": "test-api-key", - "uri": "https://us.getconvoy.cloud/api/v1", - "project_id": "test-project-id", - }) - - -def test_batch_retry_posts_empty_body_with_query_filters(convoy, calls): - convoy.event_delivery.batchresend({"status": ["Failure"]}) - call = calls[0] - assert call["method"] == "POST" - assert call["url"] == BASE + "/eventdeliveries/batchretry" - assert call["params"] == {"status": ["Failure"]} - assert call["body"] == {} - - -def test_force_resend_posts_ids_body(convoy, calls): - convoy.event_delivery.forceresend({}, {"ids": ["ed-1", "ed-2"]}) - call = calls[0] - assert call["method"] == "POST" - assert call["url"] == BASE + "/eventdeliveries/forceresend" - assert call["body"] == {"ids": ["ed-1", "ed-2"]} - - -def test_endpoint_pause_uses_put_with_empty_body(convoy, calls): - convoy.endpoint.pause("ep-1") - call = calls[0] - assert call["method"] == "PUT" - assert call["url"] == BASE + "/endpoints/ep-1/pause" - assert call["body"] == {} - - -def test_endpoint_expire_secret_uses_put(convoy, calls): - convoy.endpoint.expire_secret("ep-1", {"expiration": 24}) - call = calls[0] - assert call["method"] == "PUT" - assert call["url"] == BASE + "/endpoints/ep-1/expire_secret" - assert call["body"] == {"expiration": 24} - - -def test_endpoint_find_targets_single_endpoint(convoy, calls): - convoy.endpoint.find("ep-1", {}) - assert calls[0]["url"] == BASE + "/endpoints/ep-1" - - -def test_event_fanout_posts_to_fanout(convoy, calls): - convoy.event.fanout({}, {"owner_id": "owner-1", "event_type": "x", "data": {}}) - call = calls[0] - assert call["method"] == "POST" - assert call["url"] == BASE + "/events/fanout" - - -def test_event_broadcast_posts_to_broadcast(convoy, calls): - convoy.event.broadcast({}, {"event_type": "x", "data": {}}) - call = calls[0] - assert call["method"] == "POST" - assert call["url"] == BASE + "/events/broadcast" - - -def test_event_replay_posts_to_replay(convoy, calls): - convoy.event.replay("ev-1") - call = calls[0] - assert call["method"] == "PUT" - assert call["url"] == BASE + "/events/ev-1/replay" - - -def test_project_find_targets_project_root(convoy, calls): - convoy.project.find({}) - assert calls[0]["url"] == BASE