From e03fd303dd3991cfa2dfd121fde1e1ed90d9c51c Mon Sep 17 00:00:00 2001 From: Jennifer Linnenberg Date: Mon, 5 Oct 2026 10:34:15 +0200 Subject: [PATCH 1/2] feat: restore SDK files --- .github/workflows/ci.yml | 53 ++ .github/workflows/integration.yml | 34 + .github/workflows/release.yml | 75 ++ .gitignore | 20 + .python-version | 1 + Makefile | 18 + README.md | 96 +++ api/openapi-api.yaml | 1129 +++++++++++++++++++++++++ api/openapi-dataplane.yaml | 297 +++++++ examples/01_publish_message.py | 143 ++++ examples/02_pull_and_ack.py | 174 ++++ examples/03_error_handling.py | 178 ++++ examples/README.md | 59 ++ pyproject.toml | 38 + src/pubsub_sdk_python/__init__.py | 76 ++ src/pubsub_sdk_python/auth.py | 208 +++++ src/pubsub_sdk_python/client.py | 82 ++ src/pubsub_sdk_python/errors.py | 37 + src/pubsub_sdk_python/models.py | 109 +++ src/pubsub_sdk_python/publisher.py | 308 +++++++ src/pubsub_sdk_python/retry.py | 233 +++++ src/pubsub_sdk_python/subscriber.py | 424 ++++++++++ tests/conftest.py | 100 +++ tests/integration/conftest.py | 558 ++++++++++++ tests/integration/test_integration.py | 271 ++++++ tests/test_auth.py | 172 ++++ tests/test_client.py | 72 ++ tests/test_models.py | 132 +++ tests/test_publisher.py | 326 +++++++ tests/test_retry.py | 313 +++++++ tests/test_subscriber.py | 409 +++++++++ uv.lock | 944 +++++++++++++++++++++ 32 files changed, 7089 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/integration.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 Makefile create mode 100644 README.md create mode 100644 api/openapi-api.yaml create mode 100644 api/openapi-dataplane.yaml create mode 100644 examples/01_publish_message.py create mode 100644 examples/02_pull_and_ack.py create mode 100644 examples/03_error_handling.py create mode 100644 examples/README.md create mode 100644 pyproject.toml create mode 100644 src/pubsub_sdk_python/__init__.py create mode 100644 src/pubsub_sdk_python/auth.py create mode 100644 src/pubsub_sdk_python/client.py create mode 100644 src/pubsub_sdk_python/errors.py create mode 100644 src/pubsub_sdk_python/models.py create mode 100644 src/pubsub_sdk_python/publisher.py create mode 100644 src/pubsub_sdk_python/retry.py create mode 100644 src/pubsub_sdk_python/subscriber.py create mode 100644 tests/conftest.py create mode 100644 tests/integration/conftest.py create mode 100644 tests/integration/test_integration.py create mode 100644 tests/test_auth.py create mode 100644 tests/test_client.py create mode 100644 tests/test_models.py create mode 100644 tests/test_publisher.py create mode 100644 tests/test_retry.py create mode 100644 tests/test_subscriber.py create mode 100644 uv.lock diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..31c8b4b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,53 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + lint: + name: Lint & Format + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up uv + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + python-version: "3.14" + enable-cache: true + + - name: Install dependencies + run: | + uv sync --group dev + uv pip install -e . + + - name: Run ruff check + run: uv run ruff check src/ tests/ + + - name: Run ruff format check + run: uv run ruff format --check src/ tests/ + + test: + name: Unit Tests + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up uv + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + python-version: "3.14" + enable-cache: true + + - name: Install dependencies + run: | + uv sync --group dev + uv pip install -e . + + - name: Run pytest with coverage + run: uv run pytest --cov=src/pubsub_sdk_python --cov-branch --cov-report=term-missing --cov-fail-under=85 diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml new file mode 100644 index 0000000..8da6d8d --- /dev/null +++ b/.github/workflows/integration.yml @@ -0,0 +1,34 @@ +name: Integration Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + integration: + name: Integration Tests + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up uv + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + python-version: "3.14" + enable-cache: true + + - name: Install dependencies + run: | + uv sync --group dev + uv pip install -e . + + - name: Run integration tests + env: + SA_KEY_B64: ${{ secrets.SA_KEY_B64 }} + PROJECT_ID: ${{ secrets.PROJECT_ID }} + TOKEN_URL: ${{ secrets.TOKEN_URL }} + REGION: ${{ secrets.REGION }} + run: uv run pytest tests/integration/ -v --timeout=700 --no-cov diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..1ae9e7e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,75 @@ +name: Release + +on: + release: + types: [published] + +jobs: + test: + name: Lint & Test + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up uv + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + python-version: "3.14" + enable-cache: true + + - name: Install dependencies + run: | + uv sync --group dev + uv pip install -e . + + - name: Run ruff check + run: uv run ruff check src/ tests/ + + - name: Run ruff format check + run: uv run ruff format --check src/ tests/ + + - name: Run pytest with coverage + run: uv run pytest --cov=src/pubsub_sdk_python --cov-branch --cov-report=term-missing --cov-fail-under=85 + + build: + name: Build distribution + needs: test + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up uv + uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + with: + python-version: "3.14" + enable-cache: true + + - name: Build sdist and wheel + run: uv build + + - name: Upload distribution artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: dist + path: dist/ + + publish: + name: Publish to PyPI + needs: [test, build] + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/project/pubsub-sdk-python/ + permissions: + id-token: write + steps: + - name: Download distribution artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: dist + path: dist/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7df7104 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv + +# Idea +.idea + +# Misc +*.rst +.coverage + +# Service account keys +*.json \ No newline at end of file diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..075bf9e --- /dev/null +++ b/Makefile @@ -0,0 +1,18 @@ +install: + uv sync --group dev + uv pip install -e . + +lint: + uvx ruff@0.16.4 check + +format: + uvx ruff@0.16.4 format + +check: lint format + uvx ruff@0.16.4 format --check + +test: check install + uv run pytest + +test-cov: check install + uv run pytest --cov=src/pubsub_sdk_python --cov-branch --cov-report=term-missing --cov-fail-under=85 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e306b32 --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +# pubsub-sdk-python + +Python SDK for the **STACKIT PubSub Data Plane API**. + +This SDK covers the **data plane only** (publish, pull, ack, nack). Creating/deleting topics and subscriptions, and granting publisher/subscriber access, is done via the separate STACKIT PubSub **control plane** REST API (see `tests/integration/conftest.py` for example calls) or the STACKIT Portal. + +## Install + +Not published to PyPI yet — every GitHub Release publishes `pubsub-sdk-python` to PyPI automatically (see `.github/workflows/release.yml`), so once a release is cut: + +```bash +pip install pubsub-sdk-python +# or: uv add pubsub-sdk-python +``` + +Until then, install from source with [uv](https://docs.astral.sh/uv/): + +```bash +uv sync --group dev +uv pip install -e . +``` + +Requires Python >= 3.11. Runtime dependencies: `httpx`, `stackit-authorization`. + +## Auth + +Authentication goes through `AuthConfig` + `StackitAuthProvider`, backed by a STACKIT service account key. + +```python +from pubsub_sdk_python import AuthConfig, PubSubClient + +auth_config = AuthConfig( + service_account_key=open("sa-key.json").read(), # or the raw JSON string + token_endpoint="https://authorization.stackit.cloud/auth/token", # default shown +) +``` + +`AuthConfig` fields: `service_account_key`, `token_endpoint` (default `https://authorization.stackit.cloud/auth/token`), `client_id`, `client_secret`, `scope` (default `"dataplane"`). Tokens are fetched via `stackit-authorization` and cached until ~60s before expiry. + +## Quickstart + +```python +import asyncio +from pubsub_sdk_python import PubSubClient, PublishMessage + + +async def main(): + client = PubSubClient(topic_id="", auth_config=auth_config) + + async with client.get_publisher() as publisher: + response = await publisher.publish([PublishMessage.from_plain("hello")]) + print(response.message_ids) + + async with client.get_subscriber(subscription_id="") as subscriber: + messages = await subscriber.pull(max_messages=10) + for msg in messages: + print(msg.text) # base64-decoded payload + await subscriber.ack([msg.ack_id for msg in messages]) + # or: await subscriber.ack_messages(messages) + + +asyncio.run(main()) +``` + +`client.base_url` defaults to `https://{topic_id}.pubsub.eu01.onstackit.cloud`; pass `base_url=` to `PubSubClient(...)` to override (e.g. for other regions/environments). + +`PublishMessage.from_plain(str)` / `.from_bytes(bytes)` base64-encode the payload. `Message.payload` (bytes) / `Message.text` (str) base64-decode it back. + +Both `PubSubPublisher` and `PubSubSubscriber` support `async with`, and expose call counters: `publisher.messages_sent` / `.publish_calls` / `.purge_calls` / `.messages_purged`, `subscriber.messages_received` / `.messages_acked` / `.messages_nacked` / `.pull_calls` / `.ack_calls` / `.nack_calls`. + +Other methods: `publisher.publish_batch(messages, batch_size=100)`, `publisher.purge_topic()` / `.purge_topic_with_confirm("purge")`, `subscriber.pull_stream(on_message=callback)`, `subscriber.nack(...)` / `.nack_messages(...)`. + +## Errors + +All exceptions subclass `PubSubError`: + +| Exception | Raised when | +|-----------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `PubSubValidationError` | Bad request (HTTP 400/413), or local validation (empty message list, >100 messages, `max_messages`/`long_pull_duration_ms` out of range, empty ack/nack list) | +| `PubSubNotFoundError` | HTTP 404 | +| `PubSubAuthenticationError` | HTTP 401/403, or token acquisition failure | +| `PubSubServerError` | HTTP 5xx (transient - triggers a retry, see below) | +| `PubSubRetryError` | All retry attempts exhausted; `.causes` holds the underlying exceptions | +| `PubSubTimeoutError` | Request timed out (also a `TimeoutError`) | + +## Retries + +`PubSubPublisher`/`PubSubSubscriber` retry automatically on network/timeout errors (`httpx.TimeoutException`, `httpx.NetworkError`, `OSError`, `ConnectionError`, `TimeoutError`) and HTTP 500/502/503/504, with exponential backoff + jitter. Configure via `retry_config=RetryConfig(...)` passed to the publisher/subscriber constructor (or `get_publisher()`/`get_subscriber()` kwargs): + +```python +from pubsub_sdk_python import RetryConfig + +retry_config = RetryConfig(max_retries=3, base_delay=0.5, max_delay=30.0, multiplier=2.0) +``` + +400/401/404/413 are never retried. The standalone decorators `with_retry`/`with_async_retry` (in `pubsub_sdk_python.retry`) apply the same policy to arbitrary functions. diff --git a/api/openapi-api.yaml b/api/openapi-api.yaml new file mode 100644 index 0000000..3d9bebd --- /dev/null +++ b/api/openapi-api.yaml @@ -0,0 +1,1129 @@ +# For details see https://spec.openapis.org/oas/v3.0.3.html +openapi: 3.0.3 +info: + version: 1beta.0.0 + title: 'STACKIT PubSub API' + description: | + This API provides endpoints for managing PubSubs. + +# For details see https://developers.stackit.schwarz/domains/core-platform/api-management/api-gateway/features/annotations/#x-stackit-api +x-stackit-api: + type: stackit +# For details see https://developers.stackit.schwarz/domains/core-platform/api-management/api-gateway/features/annotations/#x-stackit-scope +x-stackit-scope: + visibility: public + show-as-global: true +# For details see https://developers.stackit.schwarz/domains/core-platform/api-management/api-gateway/features/annotations/#x-stackit-sdk +x-stackit-sdk: + include: true +# For details see https://developers.stackit.schwarz/domains/core-platform/api-management/api-gateway/features/annotations/#x-stackit-cors +x-stackit-cors: + allow_origins_default: + - portal + - backoffice +# For details see https://developers.stackit.schwarz/domains/core-platform/api-management/api-gateway/features/annotations/#x-stackit-portal +x-stackit-portal: + cors: true +# For details see https://developers.stackit.schwarz/domains/core-platform/api-management/api-gateway/features/annotations/#x-stackit-signer +x-stackit-signer: + audience: + - pubsub.stackit.cloud +x-stackit-ratelimit: + rate: 1m + limit: 200 + +servers: + - url: https://api.pubsub.eu01.dev.stackit.cloud + description: Development endpoint in eu01. + # For details see https://developers.stackit.schwarz/domains/core-platform/api-management/api-gateway/features/annotations/#x-stackit-stage + x-stackit-stage: dev-eu01 + - url: https://api.pubsub.eu01.qa.stackit.cloud + description: QA endpoint in eu01. + # For details see https://developers.stackit.schwarz/domains/core-platform/api-management/api-gateway/features/annotations/#x-stackit-stage + x-stackit-stage: qa-eu01 + - url: https://api.pubsub.eu01.stackit.cloud + description: Production endpoint in eu01. + # For details see https://developers.stackit.schwarz/domains/core-platform/api-management/api-gateway/features/annotations/#x-stackit-stage + x-stackit-stage: prd-eu01 + +tags: + - name: topics + description: Operations related to topics. + - name: subscriptions + description: Operations related to subscriptions. + +paths: + /v1beta/projects/{projectId}/regions/{regionId}/topics: + parameters: + - $ref: '#/components/parameters/projectId' + - $ref: '#/components/parameters/regionId' + get: + operationId: list-topics + description: Returns a list of all topics within the PubSub project. + parameters: + - $ref: '#/components/parameters/pageToken' + - $ref: '#/components/parameters/pageSize' + - $ref: '#/components/parameters/query' + tags: + - topics + responses: + '200': + description: Successful response with all topics. + content: + application/json: + schema: + $ref: '#/components/schemas/listTopicsResponse' + '400': + $ref: '#/components/responses/badRequest' + '401': + $ref: '#/components/responses/unauthorized' + '403': + $ref: '#/components/responses/forbidden' + '404': + $ref: '#/components/responses/notFound' + '429': + $ref: '#/components/responses/tooManyRequests' + '500': + $ref: '#/components/responses/internalServerError' + # For details see https://developers.stackit.schwarz/domains/core-platform/api-management/api-gateway/features/annotations/#x-stackit-authorization + x-stackit-authorization: + resource-id-type: dynamic + resource-id: projectId + resource-type: project + actions: + - pubsub.topic.list + post: + operationId: create-topic + description: Creates a new topic within the PubSub project. + tags: + - topics + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/createTopicRequest' + responses: + '202': + description: New topic will be created. + content: + application/json: + schema: + $ref: '#/components/schemas/topic' + '400': + $ref: '#/components/responses/badRequest' + '401': + $ref: '#/components/responses/unauthorized' + '403': + $ref: '#/components/responses/forbidden' + '404': + $ref: '#/components/responses/notFound' + '409': + $ref: '#/components/responses/conflict' + '429': + $ref: '#/components/responses/tooManyRequests' + '500': + $ref: '#/components/responses/internalServerError' + # For details see https://developers.stackit.schwarz/domains/core-platform/api-management/api-gateway/features/annotations/#x-stackit-authorization + x-stackit-authorization: + resource-id-type: dynamic + resource-id: projectId + resource-type: project + actions: + - pubsub.topic.create + /v1beta/projects/{projectId}/regions/{regionId}/topics/{topicId}: + parameters: + - $ref: '#/components/parameters/projectId' + - $ref: '#/components/parameters/regionId' + - $ref: '#/components/parameters/topicId' + get: + operationId: get-topic + description: Returns the details for the given topic. + tags: + - topics + responses: + '200': + description: Topic details successfully retrieved. + content: + application/json: + schema: + $ref: '#/components/schemas/topic' + '400': + $ref: '#/components/responses/badRequest' + '401': + $ref: '#/components/responses/unauthorized' + '403': + $ref: '#/components/responses/forbidden' + '404': + $ref: '#/components/responses/notFound' + '429': + $ref: '#/components/responses/tooManyRequests' + '500': + $ref: '#/components/responses/internalServerError' + # For details see https://developers.stackit.schwarz/domains/core-platform/api-management/api-gateway/features/annotations/#x-stackit-authorization + x-stackit-authorization: + resource-id-type: dynamic + resource-id: projectId + resource-type: project + actions: + - pubsub.topic.get + delete: + operationId: delete-topic + description: Deletes the given topic. + parameters: + - $ref: '#/components/parameters/force' + tags: + - topics + responses: + '202': + description: Topic will be deleted. + '400': + $ref: '#/components/responses/badRequest' + '401': + $ref: '#/components/responses/unauthorized' + '403': + $ref: '#/components/responses/forbidden' + '404': + $ref: '#/components/responses/notFound' + '422': + $ref: '#/components/responses/unprocessable' + '429': + $ref: '#/components/responses/tooManyRequests' + '500': + $ref: '#/components/responses/internalServerError' + # For details see https://developers.stackit.schwarz/domains/core-platform/api-management/api-gateway/features/annotations/#x-stackit-authorization + x-stackit-authorization: + resource-id-type: dynamic + resource-id: projectId + resource-type: project + actions: + - pubsub.topic.delete + patch: + operationId: update-topic + description: Will update the given topic. + tags: + - topics + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/updateTopicRequest' + responses: + '202': + description: Topic will be updated. + '400': + $ref: '#/components/responses/badRequest' + '401': + $ref: '#/components/responses/unauthorized' + '403': + $ref: '#/components/responses/forbidden' + '404': + $ref: '#/components/responses/notFound' + '409': + $ref: '#/components/responses/conflict' + '422': + $ref: '#/components/responses/unprocessable' + '429': + $ref: '#/components/responses/tooManyRequests' + '500': + $ref: '#/components/responses/internalServerError' + # For details see https://developers.stackit.schwarz/domains/core-platform/api-management/api-gateway/features/annotations/#x-stackit-authorization + x-stackit-authorization: + resource-id-type: dynamic + resource-id: projectId + resource-type: project + actions: + - pubsub.topic.update + + /v1beta/projects/{projectId}/regions/{regionId}/topics/{topicId}/publishers: + parameters: + - $ref: '#/components/parameters/projectId' + - $ref: '#/components/parameters/regionId' + - $ref: '#/components/parameters/topicId' + get: + operationId: list-topic-publishers + description: Returns a list of emailAddresses that are allowed to publish to the given topic. + tags: + - topics + responses: + '200': + description: Successful response with a list of all emailAddresses that are allowed to publish to the given topic. + content: + application/json: + schema: + $ref: '#/components/schemas/listEmailAddressesResponse' + '400': + $ref: '#/components/responses/badRequest' + '401': + $ref: '#/components/responses/unauthorized' + '403': + $ref: '#/components/responses/forbidden' + '404': + $ref: '#/components/responses/notFound' + '429': + $ref: '#/components/responses/tooManyRequests' + '500': + $ref: '#/components/responses/internalServerError' + x-stackit-authorization: + resource-id-type: dynamic + resource-id: projectId + resource-type: project + actions: + - pubsub.topic.publisher.list + patch: + operationId: grant-topic-publisher-access + description: Grants an emailAddress access to publish to the given topic. + tags: + - topics + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/addEmailAddressRequest' + responses: + '202': + description: Access to publish to the given topic will be granted. + '400': + $ref: '#/components/responses/badRequest' + '401': + $ref: '#/components/responses/unauthorized' + '403': + $ref: '#/components/responses/forbidden' + '404': + $ref: '#/components/responses/notFound' + '422': + $ref: '#/components/responses/unprocessable' + '429': + $ref: '#/components/responses/tooManyRequests' + '500': + $ref: '#/components/responses/internalServerError' + x-stackit-authorization: + resource-id-type: dynamic + resource-id: projectId + resource-type: project + actions: + - pubsub.topic.publisher.add + put: + operationId: update-publisher-access + description: Updates the access list of email-addresses to publish to the given topic. + tags: + - topics + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/updateEmailAddressesRequest' + responses: + '202': + description: Access to publish to the given topic will be granted. + '400': + $ref: '#/components/responses/badRequest' + '401': + $ref: '#/components/responses/unauthorized' + '403': + $ref: '#/components/responses/forbidden' + '404': + $ref: '#/components/responses/notFound' + '422': + $ref: '#/components/responses/unprocessable' + '429': + $ref: '#/components/responses/tooManyRequests' + '500': + $ref: '#/components/responses/internalServerError' + x-stackit-authorization: + resource-id-type: dynamic + resource-id: projectId + resource-type: project + actions: + - pubsub.topic.publisher.update + + delete: + operationId: revoke-topic-publisher-access + description: Revokes all email-addresses access to publish to the given topic. + tags: + - topics + responses: + '202': + description: Access to publish to the given topic will be revoked. + '400': + $ref: '#/components/responses/badRequest' + '401': + $ref: '#/components/responses/unauthorized' + '403': + $ref: '#/components/responses/forbidden' + '404': + $ref: '#/components/responses/notFound' + '422': + $ref: '#/components/responses/unprocessable' + '429': + $ref: '#/components/responses/tooManyRequests' + '500': + $ref: '#/components/responses/internalServerError' + x-stackit-authorization: + resource-id-type: dynamic + resource-id: projectId + resource-type: project + actions: + - pubsub.topic.publisher.remove + + /v1beta/projects/{projectId}/regions/{regionId}/topics/{topicId}/subscriptions: + parameters: + - $ref: '#/components/parameters/projectId' + - $ref: '#/components/parameters/regionId' + - $ref: '#/components/parameters/topicId' + get: + operationId: list-subscriptions + description: Returns a list of all topic subscriptions. + parameters: + - $ref: '#/components/parameters/pageToken' + - $ref: '#/components/parameters/pageSize' + - $ref: '#/components/parameters/query' + tags: + - subscriptions + responses: + '200': + description: Successful response with all subscriptions. + content: + application/json: + schema: + $ref: '#/components/schemas/listSubscriptionsResponse' + '400': + $ref: '#/components/responses/badRequest' + '401': + $ref: '#/components/responses/unauthorized' + '403': + $ref: '#/components/responses/forbidden' + '404': + $ref: '#/components/responses/notFound' + '429': + $ref: '#/components/responses/tooManyRequests' + '500': + $ref: '#/components/responses/internalServerError' + # For details see https://developers.stackit.schwarz/domains/core-platform/api-management/api-gateway/features/annotations/#x-stackit-authorization + x-stackit-authorization: + resource-id-type: dynamic + resource-id: projectId + resource-type: project + actions: + - pubsub.subscription.list + post: + operationId: create-subscription + description: Creates a new subscription for a topic within the PubSub project. + tags: + - subscriptions + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/createSubscriptionRequest' + responses: + '202': + description: Subscription will be created. + content: + application/json: + schema: + $ref: '#/components/schemas/subscription' + '400': + $ref: '#/components/responses/badRequest' + '401': + $ref: '#/components/responses/unauthorized' + '403': + $ref: '#/components/responses/forbidden' + '404': + $ref: '#/components/responses/notFound' + '409': + $ref: '#/components/responses/conflict' + '422': + $ref: '#/components/responses/unprocessable' + '429': + $ref: '#/components/responses/tooManyRequests' + '500': + $ref: '#/components/responses/internalServerError' + # For details see https://developers.stackit.schwarz/domains/core-platform/api-management/api-gateway/features/annotations/#x-stackit-authorization + x-stackit-authorization: + resource-id-type: dynamic + resource-id: projectId + resource-type: project + actions: + - pubsub.subscription.create + + /v1beta/projects/{projectId}/regions/{regionId}/topics/{topicId}/subscriptions/{subscriptionId}: + parameters: + - $ref: '#/components/parameters/projectId' + - $ref: '#/components/parameters/regionId' + - $ref: '#/components/parameters/topicId' + - $ref: '#/components/parameters/subscriptionId' + get: + operationId: get-subscription + description: Returns the details for the given subscription. + tags: + - subscriptions + responses: + '200': + description: Subscription details successfully retrieved. + content: + application/json: + schema: + $ref: '#/components/schemas/subscription' + '400': + $ref: '#/components/responses/badRequest' + '401': + $ref: '#/components/responses/unauthorized' + '403': + $ref: '#/components/responses/forbidden' + '404': + $ref: '#/components/responses/notFound' + '429': + $ref: '#/components/responses/tooManyRequests' + '500': + $ref: '#/components/responses/internalServerError' + # For details see https://developers.stackit.schwarz/domains/core-platform/api-management/api-gateway/features/annotations/#x-stackit-authorization + x-stackit-authorization: + resource-id-type: dynamic + resource-id: projectId + resource-type: project + actions: + - pubsub.subscription.get + delete: + operationId: delete-subscription + description: Deletes the given subscription. + parameters: + - $ref: '#/components/parameters/force' + tags: + - subscriptions + responses: + '202': + description: Subscription will be deleted. + '400': + $ref: '#/components/responses/badRequest' + '401': + $ref: '#/components/responses/unauthorized' + '403': + $ref: '#/components/responses/forbidden' + '404': + $ref: '#/components/responses/notFound' + '409': + $ref: '#/components/responses/conflict' + '429': + $ref: '#/components/responses/tooManyRequests' + '500': + $ref: '#/components/responses/internalServerError' + # For details see https://developers.stackit.schwarz/domains/core-platform/api-management/api-gateway/features/annotations/#x-stackit-authorization + x-stackit-authorization: + resource-id-type: dynamic + resource-id: projectId + resource-type: project + actions: + - pubsub.subscription.delete + patch: + operationId: update-subscription + description: Will update the given subscription. + tags: + - subscriptions + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/updateSubscriptionRequest' + responses: + '202': + description: Subscription will be updated. + '400': + $ref: '#/components/responses/badRequest' + '401': + $ref: '#/components/responses/unauthorized' + '403': + $ref: '#/components/responses/forbidden' + '404': + $ref: '#/components/responses/notFound' + '409': + $ref: '#/components/responses/conflict' + '422': + $ref: '#/components/responses/unprocessable' + '429': + $ref: '#/components/responses/tooManyRequests' + '500': + $ref: '#/components/responses/internalServerError' + # For details see https://developers.stackit.schwarz/domains/core-platform/api-management/api-gateway/features/annotations/#x-stackit-authorization + x-stackit-authorization: + resource-id-type: dynamic + resource-id: projectId + resource-type: project + actions: + - pubsub.subscription.update + + /v1beta/projects/{projectId}/regions/{regionId}/topics/{topicId}/subscriptions/{subscriptionId}/subscribers: + parameters: + - $ref: '#/components/parameters/projectId' + - $ref: '#/components/parameters/regionId' + - $ref: '#/components/parameters/topicId' + - $ref: '#/components/parameters/subscriptionId' + get: + operationId: list-subscription-subscribers + description: Returns a list of emailAddresses that are allowed to pull from the given subscription. + tags: + - subscriptions + responses: + '200': + description: Successful response with a list of all emailAddresses that are allowed to pull from the given subscription. + content: + application/json: + schema: + $ref: '#/components/schemas/listEmailAddressesResponse' + '400': + $ref: '#/components/responses/badRequest' + '401': + $ref: '#/components/responses/unauthorized' + '403': + $ref: '#/components/responses/forbidden' + '404': + $ref: '#/components/responses/notFound' + '429': + $ref: '#/components/responses/tooManyRequests' + '500': + $ref: '#/components/responses/internalServerError' + x-stackit-authorization: + resource-id-type: dynamic + resource-id: projectId + resource-type: project + actions: + - pubsub.subscription.subscriber.list + patch: + operationId: grant-subscription-subscriber-access + description: Grants an emailAddress access to pull from the given subscription. + tags: + - subscriptions + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/addEmailAddressRequest' + responses: + '202': + description: Access to pull from the given subscription will be granted. + '400': + $ref: '#/components/responses/badRequest' + '401': + $ref: '#/components/responses/unauthorized' + '403': + $ref: '#/components/responses/forbidden' + '404': + $ref: '#/components/responses/notFound' + '422': + $ref: '#/components/responses/unprocessable' + '429': + $ref: '#/components/responses/tooManyRequests' + '500': + $ref: '#/components/responses/internalServerError' + x-stackit-authorization: + resource-id-type: dynamic + resource-id: projectId + resource-type: project + actions: + - pubsub.subscription.subscriber.add + put: + operationId: update-subscription-subscriber-access + description: Updates the access list of emailAddresses to pull from the given subscription. + tags: + - subscriptions + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/updateEmailAddressesRequest' + responses: + '202': + description: Access to pull from the given Subscription will be granted. + '400': + $ref: '#/components/responses/badRequest' + '401': + $ref: '#/components/responses/unauthorized' + '403': + $ref: '#/components/responses/forbidden' + '404': + $ref: '#/components/responses/notFound' + '422': + $ref: '#/components/responses/unprocessable' + '429': + $ref: '#/components/responses/tooManyRequests' + '500': + $ref: '#/components/responses/internalServerError' + x-stackit-authorization: + resource-id-type: dynamic + resource-id: projectId + resource-type: project + actions: + - pubsub.subscription.subscriber.update + delete: + operationId: revoke-subscription-subscriber-access + description: Revokes all email-addresses access to pull from the given subscription. + tags: + - subscriptions + responses: + '202': + description: Access to pull from the given subscription will be revoked. + '400': + $ref: '#/components/responses/badRequest' + '401': + $ref: '#/components/responses/unauthorized' + '403': + $ref: '#/components/responses/forbidden' + '404': + $ref: '#/components/responses/notFound' + '422': + $ref: '#/components/responses/unprocessable' + '429': + $ref: '#/components/responses/tooManyRequests' + '500': + $ref: '#/components/responses/internalServerError' + x-stackit-authorization: + resource-id-type: dynamic + resource-id: projectId + resource-type: project + actions: + - pubsub.subscription.subscriber.remove + +components: + parameters: + projectId: + name: projectId + description: The STACKIT portal project UUID the resource is located in. + schema: + type: string + format: uuid + minLength: 36 + maxLength: 36 + in: path + required: true + regionId: + name: regionId + description: The STACKIT region name the resource is located in. + schema: + type: string + enum: + - eu01 + in: path + required: true + topicId: + name: topicId + description: The topic UUID. + schema: + type: string + format: uuid + minLength: 36 + maxLength: 36 + in: path + required: true + subscriptionId: + name: subscriptionId + description: The subscription UUID. + schema: + type: string + format: uuid + minLength: 36 + maxLength: 36 + in: path + required: true + pageToken: + name: pageToken + in: query + description: A token to retrieve the next page of results. + required: false + allowEmptyValue: true + schema: + type: string + maxLength: 1024 + pageSize: + name: pageSize + in: query + description: Maximum number of items to return + required: false + schema: + type: integer + format: int32 + default: 10 + minimum: 1 + maximum: 100 + force: + name: force + in: query + required: false + schema: + type: boolean + default: false + description: | + If true, deletes the topic and all its active subscriptions (cascading delete). + If false (default), the operation returns a 409 conflict if active subscriptions exist. + query: + name: query + in: query + description: Supply a query string to filter the results. + required: false + allowEmptyValue: true + schema: + type: string + maximum: 1024 + responses: + badRequest: # HTTP 400 + description: Bad Request. The data provided by the client did not match expectations. + unauthorized: # HTTP 401 + description: Unauthorized. You may have provided no or wrong credentials. + forbidden: # HTTP 403 + description: Forbidden. You are authenticated but do not have sufficient permissions to perform this action. + notFound: # HTTP 404 + description: Not found. The resource has not been found. + conflict: # HTTP 409 + description: Conflict. The resource has been modified by some other client. Please try again. + unprocessable: # HTTP 422: + description: Unprocessable Entity. The request cannot be executed. + tooManyRequests: # HTTP 429 + description: Too Many Requests. You have exceeded the rate limits. + internalServerError: # HTTP 500 + description: Internal server error. The application experienced some unexpected situation. + + schemas: + topic: + # For details see https://developers.stackit.schwarz/domains/core-platform/api-management/api-gateway/features/annotations/#x-stackit-sdk + x-stackit-sdk: + schema-name: topicResponse + required: + - id + - displayName + - state + - createTime + properties: + id: + type: string + format: uuid + minLength: 36 + maxLength: 36 + description: An auto generated unique id which identifies the resource. + displayName: + type: string + description: The display name is a short name chosen by the user to identify the resource. + minLength: 1 + maxLength: 36 + pattern: '^[a-zA-Z0-9 -]*$' + description: + type: string + description: The description is a longer text chosen by the user to provide more context for the resource. + # NOTE: This maxLength is a protection against payloads which might not have an upper bound. + maxLength: 1024 + settings: + $ref: '#/components/schemas/topicSettings' + labels: + $ref: '#/components/schemas/labels' + state: + type: string + description: The current state of the resource. + enum: + - reconciling + - active + - deleting + createTime: + type: string + format: date-time + maxLength: 128 + description: The point in time the resource was created. + uri: + type: string + description: The URI for reaching the resource. + # NOTE: This maxLength is a protection against payloads which might not have an upper bound. + maxLength: 1024 + + topicSettings: + description: Configuration settings for the topic. + type: object + properties: + retention: + type: string + description: | + The maximum length of time a message is persisted in storage. + Messages older than this duration are automatically discarded/deleted. + + **Format:** + Must be a scalar duration: a number (integer or decimal) followed by a unit ('s', 'm', or 'h'). + + **Constraints:** + - **Minimum:** "1s" + - **Maximum:** "168h" (7 days) + pattern: '^[0-9]+(\.[0-9]+)?(s|m|h)$' + example: "24h" + default: "168h" + acl: + type: array + description: Whitelist of IPs in CIDR notation that can access the topic and its underlying subscriptions. + items: + type: string + format: cidr + example: "192.168.1.0/24" + maxItems: 100 + default: + - "0.0.0.0/0" + example: + - "192.168.1.0/24" + - "192.168.2.0/24" + metrics: + type: boolean + description: Enables or disables metrics collection for the topic. + default: false + serviceLogs: + type: boolean + description: Enables or disables sending of service logs for the topic. + default: false + + labels: + description: | + Labels are key-value pairs used to group and organize subsets of resources. + + **Key constraints:** + - May contain an optional domain prefix separated by a slash (`/`). + - The domain prefix must be less than or equal to 250 characters and a valid DNS subdomain containing only lowercase alphanumerics (`[a-z0-9]`) and dashes (`-`), separated by dots (`.`). + - The name part (excluding the domain prefix) must be between 1 and 63 characters long. + - The name part must begin and end with an alphanumeric character (`[a-z0-9A-Z]`). + - The name part may contain dashes (`-`), underscores (`_`), dots (`.`), and alphanumerics in between. + - Keys starting with the prefix `stackit-` or having a domain prefix of `stackit.cloud` (including its subdomains, e.g., `*.stackit.cloud/`) are reserved for system use. + + **Value constraints:** + - Must be less than or equal to 63 characters long (can be empty). + - Must begin and end with an alphanumeric character (`[a-z0-9A-Z]`) if not empty. + - May contain dashes (`-`), underscores (`_`), dots (`.`), and alphanumerics in between. + + **Count constraint:** + - A maximum of 64 labels can be applied per resource. Restricted labels are excluded from this count. + type: object + maxProperties: 64 + additionalProperties: + type: string + nullable: true + maxLength: 63 + example: + environment: "production" + project: "PubSub" + empty-value-label: "" + + listTopicsResponse: + required: + - topics + properties: + topics: + type: array + maxLength: 100 + items: + $ref: '#/components/schemas/topic' + nextPageToken: + type: string + maxLength: 1024 + description: A token to retrieve the next page of results. + + createTopicRequest: + # For details see https://developers.stackit.schwarz/domains/core-platform/api-management/api-gateway/features/annotations/#x-stackit-sdk + x-stackit-sdk: + schema-name: createTopicPayload + required: + - displayName + properties: + displayName: + type: string + description: The display name is a short name chosen by the user to identify the resource. + minLength: 1 + maxLength: 36 + pattern: '^[a-zA-Z0-9 -]*$' + description: + type: string + description: The description is a longer text chosen by the user to provide more context for the resource. + # NOTE: This maxLength is a protection against payloads which might not have an upper bound. + maxLength: 1024 + settings: + $ref: '#/components/schemas/topicSettings' + labels: + $ref: '#/components/schemas/labels' + + updateTopicRequest: + # For details see https://developers.stackit.schwarz/domains/core-platform/api-management/api-gateway/features/annotations/#x-stackit-sdk + x-stackit-sdk: + schema-name: updateTopicPayload + properties: + displayName: + type: string + description: The display name is a short name chosen by the user to identify the resource. + minLength: 1 + maxLength: 36 + pattern: '^[a-zA-Z0-9 -]*$' + description: + type: string + description: The description is a longer text chosen by the user to provide more context for the resource. + # NOTE: This maxLength is a protection against payloads which might not have an upper bound. + maxLength: 1024 + settings: + $ref: '#/components/schemas/topicSettings' + labels: + $ref: '#/components/schemas/labels' + addEmailAddressRequest: + x-stackit-sdk: + schema-name: addEmailAddressPayload + required: + - emailAddress + properties: + emailAddress: + type: string + format: email + description: A single RFC 5322 compliant email address. + example: "jane.doe@example.com" + maxLength: 254 + updateEmailAddressesRequest: + x-stackit-sdk: + schema-name: updateEmailAddressesPayload + required: + - emailAddresses + properties: + emailAddresses: + type: array + description: A list of valid subscriber email addresses. + items: + type: string + format: email + description: A single RFC 5322 compliant email address. + example: "jane.doe@example.com" + subscription: + # For details see https://developers.stackit.schwarz/domains/core-platform/api-management/api-gateway/features/annotations/#x-stackit-sdk + x-stackit-sdk: + schema-name: subscriptionResponse + required: + - id + - displayName + - topicId + - state + - createTime + properties: + id: + type: string + format: uuid + description: An auto generated unique id which identifies the resource. + displayName: + type: string + description: The display name is a short name chosen by the user to identify the resource. + minLength: 1 + maxLength: 36 + pattern: '^[a-zA-Z0-9 -]*$' + topicId: + type: string + format: uuid + description: The id of the topic to which the subscription belongs. + description: + type: string + description: The description is a longer text chosen by the user to provide more context for the resource. + # NOTE: This maxLength is a protection against payloads which might not have an upper bound. + maxLength: 1024 + settings: + $ref: '#/components/schemas/subscriptionSettings' + labels: + $ref: '#/components/schemas/labels' + state: + type: string + description: The current state of the resource. + enum: + - reconciling + - active + - deleting + createTime: + type: string + format: date-time + description: The point in time the resource was created. + + subscriptionSettings: + description: Configuration settings for the subscription. + type: object + properties: + ackDeadline: + type: string + description: | + The maximum amount of time the system waits for a message acknowledgment. + If a message is not acknowledged within this window, it will be redelivered. + + **Format:** + Must be a duration string consisting of a number (integer or decimal) followed by a unit ('s', 'm', or 'h'). + + **Constraints:** + - **Minimum:** "1s" + - **Maximum:** "168h" (7 days) + pattern: '^[0-9]+(\.[0-9]+)?(s|m|h)$' + example: "60s" + default: "2m" + + listSubscriptionsResponse: + required: + - subscriptions + properties: + subscriptions: + type: array + maxLength: 100 + items: + $ref: '#/components/schemas/subscription' + nextPageToken: + type: string + maxLength: 1024 + description: A token to retrieve the next page of results. + + createSubscriptionRequest: + # For details see https://developers.stackit.schwarz/domains/core-platform/api-management/api-gateway/features/annotations/#x-stackit-sdk + x-stackit-sdk: + schema-name: createSubscriptionPayload + required: + - displayName + properties: + displayName: + type: string + description: The display name is a short name chosen by the user to identify the resource. + minLength: 1 + maxLength: 36 + pattern: '^[a-zA-Z0-9 -]*$' + description: + type: string + description: The description is a longer text chosen by the user to provide more context for the resource. + # NOTE: This maxLength is a protection against payloads which might not have an upper bound. + maxLength: 1024 + settings: + $ref: '#/components/schemas/subscriptionSettings' + labels: + $ref: '#/components/schemas/labels' + + updateSubscriptionRequest: + # For details see https://developers.stackit.schwarz/domains/core-platform/api-management/api-gateway/features/annotations/#x-stackit-sdk + x-stackit-sdk: + schema-name: updateSubscriptionPayload + properties: + displayName: + type: string + description: The display name is a short name chosen by the user to identify the resource. + minLength: 1 + maxLength: 36 + pattern: '^[a-zA-Z0-9 -]*$' + description: + type: string + description: The description is a longer text chosen by the user to provide more context for the resource. + # NOTE: This maxLength is a protection against payloads which might not have an upper bound. + maxLength: 1024 + settings: + $ref: '#/components/schemas/subscriptionSettings' + labels: + $ref: '#/components/schemas/labels' + + listEmailAddressesResponse: + required: + - emailAddresses + properties: + emailAddresses: + type: array + maxItems: 100 + items: + type: string + maxLength: 254 + description: Unique identifier of a user or service account. diff --git a/api/openapi-dataplane.yaml b/api/openapi-dataplane.yaml new file mode 100644 index 0000000..8b77b48 --- /dev/null +++ b/api/openapi-dataplane.yaml @@ -0,0 +1,297 @@ +# For details see https://spec.openapis.org/oas/v3.0.3.html +openapi: 3.0.3 +info: + version: 1alpha.0.0 + title: STACKIT PubSub Data Plane API + description: | + This API provides endpoints for managing PubSubs. + +servers: + - url: https://{topicId}.pubsub.eu01.onstackit.cloud + description: Production endpoint in eu01. + variables: + topicId: + description: The Topic UUID. + default: "00000000-0000-0000-0000-000000000000" + +paths: + /v1alpha/publish: + post: + operationId: publish-messages + description: Publish messages within a topic. + tags: + - topics + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/publishMessageToTopicRequest' + responses: + '200': + description: Messages have been published. + content: + application/json: + schema: + $ref: '#/components/schemas/publishMessageResponse' + '400': + $ref: '#/components/responses/badRequest' + '401': + $ref: '#/components/responses/unauthorized' + '404': + $ref: '#/components/responses/notFound' + '413': + $ref: '#/components/responses/requestEntityTooLarge' + '500': + $ref: '#/components/responses/internalServerError' + '502': + $ref: '#/components/responses/badGateway' + '503': + $ref: '#/components/responses/serviceUnavailable' + '504': + $ref: '#/components/responses/gatewayTimeout' + /v1alpha/purge: + post: + operationId: purge-topic + description: Purge all messages within a topic without deleting the topic itself. + tags: + - topics + responses: + '204': + description: Topic has been purged successfully. + '400': + $ref: '#/components/responses/badRequest' + '401': + $ref: '#/components/responses/unauthorized' + '404': + $ref: '#/components/responses/notFound' + '500': + $ref: '#/components/responses/internalServerError' + '502': + $ref: '#/components/responses/badGateway' + '503': + $ref: '#/components/responses/serviceUnavailable' + '504': + $ref: '#/components/responses/gatewayTimeout' + + /v1alpha/subscriptions/{subscriptionId}/pull: + parameters: + - $ref: '#/components/parameters/subscriptionId' + post: + operationId: pull-messages + description: Pull messages within a topic. + tags: + - subscriptions + parameters: + - name: PubSub-Max-Messages + in: header + description: The maximum number of messages to pull. + required: false + schema: + type: integer + format: int32 + minimum: 1 + maximum: 32 + default: 16 + - name: PubSub-Long-Pull-Duration + in: header + description: The maximum amount of milliseconds to keep an connection open, awaiting a new message on the server. + required: false + schema: + type: integer + format: int32 + minimum: 100 + maximum: 5000 + default: 5000 + responses: + '200': + description: Messages pulled successfully. + content: + application/json: + schema: + $ref: '#/components/schemas/messages' + '400': + $ref: '#/components/responses/badRequest' + '401': + $ref: '#/components/responses/unauthorized' + '404': + $ref: '#/components/responses/notFound' + '500': + $ref: '#/components/responses/internalServerError' + '502': + $ref: '#/components/responses/badGateway' + '503': + $ref: '#/components/responses/serviceUnavailable' + '504': + $ref: '#/components/responses/gatewayTimeout' + /v1alpha/subscriptions/{subscriptionId}/ack: + parameters: + - $ref: '#/components/parameters/subscriptionId' + post: + operationId: ack-messages + description: Acknowledge messages within a topic. + tags: + - subscriptions + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ackMessagesFromTopicRequest' + responses: + '204': + description: Messages have been acknowledged. + '400': + $ref: '#/components/responses/badRequest' + '401': + $ref: '#/components/responses/unauthorized' + '404': + $ref: '#/components/responses/notFound' + '500': + $ref: '#/components/responses/internalServerError' + '502': + $ref: '#/components/responses/badGateway' + '503': + $ref: '#/components/responses/serviceUnavailable' + '504': + $ref: '#/components/responses/gatewayTimeout' + /v1alpha/subscriptions/{subscriptionId}/nack: + parameters: + - $ref: '#/components/parameters/subscriptionId' + post: + operationId: nack-messages + description: Not acknowledging messages within a topic. + tags: + - subscriptions + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/nackMessagesFromTopicRequest' + responses: + '204': + description: Messages have been not acknowledged. + '400': + $ref: '#/components/responses/badRequest' + '401': + $ref: '#/components/responses/unauthorized' + '404': + $ref: '#/components/responses/notFound' + '500': + $ref: '#/components/responses/internalServerError' + '502': + $ref: '#/components/responses/badGateway' + '503': + $ref: '#/components/responses/serviceUnavailable' + '504': + $ref: '#/components/responses/gatewayTimeout' +components: + parameters: + subscriptionId: + name: subscriptionId + description: The Subscription UUID. + schema: + type: string + format: uuid + in: path + required: true + responses: + badRequest: # HTTP 400 + description: Bad Request. The data provided by the client did not match expectations. + unauthorized: # HTTP 401 + description: Unauthorized. You may have provided no or wrong credentials. + notFound: # HTTP 404 + description: Not found. The resource has not been found. + requestEntityTooLarge: # HTTP 413 + description: Request Entity Too Large. The payload size exceeds the maximum allowed limit. + internalServerError: # HTTP 500 + description: Internal server error. The application experienced some unexpected situation. + badGateway: # HTTP 502 + description: Bad Gateway. The server encountered an error while communicating with a backend service. + serviceUnavailable: # HTTP 503 + description: Service Unavailable. The service is temporarily unable to handle the request. + gatewayTimeout: # HTTP 504 + description: Gateway Timeout. The request to a backend service timed out. + schemas: + publishMessage: + required: + - data + properties: + data: + type: string + format: byte + description: Base64 encoded message payload. + publishMessageToTopicRequest: + required: + - messages + properties: + messages: + type: array + items: + $ref: '#/components/schemas/publishMessage' + description: The messages to publish. + publishMessageResponse: + required: + - messageIds + properties: + messageIds: + type: array + items: + type: integer + format: uint64 + description: A list of unique, increasing sequence numbers identifying the messages. + messages: + required: + - messages + properties: + messages: + type: array + items: + $ref: '#/components/schemas/message' + message: + required: + - id + - ackId + - data + - createTime + - deliveryAttempts + properties: + id: + type: integer + format: uint64 + description: A unique, increasing sequence number identifying the message within the topic. + ackId: + type: string + description: An auto generated unique id for acknowledging the message. + data: + type: string + format: byte + description: Base64 encoded messages. + createTime: + type: string + format: date-time + description: The point in time the resource was created. + deliveryAttempts: + type: integer + format: uint64 + minimum: 1 + description: Number of times the message has been tried to be delivered. + ackMessagesFromTopicRequest: + required: + - ackIds + properties: + ackIds: + type: array + items: + type: string + description: List of ids to acknowledge. + nackMessagesFromTopicRequest: + required: + - nackIds + properties: + nackIds: + type: array + items: + type: string + description: List of ids to not acknowledge. diff --git a/examples/01_publish_message.py b/examples/01_publish_message.py new file mode 100644 index 0000000..632b0a2 --- /dev/null +++ b/examples/01_publish_message.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Publish messages to a STACKIT PubSub topic. + +This example shows the standard SDK workflow: + +1. Build an ``AuthConfig`` from a STACKIT service account key file whose + path comes from an environment variable (never hardcode key material). +2. Instantiate ``PubSubClient`` and derive a ``PubSubPublisher`` from it. +3. Publish a small batch of messages and handle the SDK's specific + exception types individually. + +Prerequisites +------------- +Environment variables: + STACKIT_SERVICE_ACCOUNT_KEY_PATH Path to a STACKIT service account key + JSON file. + STACKIT_TOPIC_ID ID of an existing PubSub topic that + the service account may publish to. + +Optional environment variables: + STACKIT_TOKEN_ENDPOINT OAuth2 token endpoint override (defaults to the + SDK's built-in STACKIT authorization endpoint). + STACKIT_BASE_URL Dataplane base URL override. Defaults to + "https://{topic_id}.pubsub.eu01.onstackit.cloud". + +Obtaining a service account key +-------------------------------- +Create or rotate a service account key using the STACKIT Portal or the +STACKIT CLI (`stackit service-account key create ...`), then point +STACKIT_SERVICE_ACCOUNT_KEY_PATH at the downloaded JSON file. The key file +itself is never read, printed, or embedded by this script's source code - +only its filesystem path is taken from the environment. + +Run +--- + python examples/01_publish_message.py +""" + +from __future__ import annotations + +import asyncio +import os +import sys + +from pubsub_sdk_python import ( + AuthConfig, + PublishMessage, + PubSubAuthenticationError, + PubSubClient, + PubSubError, + PubSubRetryError, + PubSubValidationError, +) + +DEFAULT_TOKEN_ENDPOINT = "https://authorization.stackit.cloud/auth/token" +DEFAULT_BASE_URL = "https://{topicId}.pubsub.eu01.onstackit.cloud" + + +def _require_env(name: str) -> str: + """Return a required environment variable or exit with a clear message. + + Args: + name: Name of the environment variable to look up. + + Returns: + The environment variable's value. + """ + value = os.environ.get(name) + if not value: + print( + f"ERROR: required environment variable {name} is not set.\n" + "See the module docstring in this file for setup instructions.", + file=sys.stderr, + ) + sys.exit(1) + return value + + +def _build_auth_config() -> AuthConfig: + """Build an AuthConfig by reading the service account key from disk. + + The key file path is sourced from STACKIT_SERVICE_ACCOUNT_KEY_PATH + rather than being hardcoded, keeping credential material out of source + control. + + Returns: + A populated AuthConfig ready to use with PubSubClient. + """ + key_path = _require_env("STACKIT_SERVICE_ACCOUNT_KEY_PATH") + + try: + with open(key_path, encoding="utf-8") as key_file: + service_account_key = key_file.read() + except OSError as exc: + print(f"ERROR: could not read service account key at {key_path}: {exc}", file=sys.stderr) + sys.exit(1) + + token_endpoint = os.environ.get("STACKIT_TOKEN_ENDPOINT", DEFAULT_TOKEN_ENDPOINT) + return AuthConfig(service_account_key=service_account_key, token_endpoint=token_endpoint) + + +async def main() -> None: + """Publish two example messages to the configured topic.""" + topic_id = _require_env("STACKIT_TOPIC_ID") + auth_config = _build_auth_config() + base_url = os.environ.get("STACKIT_BASE_URL", DEFAULT_BASE_URL) + + client = PubSubClient(topic_id=topic_id, auth_config=auth_config, base_url=base_url) + + messages = [ + PublishMessage.from_plain("hello from the STACKIT PubSub Python SDK"), + PublishMessage.from_plain("a second example message"), + ] + + try: + async with client.get_publisher() as publisher: + response = await publisher.publish(messages) + print(f"Published {len(messages)} message(s); assigned ids={response.message_ids}") + except PubSubValidationError as exc: + # Bad request (HTTP 400/413) or local validation, e.g. an empty or + # oversized message batch. + print(f"Publish request was rejected as invalid: {exc}", file=sys.stderr) + sys.exit(1) + except PubSubAuthenticationError as exc: + # HTTP 401/403, or the service account key could not be exchanged + # for a token. + print(f"Authentication failed - check the service account key: {exc}", file=sys.stderr) + sys.exit(1) + except PubSubRetryError as exc: + # All automatic retries for transient network/server errors were + # exhausted; exc.causes holds the underlying exceptions. + print( + f"Publish failed after exhausting retries: {exc} (causes={exc.causes})", file=sys.stderr + ) + sys.exit(1) + except PubSubError as exc: + # Catch-all for any other SDK error not handled above. + print(f"Unexpected PubSub SDK error: {exc}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/02_pull_and_ack.py b/examples/02_pull_and_ack.py new file mode 100644 index 0000000..a447c39 --- /dev/null +++ b/examples/02_pull_and_ack.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +"""Pull messages from a STACKIT PubSub subscription, then ack or nack them. + +This example shows the standard SDK workflow for consuming messages: + +1. Build an ``AuthConfig`` from a STACKIT service account key file whose + path comes from an environment variable. +2. Instantiate ``PubSubClient`` and derive a ``PubSubSubscriber`` from it. +3. Pull a batch of messages, print their decoded payloads, acknowledge the + ones that were processed successfully, and negatively acknowledge (nack) + any that failed processing so they become eligible for redelivery. + +Prerequisites +------------- +Environment variables: + STACKIT_SERVICE_ACCOUNT_KEY_PATH Path to a STACKIT service account key + JSON file. + STACKIT_TOPIC_ID ID of the PubSub topic that owns the + subscription below. + STACKIT_SUBSCRIPTION_ID ID of an existing subscription that + the service account may pull from. + +Optional environment variables: + STACKIT_TOKEN_ENDPOINT OAuth2 token endpoint override (defaults to the + SDK's built-in STACKIT authorization endpoint). + STACKIT_BASE_URL Dataplane base URL override. Defaults to + "https://{topic_id}.pubsub.eu01.onstackit.cloud". + +Obtaining a service account key +-------------------------------- +Create or rotate a service account key using the STACKIT Portal or the +STACKIT CLI (`stackit service-account key create ...`), then point +STACKIT_SERVICE_ACCOUNT_KEY_PATH at the downloaded JSON file. + +Run +--- + python examples/02_pull_and_ack.py +""" + +from __future__ import annotations + +import asyncio +import os +import sys + +from pubsub_sdk_python import ( + AuthConfig, + Message, + PubSubAuthenticationError, + PubSubClient, + PubSubError, + PubSubRetryError, + PubSubValidationError, +) + +DEFAULT_TOKEN_ENDPOINT = "https://authorization.stackit.cloud/auth/token" +DEFAULT_BASE_URL = "https://{topicId}.pubsub.eu01.onstackit.cloud" + + +def _require_env(name: str) -> str: + """Return a required environment variable or exit with a clear message. + + Args: + name: Name of the environment variable to look up. + + Returns: + The environment variable's value. + """ + value = os.environ.get(name) + if not value: + print( + f"ERROR: required environment variable {name} is not set.\n" + "See the module docstring in this file for setup instructions.", + file=sys.stderr, + ) + sys.exit(1) + return value + + +def _build_auth_config() -> AuthConfig: + """Build an AuthConfig by reading the service account key from disk. + + Returns: + A populated AuthConfig ready to use with PubSubClient. + """ + key_path = _require_env("STACKIT_SERVICE_ACCOUNT_KEY_PATH") + + try: + with open(key_path, encoding="utf-8") as key_file: + service_account_key = key_file.read() + except OSError as exc: + print(f"ERROR: could not read service account key at {key_path}: {exc}", file=sys.stderr) + sys.exit(1) + + token_endpoint = os.environ.get("STACKIT_TOKEN_ENDPOINT", DEFAULT_TOKEN_ENDPOINT) + return AuthConfig(service_account_key=service_account_key, token_endpoint=token_endpoint) + + +def _process_message(message: Message) -> bool: + """Simulate application-specific processing of a single message. + + Replace this with real business logic. Returning False simulates a + processing failure so the nack path below can be exercised. + + Args: + message: The pulled message to process. + + Returns: + True if processing succeeded and the message should be acked, + False if it should be nacked for redelivery. + """ + print(f" processing message id={message.id}: {message.text!r}") + return True + + +async def main() -> None: + """Pull one batch of messages and ack/nack them based on processing outcome.""" + topic_id = _require_env("STACKIT_TOPIC_ID") + subscription_id = _require_env("STACKIT_SUBSCRIPTION_ID") + auth_config = _build_auth_config() + base_url = os.environ.get("STACKIT_BASE_URL", DEFAULT_BASE_URL) + + client = PubSubClient(topic_id=topic_id, auth_config=auth_config, base_url=base_url) + + try: + async with client.get_subscriber(subscription_id=subscription_id) as subscriber: + messages = await subscriber.pull(max_messages=10) + + if not messages: + print("No messages available to pull right now.") + return + + print(f"Pulled {len(messages)} message(s).") + + acked: list[Message] = [] + nacked: list[Message] = [] + for message in messages: + if _process_message(message): + acked.append(message) + else: + nacked.append(message) + + if acked: + await subscriber.ack_messages(acked) + print(f"Acked {len(acked)} message(s).") + if nacked: + await subscriber.nack_messages(nacked) + print(f"Nacked {len(nacked)} message(s) for redelivery.") + except PubSubValidationError as exc: + # Local/HTTP validation error, e.g. max_messages out of range or an + # empty ack/nack list. + print(f"Pull/ack request was rejected as invalid: {exc}", file=sys.stderr) + sys.exit(1) + except PubSubAuthenticationError as exc: + # HTTP 401/403, or the service account key could not be exchanged + # for a token. + print(f"Authentication failed - check the service account key: {exc}", file=sys.stderr) + sys.exit(1) + except PubSubRetryError as exc: + # All automatic retries for transient network/server errors were + # exhausted; exc.causes holds the underlying exceptions. + print( + f"Operation failed after exhausting retries: {exc} (causes={exc.causes})", + file=sys.stderr, + ) + sys.exit(1) + except PubSubError as exc: + # Catch-all for any other SDK error not handled above. + print(f"Unexpected PubSub SDK error: {exc}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/03_error_handling.py b/examples/03_error_handling.py new file mode 100644 index 0000000..d8c22ae --- /dev/null +++ b/examples/03_error_handling.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +"""Demonstrate robust error handling with the STACKIT PubSub SDK's exceptions. + +All SDK exceptions subclass ``PubSubError``. This example is split into two +parts: + +Part 1 - local validation errors (always runs, no network or credentials +required). The SDK validates input *before* making any HTTP request, so +these exceptions can be triggered and caught without a live topic, +subscription, or service account key. + +Part 2 - live error handling against the real dataplane API (only runs if +STACKIT_SERVICE_ACCOUNT_KEY_PATH and STACKIT_TOPIC_ID are set). It pulls +from a deliberately non-existent subscription with a short retry budget to +show how PubSubNotFoundError, PubSubAuthenticationError, and +PubSubRetryError (retry exhaustion) are distinguished and handled in +production code. + +Prerequisites +------------- +Part 1 has no prerequisites. + +Part 2 optionally uses: + STACKIT_SERVICE_ACCOUNT_KEY_PATH Path to a STACKIT service account key + JSON file. + STACKIT_TOPIC_ID ID of an existing PubSub topic. + +Optional environment variables: + STACKIT_TOKEN_ENDPOINT OAuth2 token endpoint override. + STACKIT_BASE_URL Dataplane base URL override. Defaults to + "https://{topic_id}.pubsub.eu01.onstackit.cloud". + +Run +--- + python examples/03_error_handling.py +""" + +from __future__ import annotations + +import asyncio +import os + +from pubsub_sdk_python import ( + AuthConfig, + PublishMessage, + PubSubAuthenticationError, + PubSubError, + PubSubNotFoundError, + PubSubPublisher, + PubSubRetryError, + PubSubSubscriber, + PubSubValidationError, + RetryConfig, + StackitAuthProvider, +) + +DEFAULT_TOKEN_ENDPOINT = "https://authorization.stackit.cloud/auth/token" +DEFAULT_BASE_URL = "https://{topicId}.pubsub.eu01.onstackit.cloud" +PLACEHOLDER_TOPIC_ID = "example-topic" + + +async def demonstrate_local_validation_errors() -> None: + """Trigger and catch PubSubValidationError without any network access. + + The SDK validates publish/pull/ack/nack arguments before issuing an + HTTP request, so these calls fail fast and predictably even with a + throwaway (unauthenticated) client. + """ + print("Part 1: local validation errors (no network required)") + + # An unauthenticated provider is fine here: none of the calls below + # reach the point of building an Authorization header. + auth_provider = StackitAuthProvider(AuthConfig()) + publisher = PubSubPublisher( + topic_id=PLACEHOLDER_TOPIC_ID, + base_url=DEFAULT_BASE_URL.format(topicId=PLACEHOLDER_TOPIC_ID), + auth_provider=auth_provider, + ) + subscriber = PubSubSubscriber( + subscription_id="example-subscription", + base_url=DEFAULT_BASE_URL.format(topicId=PLACEHOLDER_TOPIC_ID), + auth_provider=auth_provider, + ) + + try: + await publisher.publish([]) + except PubSubValidationError as exc: + print(f" caught expected error for an empty publish batch: {exc}") + + try: + await publisher.publish([PublishMessage.from_plain("x")] * 101) + except PubSubValidationError as exc: + print(f" caught expected error for an oversized publish batch: {exc}") + + try: + await subscriber.pull(max_messages=0) + except PubSubValidationError as exc: + print(f" caught expected error for an out-of-range max_messages: {exc}") + + try: + await subscriber.ack([]) + except PubSubValidationError as exc: + print(f" caught expected error for an empty ack list: {exc}") + + await publisher.close() + await subscriber.close() + + +async def demonstrate_live_error_handling() -> None: + """Show how live API errors are distinguished, using a short retry budget. + + Skips itself with an explanatory message if the required environment + variables are not set, rather than failing with a network traceback. + """ + print("Part 2: live error handling against the dataplane API") + + key_path = os.environ.get("STACKIT_SERVICE_ACCOUNT_KEY_PATH") + topic_id = os.environ.get("STACKIT_TOPIC_ID") + if not key_path or not topic_id: + print( + " skipped: set STACKIT_SERVICE_ACCOUNT_KEY_PATH and STACKIT_TOPIC_ID " + "to exercise this part against a real topic." + ) + return + + try: + with open(key_path, encoding="utf-8") as key_file: + service_account_key = key_file.read() + except OSError as exc: + print(f" skipped: could not read service account key at {key_path}: {exc}") + return + + token_endpoint = os.environ.get("STACKIT_TOKEN_ENDPOINT", DEFAULT_TOKEN_ENDPOINT) + auth_config = AuthConfig(service_account_key=service_account_key, token_endpoint=token_endpoint) + auth_provider = StackitAuthProvider(auth_config) + base_url = os.environ.get("STACKIT_BASE_URL", DEFAULT_BASE_URL).format(topicId=topic_id) + + # A small retry budget keeps this demonstration fast; production code + # would typically use the default RetryConfig() or tune it deliberately. + retry_config = RetryConfig(max_retries=1, base_delay=0.2, max_delay=1.0) + + # Deliberately non-existent subscription id to provoke a 404. + subscriber = PubSubSubscriber( + subscription_id="does-not-exist-example-subscription", + base_url=base_url, + auth_provider=auth_provider, + retry_config=retry_config, + ) + + try: + await subscriber.pull(max_messages=1) + except PubSubAuthenticationError as exc: + # HTTP 401/403, or the service account key could not be exchanged + # for a token. + print(f" caught authentication error - check the service account key: {exc}") + except PubSubNotFoundError as exc: + # HTTP 404 - expected here, since the subscription id is bogus. + print(f" caught not-found error as expected for a bogus subscription id: {exc}") + except PubSubRetryError as exc: + # All retries for transient network/server errors were exhausted; + # exc.causes holds the underlying exceptions for diagnostics. + print(f" caught retry-exhaustion error: {exc} (causes={exc.causes})") + except PubSubError as exc: + # Catch-all for any other SDK error not handled above. + print(f" caught unexpected PubSub SDK error: {exc}") + finally: + await subscriber.close() + + +async def main() -> None: + """Run both parts of the error-handling demonstration.""" + await demonstrate_local_validation_errors() + print() + await demonstrate_live_error_handling() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..1d6f617 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,59 @@ +# Examples + +Self-contained, executable scripts demonstrating the `pubsub-sdk-python` SDK. +Each script is runnable on its own (`python examples/