diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..8052d8d
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,69 @@
+name: CI
+
+on:
+ push:
+ branches: [ main ]
+ tags: [ 'v*' ]
+ pull_request:
+ branches: [ main ]
+
+jobs:
+ build:
+ name: Build & Test
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up JDK 21
+ uses: actions/setup-java@v4
+ with:
+ java-version: '21'
+ distribution: 'temurin'
+ cache: 'gradle'
+
+ - name: Setup Gradle
+ uses: gradle/actions/setup-gradle@v4
+
+ - name: Grant execute permission for gradlew
+ run: chmod +x gradlew
+
+ - name: Build and test with coverage
+ run: ./gradlew clean check
+
+ - name: Upload test results
+ uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: test-results
+ path: build/test-results/test/
+
+ deploy:
+ name: Deploy to Maven Central
+ runs-on: ubuntu-latest
+ needs: build
+ if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up JDK 21
+ uses: actions/setup-java@v4
+ with:
+ java-version: '21'
+ distribution: 'temurin'
+ cache: 'gradle'
+
+ - name: Setup Gradle
+ uses: gradle/actions/setup-gradle@v4
+
+ - name: Grant execute permission for gradlew
+ run: chmod +x gradlew
+
+ - name: Publish to Maven Central
+ run: ./gradlew publishAllPublicationsToMavenRepository
+ env:
+ ORG_GRADLE_PROJECT_mavenCentralUsername: ${{ secrets.MAVEN_CENTRAL_USERNAME }}
+ ORG_GRADLE_PROJECT_mavenCentralPassword: ${{ secrets.MAVEN_CENTRAL_PASSWORD }}
+ ORG_GRADLE_PROJECT_signingInMemoryKey: ${{ secrets.GPG_SECRET_KEY }}
+ ORG_GRADLE_PROJECT_signingInMemoryKeyPassword: ${{ secrets.GPG_PASSPHRASE }}
diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml
new file mode 100644
index 0000000..ce178ec
--- /dev/null
+++ b/.github/workflows/integration.yml
@@ -0,0 +1,41 @@
+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@v4
+
+ - name: Set up JDK 21
+ uses: actions/setup-java@v4
+ with:
+ java-version: '21'
+ distribution: 'temurin'
+ cache: 'gradle'
+
+ - name: Setup Gradle
+ uses: gradle/actions/setup-gradle@v4
+
+ - name: Grant execute permission for gradlew
+ run: chmod +x gradlew
+
+ - name: Run integration tests
+ env:
+ SA_KEY_B64: ${{ secrets.SA_KEY_B64 }}
+ STACKIT_PROJECT_ID: ${{ secrets.STACKIT_PROJECT_ID }}
+ run: ./gradlew test --tests "cloud.stackit.pubsub.sdk.integration.*"
+
+ - name: Upload test results
+ uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: integration-test-results
+ path: build/test-results/test/
diff --git a/.gitignore b/.gitignore
index 485dee6..9cfe547 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,48 @@
-.idea
+.gradle
+build/
+!gradle/wrapper/gradle-wrapper.jar
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### IntelliJ IDEA ###
+.idea/modules.xml
+.idea/jarRepositories.xml
+.idea/compiler.xml
+.idea/libraries/
+*.iws
+*.iml
+*.ipr
+out/
+!**/src/main/**/out/
+!**/src/test/**/out/
+
+### Kotlin ###
+.kotlin
+
+### Eclipse ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+bin/
+!**/src/main/**/bin/
+!**/src/test/**/bin/
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+
+### VS Code ###
+.vscode/
+
+### Mac OS ###
+.DS_Store
+
+# Service account keys
+*.json
\ No newline at end of file
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..30cf57e
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,10 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Ignored default folder with query files
+/queries/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
new file mode 100644
index 0000000..1bec35e
--- /dev/null
+++ b/.idea/codeStyles/Project.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml
new file mode 100644
index 0000000..79ee123
--- /dev/null
+++ b/.idea/codeStyles/codeStyleConfig.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
new file mode 100644
index 0000000..89022a7
--- /dev/null
+++ b/.idea/gradle.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml
new file mode 100644
index 0000000..cc87bc5
--- /dev/null
+++ b/.idea/kotlinc.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 0000000..a109a79
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..94a25f7
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/README.md b/README.md
index 5208b4d..9e5146b 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,131 @@
-Welcome to pubsub-java-sdk
\ No newline at end of file
+# pubsub-sdk-java
+
+Kotlin 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 or the STACKIT Portal.
+
+## Install
+
+Every GitHub Release publishes `pubsub-sdk-java` to Maven Central automatically (see `.github/workflows/ci.yml`). Once a release is cut:
+
+**Maven** (`pom.xml`):
+```xml
+
+ cloud.stackit.sdk
+ pubsub-sdk-java
+ 1.0.0
+
+```
+
+**Gradle** (`build.gradle.kts`):
+```kotlin
+implementation("cloud.stackit.sdk:pubsub-sdk-java:1.0.0")
+```
+
+Requires JDK 21+. Runtime dependencies: `cloud.stackit.sdk:core`, `okhttp`, `gson`.
+
+## Auth
+
+Authentication goes through `AuthConfig` + `StackitAuthProvider`, backed by a STACKIT service account key.
+
+```kotlin
+import cloud.stackit.pubsub.sdk.auth.AuthConfig
+
+val authConfig = AuthConfig(
+ serviceAccountKeyPath = "sa-key.json", // path to service account key JSON file
+ tokenEndpoint = "https://authorization.stackit.cloud/auth/token", // default shown
+)
+```
+
+`AuthConfig` fields: `serviceAccountKey` (inline JSON string), `serviceAccountKeyPath` (file path), `privateKeyPath`, `tokenEndpoint` (default `https://authorization.stackit.cloud/auth/token`).
+
+## Environments
+
+The SDK supports three environments via the `Environment` enum:
+
+| Environment | Dataplane URL pattern |
+|-------------|----------------------|
+| `PROD` (default) | `https://{topic_id}.pubsub.{region}.onstackit.cloud` |
+| `QA` | `https://{topic_id}.pubsub.{region}.qa.onstackit.cloud` |
+| `DEV` | `https://{topic_id}.pubsub.{region}.dev.onstackit.cloud` |
+
+```kotlin
+import cloud.stackit.pubsub.sdk.config.Environment
+
+val client = PubSubClient(
+ topicId = UUID.fromString("your-topic-uuid"),
+ authConfig = authConfig,
+ environment = Environment.DEV // use DEV environment
+)
+```
+
+## Quickstart
+
+```kotlin
+import cloud.stackit.pubsub.sdk.client.PubSubClient
+import cloud.stackit.pubsub.sdk.auth.AuthConfig
+import cloud.stackit.pubsub.sdk.models.PublishMessage
+import java.util.UUID
+
+fun main() {
+ val authConfig = AuthConfig(serviceAccountKeyPath = "sa-key.json")
+ val client = PubSubClient(
+ topicId = UUID.fromString("your-topic-uuid"),
+ authConfig = authConfig
+ )
+
+ // Publish
+ val publisher = client.getPublisher()
+ val response = publisher.publish(listOf(PublishMessage("aGVsbG8="))) // base64-encoded "hello"
+ println(response.messageIds)
+
+ // Subscribe
+ val subscriber = client.getSubscriber(UUID.fromString("your-subscription-uuid"))
+ val messages = subscriber.pull()
+ for (msg in messages) {
+ println(msg.text) // base64-decoded payload
+ }
+ subscriber.ack(messages.map { it.ackId })
+ // or: subscriber.nack(messages.map { it.ackId })
+}
+```
+
+`client.baseDataplaneUrl` defaults to `https://{topic_id}.pubsub.eu01.onstackit.cloud`. Override with `region` or `environment` (e.g. `environment = Environment.DEV` for `https://{topic_id}.pubsub.eu01.dev.onstackit.cloud`).
+
+`PublishMessage` takes a base64-encoded payload string. `Message.payload` (ByteArray) / `Message.text` (String) base64-decode it back.
+
+Both `PubSubPublisher` and `PubSubSubscriber` expose call counters: `publisher.messagesSent` / `.publishCalls` / `.purgeCalls`, `subscriber.pullCalls` / `.ackCalls` / `.nackCalls`.
+
+Other methods: `publisher.publishBatch(messages, batchSize = 100)`, `publisher.purgeTopic()`.
+
+## Errors
+
+All exceptions extend `PubSubAuthError` (sealed class):
+
+| Exception | Raised when |
+|------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `PubSubAuthError.MissingCredentials` | No service account key configured (local validation) |
+| `PubSubAuthError.TokenAcquisitionFailed` | HTTP 400/401/403/404/413, server errors (5xx), or token acquisition failure |
+| | |
+
+## Retries
+
+`PubSubPublisher`/`PubSubSubscriber` retry automatically on network/timeout errors (`IOException`, `ConnectException`, `SocketTimeoutException`, `TimeoutException`) and HTTP 500/502/503/504, with exponential backoff + jitter. Configure via `retryConfig = RetryConfig(...)` passed to the publisher/subscriber constructor (or `getPublisher()`/`getSubscriber()` kwargs):
+
+```kotlin
+import cloud.stackit.pubsub.sdk.retry.RetryConfig
+
+val retryConfig = RetryConfig(maxRetries = 3, baseDelay = 500L, maxDelay = 30000L, multiplier = 2.0)
+```
+
+400/401/403/404/413 are never retried.
+
+## Examples
+
+Standalone example programs demonstrating common patterns are in [`examples/`](examples/):
+
+- `BasicExample.java` — publish/subscribe workflow
+- `ErrorHandlingExample.java` — error handling patterns
+- `BatchExample.java` — batch publishing
+- `PurgeExample.java` — topic purging
+- `RetryExample.java` — custom retry configuration
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/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..4f50b0f
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,79 @@
+plugins {
+ `java-library`
+ `maven-publish`
+ kotlin("jvm") version "2.4.10"
+ id("jacoco")
+}
+
+group = "cloud.stackit.pubsub.sdk"
+version = "1.0-SNAPSHOT"
+
+repositories {
+ mavenCentral()
+}
+
+dependencies {
+ implementation("cloud.stackit.sdk:core:0.4.1")
+ implementation("com.squareup.okhttp3:okhttp:4.12.0")
+ implementation("com.google.code.gson:gson:2.9.1")
+ testImplementation(kotlin("test"))
+ testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
+ testImplementation("org.mockito:mockito-core:5.14.2")
+ testImplementation("org.mockito.kotlin:mockito-kotlin:5.4.0")
+}
+
+kotlin {
+ jvmToolchain(21)
+}
+
+tasks.withType().configureEach {
+ compilerOptions {
+ allWarningsAsErrors.set(true)
+ }
+}
+
+java {
+ withSourcesJar()
+ withJavadocJar()
+}
+
+tasks.test {
+ useJUnitPlatform {
+ excludeTags("integration")
+ }
+}
+
+jacoco {
+ toolVersion = "0.8.13"
+}
+
+tasks.jacocoTestReport {
+ reports {
+ xml.required.set(true)
+ html.required.set(true)
+ }
+}
+
+tasks.check {
+ dependsOn(tasks.jacocoTestCoverageVerification)
+}
+
+tasks.jacocoTestCoverageVerification {
+ dependsOn(tasks.test)
+
+ violationRules {
+ rule {
+ element = "CLASS"
+ includes = listOf(
+ "cloud.stackit.pubsub.sdk.publisher.*",
+ "cloud.stackit.pubsub.sdk.subscriber.*"
+ )
+ excludes = listOf(
+ "cloud.stackit.pubsub.sdk.subscriber.PubSubSubscriber*gson*"
+ )
+ limit {
+ minimum = BigDecimal("0.85")
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/examples/pom.xml b/examples/pom.xml
new file mode 100644
index 0000000..64b262c
--- /dev/null
+++ b/examples/pom.xml
@@ -0,0 +1,52 @@
+
+
+ 4.0.0
+
+ cloud.stackit.pubsub
+ pubsub-sdk-java-examples
+ 1.0-SNAPSHOT
+ jar
+
+
+ 21
+ 21
+ UTF-8
+
+
+
+
+ cloud.stackit.sdk
+ pubsub-sdk-java
+ 1.0-SNAPSHOT
+
+
+ cloud.stackit.sdk
+ core
+ 0.4.1
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.13.0
+
+ 21
+ 21
+
+
+
+ org.codehaus.mojo
+ exec-maven-plugin
+ 3.4.1
+
+ cloud.stackit.pubsub.examples.Examples
+
+
+
+
+
diff --git a/examples/src/main/java/cloud/stackit/pubsub/examples/BasicExample.java b/examples/src/main/java/cloud/stackit/pubsub/examples/BasicExample.java
new file mode 100644
index 0000000..4b1b62d
--- /dev/null
+++ b/examples/src/main/java/cloud/stackit/pubsub/examples/BasicExample.java
@@ -0,0 +1,74 @@
+package cloud.stackit.pubsub.examples;
+
+import cloud.stackit.pubsub.sdk.auth.AuthConfig;
+import cloud.stackit.pubsub.sdk.client.PubSubClient;
+import cloud.stackit.pubsub.sdk.errors.PubSubAuthError;
+import cloud.stackit.pubsub.sdk.models.Message;
+import cloud.stackit.pubsub.sdk.models.PublishMessage;
+
+import java.util.Base64;
+import java.util.UUID;
+import java.util.List;
+
+/**
+ * Basic publish and subscribe operations.
+ * Demonstrates the core workflow of sending and receiving messages.
+ */
+public class BasicExample {
+
+ private static final UUID TOPIC_ID = UUID.fromString("00000000-0000-0000-0000-000000000000");
+ private static final UUID SUBSCRIPTION_ID = UUID.fromString("00000000-0000-0000-0000-000000000000");
+
+ public static void main(String[] args) {
+ System.out.println("--- Example 1: Basic Publish & Subscribe ---");
+
+ try {
+ // Initialize auth config with service account key file path
+ AuthConfig authConfig = new AuthConfig(
+ null,
+ "sa-key.json",
+ null,
+ "https://authorization.stackit.cloud/auth/token"
+ );
+
+ // Create the client with topic ID and auth config
+ PubSubClient client = new PubSubClient(
+ TOPIC_ID,
+ authConfig
+ );
+
+ // Publish a message
+ String payload = "Hello, PubSub!";
+ String base64Payload = Base64.getEncoder().encodeToString(payload.getBytes());
+ PublishMessage message = new PublishMessage(base64Payload);
+
+ var publisher = client.getPublisher();
+ var response = publisher.publish(List.of(message));
+ System.out.println("Published messages with IDs: " + response.getMessageIds());
+ System.out.println("Messages sent: " + publisher.getMessagesSent());
+ System.out.println("Publish calls: " + publisher.getPublishCalls());
+
+ // Subscribe to messages
+ var subscriber = client.getSubscriber(SUBSCRIPTION_ID);
+ List messages = subscriber.pull();
+ System.out.println("Received " + messages.size() + " messages");
+
+ for (Message msg : messages) {
+ System.out.println(" - ID: " + msg.getId() + ", Text: " + msg.getText() +
+ ", Attempts: " + msg.getDeliveryAttempts());
+ }
+
+ // Acknowledge all received messages
+ subscriber.ack(messages.stream().map(Message::getAckId).toList());
+ System.out.println("Acknowledged all messages");
+
+ System.out.println("Subscriber ack calls: " + subscriber.getAckCalls());
+
+ } catch (PubSubAuthError e) {
+ System.err.println("PubSub error: " + e.getMessage());
+ if (e.getCause() != null) {
+ System.err.println(" Caused by: " + e.getCause().getMessage());
+ }
+ }
+ }
+}
diff --git a/examples/src/main/java/cloud/stackit/pubsub/examples/BatchExample.java b/examples/src/main/java/cloud/stackit/pubsub/examples/BatchExample.java
new file mode 100644
index 0000000..2be8f7d
--- /dev/null
+++ b/examples/src/main/java/cloud/stackit/pubsub/examples/BatchExample.java
@@ -0,0 +1,53 @@
+package cloud.stackit.pubsub.examples;
+
+import cloud.stackit.pubsub.sdk.auth.AuthConfig;
+import cloud.stackit.pubsub.sdk.client.PubSubClient;
+import cloud.stackit.pubsub.sdk.errors.PubSubAuthError;
+import cloud.stackit.pubsub.sdk.models.PublishMessage;
+
+import java.util.Base64;
+import java.util.UUID;
+import java.util.List;
+
+/**
+ * Batch publishing for higher throughput.
+ * Demonstrates sending multiple messages in batches.
+ */
+public class BatchExample {
+
+ private static final UUID TOPIC_ID = UUID.fromString("00000000-0000-0000-0000-000000000000");
+
+ public static void main(String[] args) {
+ System.out.println("--- Example 3: Batch Publishing ---");
+
+ try {
+ AuthConfig authConfig = new AuthConfig(
+ null,
+ "sa-key.json",
+ null,
+ "https://authorization.stackit.cloud/auth/token"
+ );
+
+ PubSubClient client = new PubSubClient(TOPIC_ID, authConfig);
+ var publisher = client.getPublisher();
+
+ // Create 250 messages to publish in batches of 100
+ List messages = java.util.stream.IntStream.range(0, 250)
+ .mapToObj(i -> new PublishMessage(
+ Base64.getEncoder().encodeToString(("Message " + i).getBytes())))
+ .toList();
+
+ // Publish in batches
+ var responses = publisher.publishBatch(messages, 100);
+ System.out.println("Published " + responses.size() + " batches");
+ for (int i = 0; i < responses.size(); i++) {
+ System.out.println(" Batch " + (i + 1) + ": " + responses.get(i).getMessageIds().size() + " messages");
+ }
+ System.out.println("Total messages sent: " + publisher.getMessagesSent());
+ System.out.println("Total publish calls: " + publisher.getPublishCalls());
+
+ } catch (PubSubAuthError e) {
+ System.err.println("PubSub error: " + e.getMessage());
+ }
+ }
+}
diff --git a/examples/src/main/java/cloud/stackit/pubsub/examples/ErrorHandlingExample.java b/examples/src/main/java/cloud/stackit/pubsub/examples/ErrorHandlingExample.java
new file mode 100644
index 0000000..8d12a2b
--- /dev/null
+++ b/examples/src/main/java/cloud/stackit/pubsub/examples/ErrorHandlingExample.java
@@ -0,0 +1,50 @@
+package cloud.stackit.pubsub.examples;
+
+import cloud.stackit.pubsub.sdk.auth.AuthConfig;
+import cloud.stackit.pubsub.sdk.client.PubSubClient;
+import cloud.stackit.pubsub.sdk.errors.PubSubAuthError;
+import cloud.stackit.pubsub.sdk.models.PublishMessage;
+
+import java.util.Base64;
+import java.util.UUID;
+import java.util.List;
+
+/**
+ * Error handling for various error conditions.
+ * Demonstrates how to catch and handle different types of errors.
+ */
+public class ErrorHandlingExample {
+
+ private static final UUID TOPIC_ID = UUID.fromString("00000000-0000-0000-0000-000000000000");
+
+ public static void main(String[] args) {
+ System.out.println("--- Example 2: Error Handling ---");
+
+ try {
+ AuthConfig authConfig = new AuthConfig(
+ null,
+ "sa-key.json",
+ null,
+ null
+ );
+
+ PubSubClient client = new PubSubClient(TOPIC_ID, authConfig);
+ var publisher = client.getPublisher();
+
+ // Try to publish (will fail if auth is invalid or topic doesn't exist)
+ PublishMessage message = new PublishMessage(Base64.getEncoder().encodeToString("test".getBytes()));
+ publisher.publish(List.of(message));
+
+ } catch (PubSubAuthError.MissingCredentials e) {
+ System.err.println("Missing credentials - please configure a service account key");
+ } catch (PubSubAuthError.TokenAcquisitionFailed e) {
+ System.err.println("Token acquisition failed: " + e.getMessage());
+ // This catches HTTP errors (4xx, 5xx) and network errors
+ if (e.getCause() != null) {
+ System.err.println(" Caused by: " + e.getCause().getClass().getSimpleName());
+ }
+ } catch (Exception e) {
+ System.err.println("Unexpected error: " + e.getMessage());
+ }
+ }
+}
diff --git a/examples/src/main/java/cloud/stackit/pubsub/examples/PurgeExample.java b/examples/src/main/java/cloud/stackit/pubsub/examples/PurgeExample.java
new file mode 100644
index 0000000..fae4a09
--- /dev/null
+++ b/examples/src/main/java/cloud/stackit/pubsub/examples/PurgeExample.java
@@ -0,0 +1,43 @@
+package cloud.stackit.pubsub.examples;
+
+import cloud.stackit.pubsub.sdk.auth.AuthConfig;
+import cloud.stackit.pubsub.sdk.client.PubSubClient;
+import cloud.stackit.pubsub.sdk.errors.PubSubAuthError;
+import cloud.stackit.pubsub.sdk.models.PublishMessage;
+
+import java.util.Base64;
+import java.util.UUID;
+import java.util.List;
+
+/**
+ * Topic purging.
+ * Demonstrates clearing all messages from a topic.
+ */
+public class PurgeExample {
+
+ private static final UUID TOPIC_ID = UUID.fromString("00000000-0000-0000-0000-000000000000");
+
+ public static void main(String[] args) {
+ System.out.println("--- Example 4: Topic Purging ---");
+
+ try {
+ AuthConfig authConfig = new AuthConfig(
+ null,
+ "sa-key.json",
+ null,
+ null
+ );
+
+ PubSubClient client = new PubSubClient(TOPIC_ID, authConfig);
+ var publisher = client.getPublisher();
+
+ // Purge all messages from the topic
+ publisher.purgeTopic();
+ System.out.println("Topic purged successfully");
+ System.out.println("Purge calls: " + publisher.getPurgeCalls());
+
+ } catch (PubSubAuthError e) {
+ System.err.println("PubSub error: " + e.getMessage());
+ }
+ }
+}
diff --git a/examples/src/main/java/cloud/stackit/pubsub/examples/RetryExample.java b/examples/src/main/java/cloud/stackit/pubsub/examples/RetryExample.java
new file mode 100644
index 0000000..9ae5421
--- /dev/null
+++ b/examples/src/main/java/cloud/stackit/pubsub/examples/RetryExample.java
@@ -0,0 +1,65 @@
+package cloud.stackit.pubsub.examples;
+
+import cloud.stackit.pubsub.sdk.auth.AuthConfig;
+import cloud.stackit.pubsub.sdk.client.PubSubClient;
+import cloud.stackit.pubsub.sdk.errors.PubSubAuthError;
+import cloud.stackit.pubsub.sdk.models.PublishMessage;
+import cloud.stackit.pubsub.sdk.retry.RetryConfig;
+
+import java.util.Base64;
+import java.util.UUID;
+import java.util.List;
+
+/**
+ * Custom retry configuration.
+ * Demonstrates configuring retry behavior for transient errors.
+ */
+public class RetryExample {
+
+ private static final UUID TOPIC_ID = UUID.fromString("00000000-0000-0000-0000-000000000000");
+
+ public static void main(String[] args) {
+ System.out.println("--- Example 5: Custom Retry Configuration ---");
+
+ try {
+ AuthConfig authConfig = new AuthConfig(
+ null,
+ "sa-key.json",
+ null,
+ null
+ );
+
+ PubSubClient client = new PubSubClient(TOPIC_ID, authConfig);
+
+ // Configure custom retry settings:
+ // - maxRetries: Maximum number of retry attempts (3 = 1 initial + 3 retries)
+ // - baseDelay: Initial delay in milliseconds (500ms)
+ // - maxDelay: Maximum delay cap in milliseconds (10000ms = 10s)
+ // - multiplier: Exponential backoff multiplier (2.0x per attempt)
+ RetryConfig retryConfig = new RetryConfig(
+ 3, // maxRetries
+ 500L, // baseDelay
+ 10000L, // maxDelay
+ 2.0 // multiplier
+ );
+
+ // Create publisher with custom retry config
+ var publisher = client.getPublisher(retryConfig);
+
+ PublishMessage message = new PublishMessage(
+ Base64.getEncoder().encodeToString("retry-test".getBytes())
+ );
+ publisher.publish(List.of(message));
+
+ System.out.println("Published with custom retry config");
+ System.out.println("Messages sent: " + publisher.getMessagesSent());
+
+ } catch (PubSubAuthError e) {
+ System.err.println("PubSub error after retries: " + e.getMessage());
+ // Note: After all retries are exhausted, the error includes retry information
+ if (e.getMessage().contains("retries")) {
+ System.err.println(" All retry attempts were exhausted");
+ }
+ }
+ }
+}
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..7fc6f1f
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1 @@
+kotlin.code.style=official
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..249e583
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..d4123c0
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Mon Aug 24 11:01:44 CEST 2026
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..1b6c787
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,234 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
+
+APP_NAME="Gradle"
+APP_BASE_NAME=${0##*/}
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+# Collect all arguments for the java command;
+# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
+# shell script including quotes and variable substitutions, so put them in
+# double quotes to make sure that they get re-expanded; and
+# * put everything else in single quotes, so that it's not re-expanded.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 0000000..ac1b06f
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,89 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..8d51130
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,4 @@
+plugins {
+ id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
+}
+rootProject.name = "pubsub-sdk-java"
\ No newline at end of file
diff --git a/src/main/kotlin/cloud/stackit/pubsub/sdk/auth/AuthConfig.kt b/src/main/kotlin/cloud/stackit/pubsub/sdk/auth/AuthConfig.kt
new file mode 100644
index 0000000..ddefd7b
--- /dev/null
+++ b/src/main/kotlin/cloud/stackit/pubsub/sdk/auth/AuthConfig.kt
@@ -0,0 +1,25 @@
+package cloud.stackit.pubsub.sdk.auth
+
+import cloud.stackit.sdk.core.config.CoreConfiguration
+
+data class AuthConfig(
+ val serviceAccountKey: String? = null,
+ val serviceAccountKeyPath: String? = null,
+ val privateKeyPath: String? = null,
+ val tokenEndpoint: String? = "https://authorization.stackit.cloud/auth/token"
+) {
+ init {
+ tokenEndpoint?.let { url ->
+ require(url.startsWith("https://")) { "tokenEndpoint must use HTTPS: $url" }
+ }
+ }
+
+ fun toCoreConfiguration(): CoreConfiguration {
+ val config = CoreConfiguration()
+ serviceAccountKey?.let { config.serviceAccountKey(it) }
+ serviceAccountKeyPath?.let { config.serviceAccountKeyPath(it) }
+ privateKeyPath?.let { config.privateKeyPath(it) }
+ tokenEndpoint?.let { config.tokenCustomUrl(it) }
+ return config
+ }
+}
diff --git a/src/main/kotlin/cloud/stackit/pubsub/sdk/auth/AuthProvider.kt b/src/main/kotlin/cloud/stackit/pubsub/sdk/auth/AuthProvider.kt
new file mode 100644
index 0000000..e81a919
--- /dev/null
+++ b/src/main/kotlin/cloud/stackit/pubsub/sdk/auth/AuthProvider.kt
@@ -0,0 +1,6 @@
+package cloud.stackit.pubsub.sdk.auth
+
+interface AuthProvider {
+ fun getToken(): String
+ fun getAuthHeader(): Map
+}
diff --git a/src/main/kotlin/cloud/stackit/pubsub/sdk/auth/StackitAuthProvider.kt b/src/main/kotlin/cloud/stackit/pubsub/sdk/auth/StackitAuthProvider.kt
new file mode 100644
index 0000000..11ec072
--- /dev/null
+++ b/src/main/kotlin/cloud/stackit/pubsub/sdk/auth/StackitAuthProvider.kt
@@ -0,0 +1,77 @@
+package cloud.stackit.pubsub.sdk.auth
+
+import cloud.stackit.sdk.core.KeyFlowAuthenticator
+import cloud.stackit.sdk.core.config.CoreConfiguration
+import okhttp3.OkHttpClient
+import cloud.stackit.pubsub.sdk.errors.PubSubError
+import java.security.spec.InvalidKeySpecException
+import java.time.Instant
+import java.util.concurrent.atomic.AtomicReference
+
+class StackitAuthProvider(
+ private val config: AuthConfig
+) : AuthProvider {
+ private val tokenRef = AtomicReference(EMPTY_TOKEN)
+
+ private companion object {
+ val EMPTY_TOKEN = TokenRecord(null, Instant.EPOCH)
+ val TOKEN_EXPIRY_BUFFER: java.time.Duration = java.time.Duration.ofSeconds(60)
+ val TOKEN_LIFETIME: java.time.Duration = java.time.Duration.ofSeconds(3600)
+ }
+
+ private data class TokenRecord(
+ val token: String?,
+ val expiresAt: Instant
+ )
+
+ init {
+ val hasKey = config.serviceAccountKey?.isNotBlank() == true
+ val hasKeyPath = config.serviceAccountKeyPath?.isNotBlank() == true
+ require(hasKey || hasKeyPath) {
+ """
+ |No service account key configured. Provide one of:
+ | - serviceAccountKey: inline JSON string or file path
+ | - serviceAccountKeyPath: path to service account key file
+ | - STACKIT_SERVICE_ACCOUNT_KEY environment variable
+ """.trimMargin()
+ }
+ }
+
+ override fun getAuthHeader(): Map =
+ mapOf("Authorization" to "Bearer ${getToken()}")
+
+ override fun getToken(): String {
+ val cached = tokenRef.get()
+ val now = Instant.now()
+
+ if (cached.token != null && now.isBefore(cached.expiresAt.minus(TOKEN_EXPIRY_BUFFER))) {
+ return cached.token
+ }
+
+ synchronized(tokenRef) {
+ val current = tokenRef.get()
+ if (current.token != null && now.isBefore(current.expiresAt.minus(TOKEN_EXPIRY_BUFFER))) {
+ return current.token
+ }
+ val (token, expiresAt) = fetchTokenFromStackit()
+ tokenRef.set(TokenRecord(token, expiresAt))
+ return token
+ }
+ }
+
+ private fun fetchTokenFromStackit(): Pair {
+ return try {
+ val coreConfig: CoreConfiguration = config.toCoreConfiguration()
+ val httpClient = OkHttpClient.Builder().build()
+
+ val authenticator = KeyFlowAuthenticator(httpClient, coreConfig)
+ val accessToken = authenticator.accessToken
+ val expiresAt = Instant.now().plus(TOKEN_LIFETIME)
+ accessToken to expiresAt
+ } catch (e: InvalidKeySpecException) {
+ throw PubSubError.AuthError.InvalidKey("Invalid private key in service account: ${e.message}", e)
+ } catch (e: Exception) {
+ throw PubSubError.AuthError.TokenAcquisitionFailed("Failed to obtain STACKIT token: ${e.message}", e)
+ }
+ }
+}
diff --git a/src/main/kotlin/cloud/stackit/pubsub/sdk/client/PubSubClient.kt b/src/main/kotlin/cloud/stackit/pubsub/sdk/client/PubSubClient.kt
new file mode 100644
index 0000000..be17e8a
--- /dev/null
+++ b/src/main/kotlin/cloud/stackit/pubsub/sdk/client/PubSubClient.kt
@@ -0,0 +1,83 @@
+package cloud.stackit.pubsub.sdk.client
+
+import cloud.stackit.pubsub.sdk.auth.AuthConfig
+import cloud.stackit.pubsub.sdk.auth.AuthProvider
+import cloud.stackit.pubsub.sdk.auth.StackitAuthProvider
+import cloud.stackit.pubsub.sdk.config.Environment
+import cloud.stackit.pubsub.sdk.config.Region
+import cloud.stackit.pubsub.sdk.publisher.PubSubPublisher
+import cloud.stackit.pubsub.sdk.retry.RetryConfig
+import cloud.stackit.pubsub.sdk.subscriber.PubSubSubscriber
+import okhttp3.OkHttpClient
+import java.time.Duration
+import java.util.UUID
+
+/**
+ * Main entry point for the STACKIT PubSub SDK.
+ *
+ * Provides a unified client for both publishing and subscribing,
+ * sharing authentication and HTTP client resources.
+ *
+ * @param topicId The topic UUID.
+ * @param authConfig Authentication configuration.
+ * @param region API region (default: EU01).
+ * @param environment STACKIT environment (default: PROD).
+ */
+class PubSubClient(
+ val topicId: UUID,
+ val authConfig: AuthConfig? = null,
+ val region: Region = Region.EU01,
+ val environment: Environment = Environment.PROD
+) {
+ val authProvider: StackitAuthProvider = StackitAuthProvider(authConfig ?: AuthConfig())
+
+ val baseDataplaneUrl: String = "https://$topicId.pubsub.${region.value}.${environment.hostnamePrefix}onstackit.cloud"
+
+ private val sharedHttpClient: OkHttpClient = OkHttpClient.Builder()
+ .connectTimeout(Duration.ofSeconds(10))
+ .readTimeout(Duration.ofSeconds(30))
+ .writeTimeout(Duration.ofSeconds(10))
+ .build()
+
+ /**
+ * Create a Publisher for this topic.
+ *
+ * @param retryConfig Optional retry configuration.
+ * @return Configured PubSubPublisher.
+ */
+ fun getPublisher(retryConfig: RetryConfig? = null): PubSubPublisher {
+ return PubSubPublisher(
+ topicId = topicId,
+ baseDataplaneUrl = baseDataplaneUrl,
+ authProvider = authProvider,
+ retryConfig = retryConfig ?: RetryConfig.Default,
+ httpClient = sharedHttpClient
+ )
+ }
+
+ /**
+ * Create a Subscriber for a subscription.
+ *
+ * @param subscriptionId The subscription UUID.
+ * @param retryConfig Optional retry configuration.
+ * @param maxMessages Maximum messages per pull (1-32).
+ * @param longPullDurationMs Long pull timeout in ms (100-5000).
+ * @return Configured PubSubSubscriber.
+ */
+ fun getSubscriber(
+ subscriptionId: UUID,
+ retryConfig: RetryConfig? = null,
+ maxMessages: Int = 16,
+ longPullDurationMs: Int = 5000
+ ): PubSubSubscriber {
+ return PubSubSubscriber(
+ subscriptionId = subscriptionId,
+ baseDataplaneUrl = baseDataplaneUrl,
+ authProvider = authProvider,
+ retryConfig = retryConfig ?: RetryConfig.Default,
+ maxMessages = maxMessages,
+ longPullDurationMs = longPullDurationMs,
+ httpClient = sharedHttpClient
+ )
+ }
+}
diff --git a/src/main/kotlin/cloud/stackit/pubsub/sdk/config/Config.kt b/src/main/kotlin/cloud/stackit/pubsub/sdk/config/Config.kt
new file mode 100644
index 0000000..80a86ce
--- /dev/null
+++ b/src/main/kotlin/cloud/stackit/pubsub/sdk/config/Config.kt
@@ -0,0 +1,27 @@
+package cloud.stackit.pubsub.sdk.config
+
+/**
+ * STACKIT PubSub regions.
+ */
+enum class Region {
+ EU01;
+
+ val value: String
+ get() = this.name.lowercase()
+}
+
+/**
+ * STACKIT environments.
+ */
+enum class Environment {
+ PROD,
+ QA,
+ DEV;
+
+ val hostnamePrefix: String
+ get() = when (this) {
+ PROD -> ""
+ QA -> "qa."
+ DEV -> "dev."
+ }
+}
diff --git a/src/main/kotlin/cloud/stackit/pubsub/sdk/errors/PubSubAuthenticationError.kt b/src/main/kotlin/cloud/stackit/pubsub/sdk/errors/PubSubAuthenticationError.kt
new file mode 100644
index 0000000..a26b147
--- /dev/null
+++ b/src/main/kotlin/cloud/stackit/pubsub/sdk/errors/PubSubAuthenticationError.kt
@@ -0,0 +1,63 @@
+package cloud.stackit.pubsub.sdk.errors
+
+sealed class PubSubError(
+ message: String,
+ cause: Throwable? = null
+) : RuntimeException(message, cause) {
+ sealed class AuthError(
+ message: String,
+ cause: Throwable? = null
+ ) : PubSubError(message, cause) {
+ class MissingCredentials(
+ message: String = "No service account key configured"
+ ) : AuthError(message)
+
+ class TokenAcquisitionFailed(
+ message: String,
+ cause: Throwable? = null
+ ) : AuthError(message, cause)
+
+ class InvalidKey(
+ message: String,
+ cause: Throwable? = null
+ ) : AuthError(message, cause)
+ }
+
+ sealed class HttpError(
+ message: String,
+ cause: Throwable? = null
+ ) : PubSubError(message, cause) {
+ class BadRequest(
+ message: String
+ ) : HttpError(message)
+
+ class Unauthorized(
+ message: String
+ ) : HttpError(message)
+
+ class Forbidden(
+ message: String
+ ) : HttpError(message)
+
+ class NotFound(
+ message: String
+ ) : HttpError(message)
+
+ class PayloadTooLarge(
+ message: String
+ ) : HttpError(message)
+
+ class ServerError(
+ message: String
+ ) : HttpError(message)
+
+ class Unexpected(
+ message: String
+ ) : HttpError(message)
+ }
+
+ class RequestFailed(
+ message: String,
+ cause: Throwable? = null
+ ) : PubSubError(message, cause)
+}
diff --git a/src/main/kotlin/cloud/stackit/pubsub/sdk/models/AckNackModels.kt b/src/main/kotlin/cloud/stackit/pubsub/sdk/models/AckNackModels.kt
new file mode 100644
index 0000000..b5279dc
--- /dev/null
+++ b/src/main/kotlin/cloud/stackit/pubsub/sdk/models/AckNackModels.kt
@@ -0,0 +1,19 @@
+package cloud.stackit.pubsub.sdk.models
+
+/**
+ * Request to acknowledge messages.
+ *
+ * @param ackIds IDs of messages to acknowledge.
+ */
+data class AckMessagesRequest(
+ val ackIds: List
+)
+
+/**
+ * Request to negatively acknowledge messages (requeue).
+ *
+ * @param nackIds IDs of messages to nack.
+ */
+data class NackMessagesRequest(
+ val nackIds: List
+)
diff --git a/src/main/kotlin/cloud/stackit/pubsub/sdk/models/PublishModels.kt b/src/main/kotlin/cloud/stackit/pubsub/sdk/models/PublishModels.kt
new file mode 100644
index 0000000..f1d6891
--- /dev/null
+++ b/src/main/kotlin/cloud/stackit/pubsub/sdk/models/PublishModels.kt
@@ -0,0 +1,28 @@
+package cloud.stackit.pubsub.sdk.models
+
+/**
+ * A single message to publish to a topic.
+ *
+ * @param data Base64-encoded message payload.
+ */
+data class PublishMessage(
+ val data: String
+)
+
+/**
+ * Request to publish messages to a topic.
+ *
+ * @param messages The messages to publish.
+ */
+data class PublishMessagesRequest(
+ val messages: List
+)
+
+/**
+ * Response from publishing messages.
+ *
+ * @param messageIds Unique sequence numbers identifying the published messages.
+ */
+data class PublishMessagesResponse(
+ val messageIds: List
+)
diff --git a/src/main/kotlin/cloud/stackit/pubsub/sdk/models/ReceiveModels.kt b/src/main/kotlin/cloud/stackit/pubsub/sdk/models/ReceiveModels.kt
new file mode 100644
index 0000000..9b57679
--- /dev/null
+++ b/src/main/kotlin/cloud/stackit/pubsub/sdk/models/ReceiveModels.kt
@@ -0,0 +1,42 @@
+package cloud.stackit.pubsub.sdk.models
+
+import java.time.Instant
+import java.util.Base64
+
+/**
+ * A message received from a topic subscription.
+ *
+ * @param id Unique sequence number identifying the message.
+ * @param ackId ID for acknowledging the message.
+ * @param data Base64-encoded message payload.
+ * @param createTime When the message was created.
+ * @param deliveryAttempts Number of delivery attempts.
+ */
+data class Message(
+ val id: Long,
+ val ackId: String,
+ val data: String,
+ val createTime: Instant,
+ val deliveryAttempts: Long
+) {
+ /**
+ * Decodes the base64-encoded data to bytes.
+ */
+ val payload: ByteArray
+ get() = Base64.getDecoder().decode(data)
+
+ /**
+ * Decodes the base64-encoded data to a UTF-8 string.
+ */
+ val text: String
+ get() = String(Base64.getDecoder().decode(data), java.nio.charset.StandardCharsets.UTF_8)
+}
+
+/**
+ * Response containing messages pulled from a subscription.
+ *
+ * @param messages The pulled messages.
+ */
+data class MessagesResponse(
+ val messages: List
+)
diff --git a/src/main/kotlin/cloud/stackit/pubsub/sdk/publisher/PubSubPublisher.kt b/src/main/kotlin/cloud/stackit/pubsub/sdk/publisher/PubSubPublisher.kt
new file mode 100644
index 0000000..abd96a7
--- /dev/null
+++ b/src/main/kotlin/cloud/stackit/pubsub/sdk/publisher/PubSubPublisher.kt
@@ -0,0 +1,176 @@
+package cloud.stackit.pubsub.sdk.publisher
+
+import com.google.gson.Gson
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import cloud.stackit.pubsub.sdk.auth.AuthProvider
+import cloud.stackit.pubsub.sdk.errors.PubSubError
+import cloud.stackit.pubsub.sdk.models.PublishMessage
+import cloud.stackit.pubsub.sdk.models.PublishMessagesRequest
+import cloud.stackit.pubsub.sdk.models.PublishMessagesResponse
+import cloud.stackit.pubsub.sdk.retry.RetryConfig
+import cloud.stackit.pubsub.sdk.retry.isRetryableException
+import cloud.stackit.pubsub.sdk.retry.isRetryableStatusCode
+import cloud.stackit.pubsub.sdk.retry.getRetryDelay
+import java.io.IOException
+import java.time.Duration
+import java.util.UUID
+import java.util.concurrent.atomic.AtomicInteger
+
+/**
+ * Publisher interface for sending messages to a STACKIT PubSub topic.
+ *
+ * @param topicId The topic UUID.
+ * @param baseDataplaneUrl The dataplane base URL.
+ * @param authProvider Provider for STACKIT authorization tokens.
+ * @param retryConfig Configuration for retry behavior.
+ * @param httpClient Shared HTTP client for connection reuse.
+ * @param messagesSent Total count of messages sent.
+ * @param publishCalls Total count of publish calls made.
+ * @param purgeCalls Total count of purge calls made.
+ */
+class PubSubPublisher(
+ private val topicId: UUID,
+ private val baseDataplaneUrl: String,
+ private val authProvider: AuthProvider,
+ private val retryConfig: RetryConfig = RetryConfig.Default,
+ httpClient: OkHttpClient? = null
+) {
+ private val gson = Gson()
+ private val httpClient: OkHttpClient = httpClient ?: OkHttpClient.Builder()
+ .connectTimeout(Duration.ofSeconds(10))
+ .readTimeout(Duration.ofSeconds(30))
+ .writeTimeout(Duration.ofSeconds(10))
+ .build()
+
+ private val jsonMediaType = "application/json".toMediaType()
+
+ val messagesSent: AtomicInteger = AtomicInteger(0)
+
+ val publishCalls: AtomicInteger = AtomicInteger(0)
+
+ val purgeCalls: AtomicInteger = AtomicInteger(0)
+
+ /**
+ * Publish messages to the topic.
+ *
+ * @param messages List of messages to publish (max 100).
+ * @return Response with assigned message IDs.
+ */
+ fun publish(messages: List): PublishMessagesResponse {
+ require(messages.isNotEmpty()) { "Cannot publish an empty list of messages" }
+ require(messages.size <= 100) { "Maximum 100 messages per publish request" }
+
+ publishCalls.incrementAndGet()
+ messagesSent.addAndGet(messages.size)
+
+ val request = Request.Builder()
+ .url("$baseDataplaneUrl/v1alpha/publish")
+ .post(
+ gson.toJson(PublishMessagesRequest(messages)).toRequestBody(jsonMediaType)
+ )
+ .addHeader("Content-Type", "application/json")
+ .addHeader("Accept", "application/json")
+ .addHeader("Authorization", "Bearer ${authProvider.getToken()}")
+ .build()
+
+ val (statusCode, body) = executeWithRetry(request)
+
+ if (statusCode == 200) {
+ val response = gson.fromJson(body, PublishMessagesResponse::class.java)
+ return response
+ }
+
+ handleErrorResponse(statusCode, body)
+ }
+
+ /**
+ * Publish messages in batches.
+ *
+ * @param messages All messages to publish.
+ * @param batchSize Maximum messages per batch (default: 100).
+ * @return List of responses, one per batch.
+ */
+ fun publishBatch(messages: List, batchSize: Int = 100): List {
+ if (messages.isEmpty()) return emptyList()
+
+ val responses = mutableListOf()
+ messages.chunked(batchSize).forEach { batch ->
+ responses.add(publish(batch))
+ }
+ return responses
+ }
+
+ /**
+ * Purge all messages from the topic.
+ */
+ fun purgeTopic() {
+ purgeCalls.incrementAndGet()
+
+ val request = Request.Builder()
+ .url("$baseDataplaneUrl/v1alpha/purge")
+ .post("{}".toRequestBody(jsonMediaType))
+ .addHeader("Content-Type", "application/json")
+ .addHeader("Accept", "application/json")
+ .addHeader("Authorization", "Bearer ${authProvider.getToken()}")
+ .build()
+
+ val (statusCode, _) = executeWithRetry(request)
+
+ if (statusCode != 204) {
+ handleErrorResponse(statusCode, "")
+ }
+ }
+
+ private fun executeWithRetry(request: Request): Pair {
+ var lastException: Throwable? = null
+
+ for (attempt in 0..retryConfig.maxRetries) {
+ try {
+ val response = httpClient.newCall(request).execute()
+ val body = response.body?.string() ?: ""
+ val statusCode = response.code
+
+ if (statusCode == 200 || statusCode == 204) {
+ return statusCode to body
+ }
+
+ if (isRetryableStatusCode(statusCode)) {
+ if (attempt >= retryConfig.maxRetries) {
+ throw PubSubError.HttpError.ServerError("Server error $statusCode after $attempt retries: $body")
+ }
+ val delay = getRetryDelay(retryConfig, attempt)
+ Thread.sleep(delay)
+ continue
+ }
+
+ return statusCode to body
+ } catch (e: IOException) {
+ lastException = e
+ if (!isRetryableException(e) || attempt >= retryConfig.maxRetries) {
+ throw PubSubError.RequestFailed("Request failed: ${e.message}", e)
+ }
+ val delay = getRetryDelay(retryConfig, attempt)
+ Thread.sleep(delay)
+ }
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ throw (lastException as? Exception) ?: throw lastException!!
+ }
+
+ private fun handleErrorResponse(statusCode: Int, body: String): Nothing {
+ val error = when (statusCode) {
+ 400 -> PubSubError.HttpError.BadRequest("Bad request: $body")
+ 401 -> PubSubError.HttpError.Unauthorized("Unauthorized: $body")
+ 403 -> PubSubError.HttpError.Forbidden("Forbidden: $body")
+ 404 -> PubSubError.HttpError.NotFound("Not found: $body")
+ 413 -> PubSubError.HttpError.PayloadTooLarge("Request body too large: $body")
+ in 500..599 -> PubSubError.HttpError.ServerError("Server error $statusCode: $body")
+ else -> PubSubError.HttpError.Unexpected("Unexpected error $statusCode: $body")
+ }
+ throw error
+ }
+}
diff --git a/src/main/kotlin/cloud/stackit/pubsub/sdk/retry/RetryConfig.kt b/src/main/kotlin/cloud/stackit/pubsub/sdk/retry/RetryConfig.kt
new file mode 100644
index 0000000..6ad4b7e
--- /dev/null
+++ b/src/main/kotlin/cloud/stackit/pubsub/sdk/retry/RetryConfig.kt
@@ -0,0 +1,22 @@
+package cloud.stackit.pubsub.sdk.retry
+
+/**
+ * Configuration for retry behavior with exponential backoff.
+ *
+ * @param maxRetries Maximum number of retry attempts (0 = no retries). Default: 3
+ * @param baseDelay Base delay in milliseconds between retries. Default: 500
+ * @param maxDelay Maximum delay in milliseconds between retries. Default: 30000
+ * @param multiplier Multiplier applied to delay after each retry. Default: 2.0
+ * @param jitterFactor Randomization factor (0.0-1.0) to avoid thundering herd. Default: 0.25
+ */
+data class RetryConfig(
+ val maxRetries: Int = 3,
+ val baseDelay: Long = 500,
+ val maxDelay: Long = 30_000,
+ val multiplier: Double = 2.0,
+ val jitterFactor: Double = 0.25
+) {
+ companion object {
+ val Default = RetryConfig()
+ }
+}
diff --git a/src/main/kotlin/cloud/stackit/pubsub/sdk/retry/RetryUtils.kt b/src/main/kotlin/cloud/stackit/pubsub/sdk/retry/RetryUtils.kt
new file mode 100644
index 0000000..b72cfdd
--- /dev/null
+++ b/src/main/kotlin/cloud/stackit/pubsub/sdk/retry/RetryUtils.kt
@@ -0,0 +1,94 @@
+package cloud.stackit.pubsub.sdk.retry
+
+import cloud.stackit.pubsub.sdk.errors.PubSubError
+import java.util.concurrent.ThreadLocalRandom
+
+/**
+ * HTTP status codes that are considered transient and worth retrying.
+ */
+val TRANSIENT_STATUS_CODES = setOf(500, 502, 503, 504)
+
+/**
+ * HTTP status codes that are never retried (permanent failures).
+ */
+val NON_RETRYABLE_STATUS_CODES = setOf(400, 401, 403, 404, 413)
+
+/**
+ * Determine whether an HTTP status code is retryable.
+ */
+fun isRetryableStatusCode(code: Int, config: RetryConfig = RetryConfig.Default): Boolean {
+ return code in TRANSIENT_STATUS_CODES
+}
+
+/**
+ * Determine whether an exception is retryable.
+ */
+fun isRetryableException(exc: Throwable, config: RetryConfig = RetryConfig.Default): Boolean {
+ return when (exc) {
+ is PubSubError -> false
+ is java.net.ConnectException -> true
+ is java.net.SocketTimeoutException -> true
+ is java.io.IOException -> true
+ is java.util.concurrent.TimeoutException -> true
+ else -> false
+ }
+}
+
+/**
+ * Calculate the delay for a given retry attempt using exponential backoff with jitter.
+ */
+fun getRetryDelay(config: RetryConfig, attempt: Int): Long {
+ val delay = minOf(
+ (config.baseDelay * pow(config.multiplier, attempt)).toLong(),
+ config.maxDelay
+ )
+ val jitter = (delay * config.jitterFactor * (ThreadLocalRandom.current().nextDouble(0.0, 1.0))).toLong()
+ return delay + jitter
+}
+
+private fun pow(base: Double, exp: Int): Double {
+ if (exp <= 0) return 1.0
+ return Math.pow(base, exp.toDouble())
+}
+
+/**
+ * Check if an exception and its causes are retryable.
+ */
+fun isRetryable(exc: Throwable): Boolean {
+ var current: Throwable? = exc
+ while (current != null) {
+ if (isRetryableException(current)) return true
+ current = current.cause
+ }
+ return false
+}
+
+/**
+ * Retry a block of code with exponential backoff.
+ *
+ * @param config Retry configuration (uses [RetryConfig.Default] if not specified).
+ * @param block The code block to execute and potentially retry.
+ * @throws T The last exception if all retries are exhausted.
+ */
+inline fun retry(
+ config: RetryConfig = RetryConfig.Default,
+ noinline block: () -> Unit
+) {
+ var lastException: Throwable? = null
+ for (attempt in 0..config.maxRetries) {
+ try {
+ block()
+ return
+ } catch (e: Throwable) {
+ lastException = e
+ if (!isRetryable(e) || attempt >= config.maxRetries) {
+ @Suppress("UNCHECKED_CAST")
+ throw lastException as? T ?: throw lastException
+ }
+ val delay = getRetryDelay(config, attempt)
+ Thread.sleep(delay)
+ }
+ }
+ @Suppress("UNCHECKED_CAST")
+ throw lastException as? T ?: throw lastException!!
+}
diff --git a/src/main/kotlin/cloud/stackit/pubsub/sdk/subscriber/PubSubSubscriber.kt b/src/main/kotlin/cloud/stackit/pubsub/sdk/subscriber/PubSubSubscriber.kt
new file mode 100644
index 0000000..e8eecd4
--- /dev/null
+++ b/src/main/kotlin/cloud/stackit/pubsub/sdk/subscriber/PubSubSubscriber.kt
@@ -0,0 +1,201 @@
+package cloud.stackit.pubsub.sdk.subscriber
+
+import com.google.gson.GsonBuilder
+import com.google.gson.JsonDeserializer
+import com.google.gson.JsonSerializer
+import com.google.gson.JsonElement
+import com.google.gson.JsonPrimitive
+import com.google.gson.JsonSerializationContext
+import com.google.gson.JsonDeserializationContext
+import java.lang.reflect.Type
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import cloud.stackit.pubsub.sdk.auth.AuthProvider
+import cloud.stackit.pubsub.sdk.errors.PubSubError
+import cloud.stackit.pubsub.sdk.models.AckMessagesRequest
+import cloud.stackit.pubsub.sdk.models.Message
+import cloud.stackit.pubsub.sdk.models.MessagesResponse
+import cloud.stackit.pubsub.sdk.models.NackMessagesRequest
+import cloud.stackit.pubsub.sdk.retry.RetryConfig
+import cloud.stackit.pubsub.sdk.retry.getRetryDelay
+import cloud.stackit.pubsub.sdk.retry.isRetryableException
+import cloud.stackit.pubsub.sdk.retry.isRetryableStatusCode
+import java.io.IOException
+import java.time.Duration
+import java.util.UUID
+
+/**
+ * Subscriber interface for receiving messages from a STACKIT PubSub subscription.
+ *
+ * @param subscriptionId The subscription UUID.
+ * @param baseDataplaneUrl The dataplane base URL.
+ * @param authProvider Provider for STACKIT authorization tokens.
+ * @param retryConfig Configuration for retry behavior.
+ * @param maxMessages Maximum messages to pull per request.
+ * @param longPullDurationMs Long pull timeout in milliseconds.
+ * @param httpClient Shared HTTP client for connection reuse.
+ */
+class PubSubSubscriber(
+ private val subscriptionId: UUID,
+ private val baseDataplaneUrl: String,
+ private val authProvider: AuthProvider,
+ private val retryConfig: RetryConfig = RetryConfig.Default,
+ private val maxMessages: Int = 16,
+ private val longPullDurationMs: Int = 5000,
+ httpClient: OkHttpClient? = null
+) {
+ private val gson = GsonBuilder()
+ .registerTypeAdapter(java.time.Instant::class.java, object : JsonDeserializer {
+ override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): java.time.Instant {
+ return java.time.Instant.parse(json.asString)
+ }
+ })
+ .registerTypeAdapter(java.time.Instant::class.java, object : JsonSerializer {
+ override fun serialize(src: java.time.Instant, typeOfSrc: Type, context: JsonSerializationContext): JsonElement {
+ return JsonPrimitive(src.toString())
+ }
+ })
+ .create()
+ private val jsonMediaType = "application/json".toMediaType()
+ private val httpClient: OkHttpClient = httpClient ?: OkHttpClient.Builder()
+ .connectTimeout(Duration.ofSeconds(10))
+ .readTimeout(Duration.ofSeconds(30))
+ .writeTimeout(Duration.ofSeconds(10))
+ .build()
+
+ init {
+ require(maxMessages in 1..32) { "maxMessages must be between 1 and 32" }
+ require(longPullDurationMs in 100..5000) { "longPullDurationMs must be between 100 and 5000" }
+ }
+
+ /**
+ * Pull messages from the subscription.
+ *
+ * @param maxMessages Override max messages for this request (default: 16, max: 32).
+ * @param longPullDurationMs Override long pull duration (default: 5000ms).
+ * @return List of messages.
+ */
+ fun pull(
+ maxMessages: Int = this.maxMessages,
+ longPullDurationMs: Int = this.longPullDurationMs
+ ): List {
+ require(maxMessages in 1..32) { "maxMessages must be between 1 and 32" }
+
+ val request = Request.Builder()
+ .url("$baseDataplaneUrl/v1alpha/subscriptions/$subscriptionId/pull")
+ .post("{}".toRequestBody(jsonMediaType))
+ .addHeader("Content-Type", "application/json")
+ .addHeader("Accept", "application/json")
+ .addHeader("Authorization", "Bearer ${authProvider.getToken()}")
+ .addHeader("PubSub-Max-Messages", maxMessages.toString())
+ .addHeader("PubSub-Long-Pull-Duration", longPullDurationMs.toString())
+ .build()
+
+ val (statusCode, body) = executeWithRetry(request)
+
+ if (statusCode == 200) {
+ val response = gson.fromJson(body, MessagesResponse::class.java)
+ return response.messages
+ }
+
+ handleErrorResponse(statusCode, body)
+ }
+
+ /**
+ * Acknowledge messages.
+ *
+ * @param ackIds IDs of messages to acknowledge.
+ */
+ fun ack(ackIds: List) {
+ require(ackIds.isNotEmpty()) { "ackIds must not be empty" }
+
+ val request = Request.Builder()
+ .url("$baseDataplaneUrl/v1alpha/subscriptions/$subscriptionId/ack")
+ .post(gson.toJson(AckMessagesRequest(ackIds)).toRequestBody(jsonMediaType))
+ .addHeader("Content-Type", "application/json")
+ .addHeader("Accept", "application/json")
+ .addHeader("Authorization", "Bearer ${authProvider.getToken()}")
+ .build()
+
+ val (statusCode, body) = executeWithRetry(request)
+
+ if (statusCode == 204) return
+
+ handleErrorResponse(statusCode, body)
+ }
+
+ /**
+ * Negatively acknowledge messages (requeue).
+ *
+ * @param nackIds IDs of messages to nack.
+ */
+ fun nack(nackIds: List) {
+ require(nackIds.isNotEmpty()) { "nackIds must not be empty" }
+
+ val request = Request.Builder()
+ .url("$baseDataplaneUrl/v1alpha/subscriptions/$subscriptionId/nack")
+ .post(gson.toJson(NackMessagesRequest(nackIds)).toRequestBody(jsonMediaType))
+ .addHeader("Content-Type", "application/json")
+ .addHeader("Accept", "application/json")
+ .addHeader("Authorization", "Bearer ${authProvider.getToken()}")
+ .build()
+
+ val (statusCode, body) = executeWithRetry(request)
+
+ if (statusCode == 204) return
+
+ handleErrorResponse(statusCode, body)
+ }
+
+ private fun executeWithRetry(request: Request): Pair {
+ var lastException: Throwable? = null
+
+ for (attempt in 0..retryConfig.maxRetries) {
+ try {
+ val response = httpClient.newCall(request).execute()
+ val body = response.body?.string() ?: ""
+ val statusCode = response.code
+
+ if (statusCode == 200 || statusCode == 204) {
+ return statusCode to body
+ }
+
+ if (isRetryableStatusCode(statusCode)) {
+ if (attempt >= retryConfig.maxRetries) {
+ throw PubSubError.HttpError.ServerError("Server error $statusCode after $attempt retries: $body")
+ }
+ val delay = getRetryDelay(retryConfig, attempt)
+ Thread.sleep(delay)
+ continue
+ }
+
+ return statusCode to body
+ } catch (e: IOException) {
+ lastException = e
+ if (!isRetryableException(e) || attempt >= retryConfig.maxRetries) {
+ throw PubSubError.RequestFailed("Request failed: ${e.message}", e)
+ }
+ val delay = getRetryDelay(retryConfig, attempt)
+ Thread.sleep(delay)
+ }
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ throw (lastException as? Exception) ?: throw lastException!!
+ }
+
+ private fun handleErrorResponse(statusCode: Int, body: String): Nothing {
+ val error = when (statusCode) {
+ 400 -> PubSubError.HttpError.BadRequest("Bad request: $body")
+ 401 -> PubSubError.HttpError.Unauthorized("Unauthorized: $body")
+ 403 -> PubSubError.HttpError.Forbidden("Forbidden: $body")
+ 404 -> PubSubError.HttpError.NotFound("Not found: $body")
+ 413 -> PubSubError.HttpError.PayloadTooLarge("Request body too large: $body")
+ in 500..599 -> PubSubError.HttpError.ServerError("Server error $statusCode: $body")
+ else -> PubSubError.HttpError.Unexpected("Unexpected error $statusCode: $body")
+ }
+ throw error
+ }
+}
diff --git a/src/test/kotlin/cloud/stackit/pubsub/TestUtils.kt b/src/test/kotlin/cloud/stackit/pubsub/TestUtils.kt
new file mode 100644
index 0000000..a630114
--- /dev/null
+++ b/src/test/kotlin/cloud/stackit/pubsub/TestUtils.kt
@@ -0,0 +1,5 @@
+package cloud.stackit.pubsub
+
+object TestUtils {
+ const val SA_KEY_PATH = "sa-key.json"
+}
diff --git a/src/test/kotlin/cloud/stackit/pubsub/sdk/auth/TestAuth.kt b/src/test/kotlin/cloud/stackit/pubsub/sdk/auth/TestAuth.kt
new file mode 100644
index 0000000..043899f
--- /dev/null
+++ b/src/test/kotlin/cloud/stackit/pubsub/sdk/auth/TestAuth.kt
@@ -0,0 +1,195 @@
+package cloud.stackit.pubsub.sdk.auth
+
+import cloud.stackit.pubsub.sdk.test.MockAuthProvider
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.assertDoesNotThrow
+import org.junit.jupiter.api.assertThrows
+import org.junit.jupiter.api.Disabled
+
+class TestAuthConfig {
+ @Test
+ fun `creates AuthConfig with defaults`() {
+ val config = AuthConfig()
+ assertNull(config.serviceAccountKey)
+ assertNull(config.serviceAccountKeyPath)
+ assertEquals("https://authorization.stackit.cloud/auth/token", config.tokenEndpoint)
+ }
+
+ @Test
+ fun `creates AuthConfig with serviceAccountKeyPath`() {
+ val config = AuthConfig(serviceAccountKeyPath = "sa-key.json")
+ assertEquals("sa-key.json", config.serviceAccountKeyPath)
+ }
+
+ @Test
+ fun `creates AuthConfig with custom tokenEndpoint`() {
+ val config = AuthConfig(tokenEndpoint = "https://custom.endpoint/token")
+ assertEquals("https://custom.endpoint/token", config.tokenEndpoint)
+ }
+
+ @Test
+ fun `tokenEndpoint must use HTTPS`() {
+ assertThrows {
+ AuthConfig(tokenEndpoint = "http://insecure.endpoint/token")
+ }
+ }
+
+ @Test
+ fun `toCoreConfiguration maps all fields`() {
+ val config = AuthConfig(
+ serviceAccountKey = "inline-key",
+ serviceAccountKeyPath = "sa-key.json",
+ privateKeyPath = "/path/to/private.pem",
+ tokenEndpoint = "https://custom/token"
+ )
+ val coreConfig = config.toCoreConfiguration()
+ assertNotNull(coreConfig)
+ }
+
+ @Test
+ fun `toCoreConfiguration handles null fields`() {
+ val config = AuthConfig(
+ serviceAccountKeyPath = "sa-key.json",
+ privateKeyPath = null,
+ tokenEndpoint = null
+ )
+ val coreConfig = config.toCoreConfiguration()
+ assertNotNull(coreConfig)
+ }
+}
+
+class TestStackitAuthProvider {
+ @Test
+ fun `throws when no credentials configured`() {
+ val config = AuthConfig()
+ assertThrows {
+ StackitAuthProvider(config)
+ }
+ }
+
+ @Test
+ fun `throws when serviceAccountKey is blank`() {
+ val config = AuthConfig(serviceAccountKey = " ")
+ assertThrows {
+ StackitAuthProvider(config)
+ }
+ }
+
+ @Test
+ fun `throws when serviceAccountKeyPath is blank`() {
+ val config = AuthConfig(serviceAccountKeyPath = " ")
+ assertThrows {
+ StackitAuthProvider(config)
+ }
+ }
+
+ @Test
+ fun `accepts valid serviceAccountKeyPath`() {
+ val config = AuthConfig(serviceAccountKeyPath = "sa-key.json")
+ assertDoesNotThrow {
+ StackitAuthProvider(config)
+ }
+ }
+
+ @Test
+ @Disabled("Requires valid network access to STACKIT authorization endpoint")
+ fun `accepts valid serviceAccountKey`() {
+ val config = AuthConfig(serviceAccountKeyPath = "sa-key.json")
+ assertDoesNotThrow {
+ val provider = StackitAuthProvider(config)
+ val token = provider.getToken()
+ // Token should be non-empty if the sa-key.json is valid
+ // This test validates the provider creation, not the token value
+ assertNotNull(provider)
+ }
+ }
+}
+
+class TestAuthProviderInterface {
+ @Test
+ @Disabled("Requires network access to STACKIT authorization endpoint")
+ fun `StackitAuthProvider implements AuthProvider`() {
+ val config = AuthConfig(serviceAccountKeyPath = "sa-key.json")
+ val provider: AuthProvider = StackitAuthProvider(config)
+ assertNotNull(provider)
+ }
+
+ @Test
+ fun `MockAuthProvider implements AuthProvider`() {
+ val provider: AuthProvider = MockAuthProvider()
+ assertNotNull(provider)
+ }
+
+ @Test
+ fun `MockAuthProvider returns default token`() {
+ val provider = MockAuthProvider()
+ val token = provider.getToken()
+ assertEquals("mock-fake-token-12345", token)
+ }
+
+ @Test
+ fun `MockAuthProvider returns custom token when set`() {
+ val provider = MockAuthProvider()
+ provider.setToken("custom-mock-token")
+ val token = provider.getToken()
+ assertEquals("custom-mock-token", token)
+ }
+
+ @Test
+ fun `MockAuthProvider getAuthHeader returns correct format`() {
+ val provider = MockAuthProvider()
+ val header = provider.getAuthHeader()
+ assertTrue(header.containsKey("Authorization"))
+ assertTrue(header["Authorization"]!!.startsWith("Bearer "))
+ }
+
+ @Test
+ fun `MockAuthProvider getAuthHeader uses current token`() {
+ val provider = MockAuthProvider()
+ provider.setToken("my-test-token")
+ val header = provider.getAuthHeader()
+ assertEquals("Bearer my-test-token", header["Authorization"])
+ }
+
+ @Test
+ @Disabled("Requires network access to STACKIT authorization endpoint")
+ fun `StackitAuthProvider getAuthHeader returns Authorization header`() {
+ val config = AuthConfig(serviceAccountKeyPath = "sa-key.json")
+ val provider = StackitAuthProvider(config)
+ val header = provider.getAuthHeader()
+ assertTrue(header.containsKey("Authorization"))
+ assertTrue(header["Authorization"]!!.startsWith("Bearer "))
+ }
+
+ @Test
+ @Disabled("Requires network access to STACKIT authorization endpoint")
+ fun `StackitAuthProvider getToken returns non-empty string`() {
+ val config = AuthConfig(serviceAccountKeyPath = "sa-key.json")
+ val provider = StackitAuthProvider(config)
+ val token = provider.getToken()
+ // Token is non-empty when sa-key.json is valid
+ assertTrue(token.isNotEmpty())
+ }
+
+ @Test
+ @Disabled("Requires network access to STACKIT authorization endpoint")
+ fun `StackitAuthProvider getToken returns same token on cache hit`() {
+ val config = AuthConfig(serviceAccountKeyPath = "sa-key.json")
+ val provider = StackitAuthProvider(config)
+ val token1 = provider.getToken()
+ val token2 = provider.getToken()
+ assertEquals(token1, token2)
+ }
+
+ @Test
+ @Disabled("Requires network access to STACKIT authorization endpoint")
+ fun `StackitAuthProvider getAuthHeader returns correct format`() {
+ val config = AuthConfig(serviceAccountKeyPath = "sa-key.json")
+ val provider = StackitAuthProvider(config)
+ val header = provider.getAuthHeader()
+ val authValue = header["Authorization"]!!
+ assertTrue(authValue.startsWith("Bearer "))
+ assertTrue(authValue.length > "Bearer ".length)
+ }
+}
diff --git a/src/test/kotlin/cloud/stackit/pubsub/sdk/client/TestClient.kt b/src/test/kotlin/cloud/stackit/pubsub/sdk/client/TestClient.kt
new file mode 100644
index 0000000..938f1dd
--- /dev/null
+++ b/src/test/kotlin/cloud/stackit/pubsub/sdk/client/TestClient.kt
@@ -0,0 +1,173 @@
+package cloud.stackit.pubsub.sdk.client
+
+import cloud.stackit.pubsub.sdk.auth.AuthConfig
+import cloud.stackit.pubsub.sdk.config.Environment
+import cloud.stackit.pubsub.sdk.config.Region
+import cloud.stackit.pubsub.sdk.retry.RetryConfig
+import cloud.stackit.pubsub.sdk.test.MockAuthProvider
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.Assertions.*
+import java.util.UUID
+
+class TestPubSubClientInit {
+ @Test
+ fun `creates client with topicId`() {
+ val topicId = UUID.randomUUID()
+ val config = AuthConfig(serviceAccountKeyPath = "sa-key.json")
+ val client = PubSubClient(topicId = topicId, authConfig = config)
+ assertEquals(topicId, client.topicId)
+ }
+
+ @Test
+ fun `accepts custom AuthConfig`() {
+ val topicId = UUID.randomUUID()
+ val customConfig = AuthConfig(serviceAccountKey = "test-key")
+ val client = PubSubClient(topicId = topicId, authConfig = customConfig)
+ assertNotNull(client.authProvider)
+ }
+
+ @Test
+ fun `sets default region to EU01`() {
+ val config = AuthConfig(serviceAccountKeyPath = "sa-key.json")
+ val client = PubSubClient(topicId = UUID.randomUUID(), authConfig = config)
+ assertEquals(Region.EU01, client.region)
+ }
+
+ @Test
+ fun `sets default environment to PROD`() {
+ val config = AuthConfig(serviceAccountKeyPath = "sa-key.json")
+ val client = PubSubClient(topicId = UUID.randomUUID(), authConfig = config)
+ assertEquals(Environment.PROD, client.environment)
+ }
+
+ @Test
+ fun `accepts custom region`() {
+ val config = AuthConfig(serviceAccountKeyPath = "sa-key.json")
+ val client = PubSubClient(topicId = UUID.randomUUID(), authConfig = config, region = Region.EU01)
+ assertEquals(Region.EU01, client.region)
+ }
+
+ @Test
+ fun `accepts custom environment`() {
+ val config = AuthConfig(serviceAccountKeyPath = "sa-key.json")
+ val client = PubSubClient(topicId = UUID.randomUUID(), authConfig = config, environment = Environment.QA)
+ assertEquals(Environment.QA, client.environment)
+ }
+
+ @Test
+ fun `builds dataplaneUrl with topicId`() {
+ val topicId = UUID.fromString("123e4567-e89b-12d3-a456-426614174000")
+ val config = AuthConfig(serviceAccountKeyPath = "sa-key.json")
+ val client = PubSubClient(topicId = topicId, authConfig = config)
+ assertTrue(client.baseDataplaneUrl.contains(topicId.toString()))
+ }
+
+ @Test
+ fun `builds correct url structure`() {
+ val topicId = UUID.fromString("123e4567-e89b-12d3-a456-426614174000")
+ val config = AuthConfig(serviceAccountKeyPath = "sa-key.json")
+ val client = PubSubClient(topicId = topicId, authConfig = config, region = Region.EU01)
+ assertEquals("https://$topicId.pubsub.eu01.onstackit.cloud", client.baseDataplaneUrl)
+ }
+
+ @Test
+ fun `builds url without prefix for PROD environment`() {
+ val config = AuthConfig(serviceAccountKeyPath = "sa-key.json")
+ val client = PubSubClient(topicId = UUID.randomUUID(), authConfig = config, environment = Environment.PROD)
+ assertFalse(client.baseDataplaneUrl.contains(".dev."))
+ assertFalse(client.baseDataplaneUrl.contains(".qa."))
+ }
+
+ @Test
+ fun `builds url with dev prefix for DEV environment`() {
+ val config = AuthConfig(serviceAccountKeyPath = "sa-key.json")
+ val client = PubSubClient(topicId = UUID.randomUUID(), authConfig = config, environment = Environment.DEV)
+ assertTrue(client.baseDataplaneUrl.contains(".dev."))
+ }
+
+ @Test
+ fun `builds url with qa prefix for QA environment`() {
+ val config = AuthConfig(serviceAccountKeyPath = "sa-key.json")
+ val client = PubSubClient(topicId = UUID.randomUUID(), authConfig = config, environment = Environment.QA)
+ assertTrue(client.baseDataplaneUrl.contains(".qa."))
+ }
+}
+
+class TestPubSubClientFactories {
+ @Test
+ fun `getPublisher returns configured publisher`() {
+ val topicId = UUID.randomUUID()
+ val authProvider = MockAuthProvider()
+ val publisher = cloud.stackit.pubsub.sdk.publisher.PubSubPublisher(
+ topicId = topicId,
+ baseDataplaneUrl = "https://test.pubsub.eu01.onstackit.cloud",
+ authProvider = authProvider
+ )
+ assertEquals(0, publisher.publishCalls.get())
+ }
+
+ @Test
+ fun `getSubscriber returns configured subscriber`() {
+ val subscriptionId = UUID.randomUUID()
+ val authProvider = MockAuthProvider()
+ val subscriber = cloud.stackit.pubsub.sdk.subscriber.PubSubSubscriber(
+ subscriptionId = subscriptionId,
+ baseDataplaneUrl = "https://test.pubsub.eu01.onstackit.cloud",
+ authProvider = authProvider
+ )
+ assertNotNull(subscriber)
+ }
+
+ @Test
+ fun `getPublisher uses shared auth provider`() {
+ val topicId = UUID.randomUUID()
+ val config = AuthConfig(serviceAccountKey = "test-key")
+ val client = PubSubClient(topicId = topicId, authConfig = config)
+ val publisher = client.getPublisher()
+ assertNotNull(publisher)
+ assertEquals(0, publisher.publishCalls.get())
+ }
+
+ @Test
+ fun `getSubscriber uses shared auth provider`() {
+ val topicId = UUID.randomUUID()
+ val subscriptionId = UUID.randomUUID()
+ val config = AuthConfig(serviceAccountKey = "test-key")
+ val client = PubSubClient(topicId = topicId, authConfig = config)
+ val subscriber = client.getSubscriber(subscriptionId)
+ assertNotNull(subscriber)
+ }
+
+ @Test
+ fun `getPublisher with null retryConfig uses default`() {
+ val topicId = UUID.randomUUID()
+ val config = AuthConfig(serviceAccountKey = "test-key")
+ val client = PubSubClient(topicId = topicId, authConfig = config)
+ val publisher = client.getPublisher(retryConfig = null)
+ assertNotNull(publisher)
+ }
+
+ @Test
+ fun `getSubscriber with null retryConfig uses default`() {
+ val topicId = UUID.randomUUID()
+ val subscriptionId = UUID.randomUUID()
+ val config = AuthConfig(serviceAccountKey = "test-key")
+ val client = PubSubClient(topicId = topicId, authConfig = config)
+ val subscriber = client.getSubscriber(subscriptionId, retryConfig = null)
+ assertNotNull(subscriber)
+ }
+
+ @Test
+ fun `getSubscriber respects maxMessages and longPullDurationMs`() {
+ val topicId = UUID.randomUUID()
+ val subscriptionId = UUID.randomUUID()
+ val config = AuthConfig(serviceAccountKey = "test-key")
+ val client = PubSubClient(topicId = topicId, authConfig = config)
+ val subscriber = client.getSubscriber(
+ subscriptionId,
+ maxMessages = 8,
+ longPullDurationMs = 3000
+ )
+ assertNotNull(subscriber)
+ }
+}
diff --git a/src/test/kotlin/cloud/stackit/pubsub/sdk/concurrency/TestConcurrency.kt b/src/test/kotlin/cloud/stackit/pubsub/sdk/concurrency/TestConcurrency.kt
new file mode 100644
index 0000000..d89079a
--- /dev/null
+++ b/src/test/kotlin/cloud/stackit/pubsub/sdk/concurrency/TestConcurrency.kt
@@ -0,0 +1,248 @@
+package cloud.stackit.pubsub.sdk.publisher
+
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import cloud.stackit.pubsub.sdk.models.PublishMessage
+import cloud.stackit.pubsub.sdk.test.MockAuthProvider
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.Assertions.*
+import java.util.UUID
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicInteger
+import kotlin.concurrent.thread
+
+class TestPublisherConcurrency {
+ private val server = MockWebServer()
+ private lateinit var baseUrl: String
+ private lateinit var authProvider: MockAuthProvider
+
+ @BeforeEach
+ fun setUp() {
+ server.start()
+ baseUrl = server.url("/").toString()
+ authProvider = MockAuthProvider()
+ }
+
+ @AfterEach
+ fun tearDown() {
+ server.shutdown()
+ }
+
+ @Test
+ fun `concurrent publishes from multiple threads`() {
+ val threadCount = 5
+ val messagesPerThread = 2
+ val totalMessages = threadCount * messagesPerThread
+ val latch = CountDownLatch(threadCount)
+ val successCount = AtomicInteger(0)
+
+ repeat(totalMessages) {
+ server.enqueue(
+ MockResponse()
+ .setResponseCode(200)
+ .setBody("""{"messageIds":[${it}]}""")
+ .addHeader("Content-Type", "application/json")
+ )
+ }
+
+ val executor: ExecutorService = Executors.newFixedThreadPool(threadCount)
+
+ try {
+ repeat(threadCount) { threadId ->
+ executor.submit {
+ try {
+ val publisher = PubSubPublisher(
+ topicId = UUID.randomUUID(),
+ baseDataplaneUrl = baseUrl,
+ authProvider = authProvider
+ )
+ val messages = List(messagesPerThread) { PublishMessage("dGVzdA==") }
+ publisher.publish(messages)
+ successCount.incrementAndGet()
+ } finally {
+ latch.countDown()
+ }
+ }
+ }
+
+ val completed = latch.await(30, TimeUnit.SECONDS)
+ assertTrue(completed, "All threads should complete within timeout")
+
+ assertEquals(threadCount, successCount.get(), "All threads should succeed")
+ } finally {
+ executor.shutdown()
+ executor.awaitTermination(5, TimeUnit.SECONDS)
+ }
+ }
+
+ @Test
+ fun `concurrent publishes share publisher safely`() {
+ val threadCount = 10
+ val latch = CountDownLatch(threadCount)
+ val successCount = AtomicInteger(0)
+
+ repeat(threadCount) {
+ server.enqueue(
+ MockResponse()
+ .setResponseCode(200)
+ .setBody("""{"messageIds":[1]}""")
+ .addHeader("Content-Type", "application/json")
+ )
+ }
+
+ val publisher = PubSubPublisher(
+ topicId = UUID.randomUUID(),
+ baseDataplaneUrl = baseUrl,
+ authProvider = authProvider
+ )
+
+ val threads = List(threadCount) {
+ thread {
+ try {
+ publisher.publish(listOf(PublishMessage("dGVzdA==")))
+ successCount.incrementAndGet()
+ } finally {
+ latch.countDown()
+ }
+ }
+ }
+
+ val completed = latch.await(30, TimeUnit.SECONDS)
+ assertTrue(completed, "All threads should complete within timeout")
+
+ threads.forEach { it.join() }
+
+ assertEquals(threadCount, successCount.get())
+ assertEquals(threadCount, publisher.publishCalls.get())
+ assertEquals(threadCount, publisher.messagesSent.get())
+ }
+
+ @Test
+ fun `concurrent ack and nack operations`() {
+ val threadCount = 10
+ val latch = CountDownLatch(threadCount)
+ val successCount = AtomicInteger(0)
+
+ repeat(threadCount) {
+ server.enqueue(MockResponse().setResponseCode(204).setBody(""))
+ }
+
+ val subscriber = cloud.stackit.pubsub.sdk.subscriber.PubSubSubscriber(
+ subscriptionId = UUID.randomUUID(),
+ baseDataplaneUrl = baseUrl,
+ authProvider = MockAuthProvider()
+ )
+
+ val threads = List(threadCount) { id ->
+ thread {
+ try {
+ if (id % 2 == 0) {
+ subscriber.ack(listOf("ack-$id"))
+ } else {
+ subscriber.nack(listOf("nack-$id"))
+ }
+ successCount.incrementAndGet()
+ } finally {
+ latch.countDown()
+ }
+ }
+ }
+
+ val completed = latch.await(30, TimeUnit.SECONDS)
+ assertTrue(completed)
+
+ assertEquals(threadCount, successCount.get())
+ threads.forEach { it.join() }
+ }
+}
+
+class TestClientConcurrency {
+ private val server = MockWebServer()
+ private lateinit var baseUrl: String
+
+ @BeforeEach
+ fun setUp() {
+ server.start()
+ baseUrl = server.url("/").toString()
+ }
+
+ @AfterEach
+ fun tearDown() {
+ server.shutdown()
+ }
+
+ @Test
+ fun `multiple publishers from same client`() {
+ try {
+ val topicId = UUID.randomUUID()
+ val authProvider = MockAuthProvider()
+
+ repeat(5) {
+ server.enqueue(
+ MockResponse()
+ .setResponseCode(200)
+ .setBody("""{"messageIds":[1]}""")
+ .addHeader("Content-Type", "application/json")
+ )
+ }
+
+ val publishers = List(5) {
+ PubSubPublisher(
+ topicId = topicId,
+ baseDataplaneUrl = baseUrl,
+ authProvider = authProvider
+ )
+ }
+ publishers.forEach { it.publish(listOf(PublishMessage("dGVzdA=="))) }
+
+ assertEquals(5, publishers.sumOf { it.publishCalls.get() })
+ } finally {
+ server.shutdown()
+ }
+ }
+
+ @Test
+ fun `publisher and subscriber from same client`() {
+ try {
+ val topicId = UUID.randomUUID()
+ val subscriptionId = UUID.randomUUID()
+ val authProvider = MockAuthProvider()
+
+ server.enqueue(
+ MockResponse()
+ .setResponseCode(200)
+ .setBody("""{"messageIds":[1]}""")
+ .addHeader("Content-Type", "application/json")
+ )
+ server.enqueue(
+ MockResponse()
+ .setResponseCode(200)
+ .setBody("""{"messages":[]}""")
+ .addHeader("Content-Type", "application/json")
+ )
+
+ val publisher = PubSubPublisher(
+ topicId = topicId,
+ baseDataplaneUrl = baseUrl,
+ authProvider = authProvider
+ )
+ publisher.publish(listOf(PublishMessage("dGVzdA==")))
+
+ val subscriber = cloud.stackit.pubsub.sdk.subscriber.PubSubSubscriber(
+ subscriptionId = subscriptionId,
+ baseDataplaneUrl = baseUrl,
+ authProvider = authProvider
+ )
+ subscriber.pull()
+
+ assertEquals(1, publisher.publishCalls.get())
+ } finally {
+ server.shutdown()
+ }
+ }
+}
diff --git a/src/test/kotlin/cloud/stackit/pubsub/sdk/integration/IntegrationTest.kt b/src/test/kotlin/cloud/stackit/pubsub/sdk/integration/IntegrationTest.kt
new file mode 100644
index 0000000..7799a07
--- /dev/null
+++ b/src/test/kotlin/cloud/stackit/pubsub/sdk/integration/IntegrationTest.kt
@@ -0,0 +1,529 @@
+package cloud.stackit.pubsub.sdk.integration
+
+import cloud.stackit.pubsub.sdk.auth.AuthConfig
+import cloud.stackit.pubsub.sdk.auth.StackitAuthProvider
+import cloud.stackit.pubsub.sdk.client.PubSubClient
+import cloud.stackit.pubsub.sdk.config.Environment
+import cloud.stackit.pubsub.sdk.config.Region
+import cloud.stackit.pubsub.sdk.models.Message
+import cloud.stackit.pubsub.sdk.models.PublishMessage
+import cloud.stackit.pubsub.sdk.retry.RetryConfig
+import com.google.gson.Gson
+import com.google.gson.JsonObject
+import okhttp3.*
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.RequestBody.Companion.toRequestBody
+import org.junit.jupiter.api.*
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.Assumptions.assumeTrue
+import java.io.File
+import java.io.IOException
+import java.nio.charset.StandardCharsets
+import java.util.Base64
+import java.util.UUID
+
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+@TestMethodOrder(MethodOrderer.OrderAnnotation::class)
+class IntegrationTest {
+ companion object {
+ @JvmStatic
+ fun isIntegrationTestEnabled(): Boolean {
+ return System.getenv("STACKIT_PROJECT_ID") != null
+ }
+ }
+
+ private val gson = Gson()
+ private val httpClient = OkHttpClient.Builder().build()
+ private lateinit var authProvider: StackitAuthProvider
+ private lateinit var projectNumber: String
+ private lateinit var topicId: String
+ private lateinit var subscriptionId: String
+ private lateinit var baseControlPlaneUrl: String
+ private lateinit var baseDataplaneUrl: String
+ private lateinit var authConfig: AuthConfig
+ private lateinit var saEmail: String
+
+ @BeforeAll
+ @Throws(Exception::class)
+ fun setupOnce() {
+ val project = System.getenv("STACKIT_PROJECT_ID")
+ assumeTrue({ project != null }, "STACKIT_PROJECT_ID environment variable not set")
+
+ val saKeyB64 = System.getenv("SA_KEY_B64")
+ val saKey = if (saKeyB64 != null && saKeyB64.isNotBlank()) {
+ String(Base64.getDecoder().decode(saKeyB64), StandardCharsets.UTF_8)
+ } else {
+ File("sa-key.json").readText()
+ }
+
+ saEmail = gson.fromJson(saKey, JsonObject::class.java)
+ .get("credentials")
+ .asJsonObject
+ .get("iss")
+ .asString
+
+ val saKeyFile = File.createTempFile("sa-key-", ".json")
+ saKeyFile.writeText(saKey)
+ saKeyFile.deleteOnExit()
+
+ val tokenUrl = System.getenv("TOKEN_URL") ?: "https://accounts.qa.stackit.cloud/oauth/v2/token"
+ authConfig = AuthConfig(serviceAccountKeyPath = saKeyFile.absolutePath, tokenEndpoint = tokenUrl)
+ authProvider = StackitAuthProvider(authConfig)
+ projectNumber = project
+ baseControlPlaneUrl = "https://pubsub-dev.api.${Region.EU01.value}.qa.stackit.cloud"
+
+ createTopic()
+ createSubscription()
+
+ baseDataplaneUrl = System.getenv("BASE_URL") ?: "https://$topicId.pubsub.${Region.EU01.value}.dev.onstackit.cloud"
+ }
+
+ @AfterAll
+ @Throws(Exception::class)
+ fun cleanupOnce() {
+ if (!::baseControlPlaneUrl.isInitialized) return
+ try {
+ deleteSubscription()
+ purgeTopic()
+ deleteTopic()
+ } catch (e: Exception) {
+ println("Cleanup failed: ${e.message}")
+ }
+ }
+
+ private fun createTopic() {
+ val tempId = UUID.randomUUID().toString().take(24)
+ val requestBody = """
+ {
+ "displayName": "sdk-test-$tempId",
+ "description": "Created by SDK integration tests"
+ }
+ """.trimIndent()
+
+ val mediaType = "application/json".toMediaType()
+ val body = requestBody.toRequestBody(mediaType)
+
+ val request = Request.Builder()
+ .url("$baseControlPlaneUrl/v1beta/projects/$projectNumber/regions/${Region.EU01.value}/topics")
+ .addHeader("Content-Type", "application/json")
+ .addHeader("Accept", "application/json")
+ .addHeader("Authorization", "Bearer ${authProvider.getToken()}")
+ .post(body)
+ .build()
+
+ val response = httpClient.newCall(request).execute()
+ val responseBody = response.body?.string() ?: ""
+
+ if (response.code == 409) {
+ println("Topic already exists, skipping creation")
+ return
+ }
+
+ if (!response.isSuccessful) {
+ throw IOException("Failed to create topic: $responseBody")
+ }
+
+ val topic = gson.fromJson(responseBody, JsonObject::class.java)
+ topicId = topic.get("id").asString
+ println("Topic created: $topicId")
+
+ waitForResourceActive("topics", topicId)
+ grantPublisherAccess(topicId)
+ }
+
+ private fun createSubscription() {
+ val tempId = UUID.randomUUID().toString().take(24)
+ val requestBody = """
+ {
+ "displayName": "sdk-sub-$tempId",
+ "description": "Created by SDK integration tests"
+ }
+ """.trimIndent()
+
+ val mediaType = "application/json".toMediaType()
+ val body = requestBody.toRequestBody(mediaType)
+
+ val request = Request.Builder()
+ .url("$baseControlPlaneUrl/v1beta/projects/$projectNumber/regions/${Region.EU01.value}/topics/$topicId/subscriptions")
+ .addHeader("Content-Type", "application/json")
+ .addHeader("Accept", "application/json")
+ .addHeader("Authorization", "Bearer ${authProvider.getToken()}")
+ .post(body)
+ .build()
+
+ val response = httpClient.newCall(request).execute()
+ val responseBody = response.body?.string() ?: ""
+
+ if (response.code == 409) {
+ println("Subscription already exists, skipping creation")
+ return
+ }
+
+ if (!response.isSuccessful) {
+ throw IOException("Failed to create subscription: $responseBody")
+ }
+
+ val sub = gson.fromJson(responseBody, JsonObject::class.java)
+ subscriptionId = sub.get("id").asString
+ println("Subscription created: $subscriptionId")
+
+ waitForResourceActive("subscriptions", subscriptionId, topicId)
+ grantSubscriberAccess(subscriptionId)
+ }
+
+ private fun grantPublisherAccess(topicId: String) {
+ val requestBody = """
+ {
+ "emailAddress": "$saEmail"
+ }
+ """.trimIndent()
+
+ val mediaType = "application/json".toMediaType()
+ val body = requestBody.toRequestBody(mediaType)
+
+ val request = Request.Builder()
+ .url("$baseControlPlaneUrl/v1beta/projects/$projectNumber/regions/${Region.EU01.value}/topics/$topicId/publishers")
+ .addHeader("Content-Type", "application/json")
+ .addHeader("Accept", "application/json")
+ .addHeader("Authorization", "Bearer ${authProvider.getToken()}")
+ .patch(body)
+ .build()
+
+ val response = httpClient.newCall(request).execute()
+ val responseBody = response.body?.string() ?: ""
+
+ if (response.isSuccessful) {
+ println("Granted publisher access for $saEmail")
+ } else {
+ println("Warning: Failed to grant publisher access: $responseBody")
+ }
+ }
+
+ private fun grantSubscriberAccess(subscriptionId: String) {
+ val requestBody = """
+ {
+ "emailAddress": "$saEmail"
+ }
+ """.trimIndent()
+
+ val mediaType = "application/json".toMediaType()
+ val body = requestBody.toRequestBody(mediaType)
+
+ val request = Request.Builder()
+ .url("$baseControlPlaneUrl/v1beta/projects/$projectNumber/regions/${Region.EU01.value}/topics/$topicId/subscriptions/$subscriptionId/subscribers")
+ .addHeader("Content-Type", "application/json")
+ .addHeader("Accept", "application/json")
+ .addHeader("Authorization", "Bearer ${authProvider.getToken()}")
+ .patch(body)
+ .build()
+
+ val response = httpClient.newCall(request).execute()
+ val responseBody = response.body?.string() ?: ""
+
+ if (response.isSuccessful) {
+ println("Granted subscriber access for $saEmail")
+ } else {
+ println("Warning: Failed to grant subscriber access: $responseBody")
+ }
+ }
+
+ private fun waitForResourceActive(
+ resourceType: String,
+ resourceId: String,
+ topicIdOverride: String? = null
+ ) {
+ val start = System.currentTimeMillis()
+ val timeoutMs = 600_000L // 10 minutes
+ val pollIntervalMs = 10_000L // 10 seconds
+
+ while (System.currentTimeMillis() - start < timeoutMs) {
+ try {
+ val resourceTopicId = topicIdOverride ?: resourceId
+ val url = if (resourceType == "subscriptions") {
+ "$baseControlPlaneUrl/v1beta/projects/$projectNumber/regions/${Region.EU01.value}/topics/$resourceTopicId/subscriptions/$resourceId"
+ } else {
+ "$baseControlPlaneUrl/v1beta/projects/$projectNumber/regions/${Region.EU01.value}/$resourceType/$resourceId"
+ }
+
+ val request = Request.Builder()
+ .url(url)
+ .addHeader("Accept", "application/json")
+ .addHeader("Authorization", "Bearer ${authProvider.getToken()}")
+ .get()
+ .build()
+
+ val response = httpClient.newCall(request).execute()
+ if (!response.isSuccessful) {
+ println("$resourceType $resourceId not reachable yet (${response.code}), retrying...")
+ Thread.sleep(pollIntervalMs)
+ continue
+ }
+
+ val body = response.body?.string() ?: ""
+ val resource = gson.fromJson(body, JsonObject::class.java)
+ val state = resource.get("state")?.asString
+
+ if (state == "active") {
+ println("$resourceType $resourceId is active")
+ return
+ }
+ if (state == "deleting") {
+ throw RuntimeException("$resourceType entered deleting state: $resourceId")
+ }
+
+ if (System.currentTimeMillis() - start > 0 && (System.currentTimeMillis() - start) % 30000 < pollIntervalMs) {
+ println("Waiting for $resourceType $resourceId to become active (state: $state)")
+ }
+
+ Thread.sleep(pollIntervalMs)
+ } catch (e: IOException) {
+ println("$resourceType $resourceId not reachable yet: ${e.message}, retrying...")
+ Thread.sleep(pollIntervalMs)
+ }
+ }
+
+ throw IOException("$resourceType $resourceId did not become active within 10 minutes")
+ }
+
+ private fun deleteTopic() {
+ val request = Request.Builder()
+ .url("$baseControlPlaneUrl/v1beta/projects/$projectNumber/regions/${Region.EU01.value}/topics/$topicId")
+ .addHeader("Accept", "application/json")
+ .addHeader("Authorization", "Bearer ${authProvider.getToken()}")
+ .delete()
+ .build()
+
+ try {
+ val response = httpClient.newCall(request).execute()
+ if (!response.isSuccessful && response.code != 404) {
+ println("Warning: Failed to delete topic: ${response.body?.string()}")
+ }
+ } catch (e: IOException) {
+ println("Warning: Failed to delete topic: ${e.message}")
+ }
+ }
+
+ private fun deleteSubscription() {
+ val request = Request.Builder()
+ .url("$baseControlPlaneUrl/v1beta/projects/$projectNumber/regions/${Region.EU01.value}/topics/$topicId/subscriptions/$subscriptionId")
+ .addHeader("Accept", "application/json")
+ .addHeader("Authorization", "Bearer ${authProvider.getToken()}")
+ .delete()
+ .build()
+
+ try {
+ val response = httpClient.newCall(request).execute()
+ if (!response.isSuccessful && response.code != 404) {
+ println("Warning: Failed to delete subscription: ${response.body?.string()}")
+ }
+ } catch (e: IOException) {
+ println("Warning: Failed to delete subscription: ${e.message}")
+ }
+ }
+
+ private fun purgeTopic() {
+ val mediaType = "application/json".toMediaType()
+
+ val request = Request.Builder()
+ .url("$baseDataplaneUrl/v1alpha/purge")
+ .post("{}".toRequestBody(mediaType))
+ .addHeader("Content-Type", "application/json")
+ .addHeader("Accept", "application/json")
+ .addHeader("Authorization", "Bearer ${authProvider.getToken()}")
+ .build()
+
+ try {
+ val response = httpClient.newCall(request).execute()
+ if (!response.isSuccessful && response.code != 404) {
+ println("Warning: Failed to purge topic: ${response.code}")
+ }
+ } catch (e: IOException) {
+ println("Warning: Failed to purge topic: ${e.message}")
+ }
+ }
+
+ @Test
+ @Order(1)
+ @Throws(Exception::class)
+ fun `full pubsub flow - publish and subscribe`() {
+ val client = PubSubClient(
+ topicId = UUID.fromString(topicId),
+ authConfig = authConfig,
+ region = Region.EU01,
+ environment = Environment.DEV
+ )
+
+ val publisher = client.getPublisher(RetryConfig(maxRetries = 2, baseDelay = 100, maxDelay = 2000))
+ val subscriber = client.getSubscriber(
+ subscriptionId = UUID.fromString(subscriptionId),
+ retryConfig = RetryConfig(maxRetries = 2, baseDelay = 100, maxDelay = 2000),
+ maxMessages = 10,
+ longPullDurationMs = 1000
+ )
+
+ val testMessages = listOf(
+ PublishMessage("aGVsbG8gd29ybGQ="),
+ PublishMessage("dGVzdCBtZXNzYWdl"),
+ PublishMessage("cHViIHN1YnNjcmliZXIgdGVzdA==")
+ )
+
+ val publishResponse = publisher.publish(testMessages)
+ assertEquals(3, publishResponse.messageIds.size)
+ assertEquals(3, publisher.messagesSent.get())
+ assertEquals(1, publisher.publishCalls.get())
+
+ Thread.sleep(2000)
+
+ var pulledMessages: List = emptyList()
+ var attempts = 0
+ val maxAttempts = 10
+
+ while (pulledMessages.isEmpty() && attempts < maxAttempts) {
+ pulledMessages = subscriber.pull()
+ if (pulledMessages.isEmpty()) {
+ Thread.sleep(1000)
+ attempts++
+ }
+ }
+
+ assertTrue(pulledMessages.size >= 3, "Expected at least 3 messages, got ${pulledMessages.size}")
+
+ val ackIds = pulledMessages.take(3).map { it.ackId }
+ subscriber.ack(ackIds)
+
+ assertTrue(pulledMessages.all { msg ->
+ msg.id in publishResponse.messageIds || msg.text in listOf("hello world", "test message", "pub subscriber test")
+ })
+ }
+
+ @Test
+ @Order(2)
+ @Throws(Exception::class)
+ fun `publish batch and nack messages`() {
+ val client = PubSubClient(
+ topicId = UUID.fromString(topicId),
+ authConfig = authConfig,
+ region = Region.EU01,
+ environment = Environment.DEV
+ )
+
+ val publisher = client.getPublisher(RetryConfig(maxRetries = 2, baseDelay = 100, maxDelay = 2000))
+ val subscriber = client.getSubscriber(
+ subscriptionId = UUID.fromString(subscriptionId),
+ retryConfig = RetryConfig(maxRetries = 2, baseDelay = 100, maxDelay = 2000),
+ maxMessages = 10,
+ longPullDurationMs = 1000
+ )
+
+ val allMessages = (1..20).map { i ->
+ PublishMessage("bGFyZ2UtYmF0Y2gtbWVzc2FnZSAtICRp")
+ }
+
+ val batchResults = publisher.publishBatch(allMessages, batchSize = 10)
+ assertEquals(2, batchResults.size)
+ val totalIds = batchResults.sumOf { it.messageIds.size }
+ assertEquals(20, totalIds)
+ assertEquals(20, publisher.messagesSent.get())
+
+ Thread.sleep(2000)
+
+ var pulledMessages: List = emptyList()
+ var attempts = 0
+ val maxAttempts = 10
+
+ while (pulledMessages.isEmpty() && attempts < maxAttempts) {
+ pulledMessages = subscriber.pull()
+ if (pulledMessages.isEmpty()) {
+ Thread.sleep(1000)
+ attempts++
+ }
+ }
+
+ if (pulledMessages.isNotEmpty()) {
+ val nackIds = pulledMessages.take(minOf(3, pulledMessages.size)).map { it.ackId }
+ subscriber.nack(nackIds)
+
+ assertTrue(pulledMessages.all { msg ->
+ msg.text.startsWith("large-batch-message - ")
+ })
+ }
+ }
+
+ @Test
+ @Order(3)
+ @Throws(Exception::class)
+ fun `client factory methods share auth provider`() {
+ val client = PubSubClient(
+ topicId = UUID.fromString(topicId),
+ authConfig = authConfig,
+ region = Region.EU01,
+ environment = Environment.DEV
+ )
+
+ val publisher = client.getPublisher()
+ val subscriber = client.getSubscriber(UUID.fromString(subscriptionId))
+
+ assertNotNull(publisher)
+ assertNotNull(subscriber)
+ assert(client.baseDataplaneUrl.contains(topicId))
+ }
+
+ @Test
+ @Order(4)
+ @Throws(Exception::class)
+ fun `purge topic removes all messages`() {
+ val client = PubSubClient(
+ topicId = UUID.fromString(topicId),
+ authConfig = authConfig,
+ region = Region.EU01,
+ environment = Environment.DEV
+ )
+
+ val publisher = client.getPublisher()
+ publisher.purgeTopic()
+ assertEquals(1, publisher.purgeCalls.get())
+
+ Thread.sleep(1000)
+
+ val subscriber = client.getSubscriber(
+ subscriptionId = UUID.fromString(subscriptionId),
+ maxMessages = 10,
+ longPullDurationMs = 500
+ )
+
+ val messages = subscriber.pull()
+ assertTrue(messages.isEmpty(), "Expected empty list after purge, got ${messages.size} messages")
+ }
+
+ @Test
+ @Order(5)
+ @Throws(Exception::class)
+ fun `dataplane URL construction is correct`() {
+ val client = PubSubClient(
+ topicId = UUID.fromString(topicId),
+ authConfig = authConfig,
+ region = Region.EU01,
+ environment = Environment.DEV
+ )
+
+ val expectedUrl = "https://$topicId.pubsub.eu01.dev.onstackit.cloud"
+ assertEquals(expectedUrl, client.baseDataplaneUrl)
+ }
+
+ @Test
+ @Order(6)
+ @Throws(Exception::class)
+ fun `message text and payload properties work correctly`() {
+ val client = PubSubClient(
+ topicId = UUID.fromString(topicId),
+ authConfig = authConfig,
+ region = Region.EU01,
+ environment = Environment.DEV
+ )
+
+ val publisher = client.getPublisher()
+ val testMessage = PublishMessage("aGVsbG8gd29ybGQ=")
+ val response = publisher.publish(listOf(testMessage))
+ assertEquals(1, response.messageIds.size)
+ }
+}
diff --git a/src/test/kotlin/cloud/stackit/pubsub/sdk/models/TestModels.kt b/src/test/kotlin/cloud/stackit/pubsub/sdk/models/TestModels.kt
new file mode 100644
index 0000000..cc7ee5b
--- /dev/null
+++ b/src/test/kotlin/cloud/stackit/pubsub/sdk/models/TestModels.kt
@@ -0,0 +1,120 @@
+package cloud.stackit.pubsub.sdk.models
+
+import org.junit.jupiter.api.Test
+import java.time.Instant
+
+class TestPublishMessage {
+ @Test
+ fun `fromPlain creates message with data`() {
+ val msg = PublishMessage("SGVsbG8=")
+ assert(msg.data == "SGVsbG8=")
+ }
+
+ @Test
+ fun `fromBytes creates message with base64 encoded bytes`() {
+ val data = "aGVsbG8gd29ybGQ=".toByteArray().toString()
+ val msg = PublishMessage(data)
+ assert(msg.data == data)
+ }
+
+ @Test
+ fun `dataIsBase64Encoded validates data format`() {
+ val msg = PublishMessage("dGVzdCBtZXNzYWdl")
+ assert(msg.data == "dGVzdCBtZXNzYWdl")
+ }
+}
+
+class TestPublishMessagesRequest {
+ @Test
+ fun `creates request with messages`() {
+ val messages = listOf(PublishMessage("dGVzdDE="), PublishMessage("dGVzdDI="))
+ val request = PublishMessagesRequest(messages)
+ assert(request.messages == messages)
+ assert(request.messages.size == 2)
+ }
+}
+
+class TestPublishMessagesResponse {
+ @Test
+ fun `has messageIds`() {
+ val response = PublishMessagesResponse(listOf(1L, 2L, 3L))
+ assert(response.messageIds == listOf(1L, 2L, 3L))
+ }
+
+ @Test
+ fun `empty messageIds`() {
+ val response = PublishMessagesResponse(emptyList())
+ assert(response.messageIds.isEmpty())
+ }
+}
+
+class TestMessage {
+ @Test
+ fun `create message`() {
+ val msg = Message(
+ id = 1L,
+ ackId = "ack-1",
+ data = "SGVsbG8=",
+ createTime = Instant.parse("2024-01-01T00:00:00Z"),
+ deliveryAttempts = 1L
+ )
+ assert(msg.id == 1L)
+ assert(msg.ackId == "ack-1")
+ assert(msg.deliveryAttempts == 1L)
+ }
+
+ @Test
+ fun `payload decodes base64`() {
+ val msg = Message(
+ id = 1L,
+ ackId = "ack-1",
+ data = "dGVzdCBkYXRh",
+ createTime = Instant.parse("2024-01-01T00:00:00Z"),
+ deliveryAttempts = 0
+ )
+ val bytes = String(msg.payload)
+ assert(bytes == "test data")
+ }
+
+ @Test
+ fun `text decodes utf8`() {
+ val msg = Message(
+ id = 1L,
+ ackId = "ack-1",
+ data = "SGVsbG8sIFdvcmxkIQ==",
+ createTime = Instant.parse("2024-01-01T00:00:00Z"),
+ deliveryAttempts = 0
+ )
+ assert(msg.text == "Hello, World!")
+ }
+}
+
+class TestMessagesResponse {
+ @Test
+ fun `container holds messages`() {
+ val msg = Message(
+ id = 1L,
+ ackId = "ack-1",
+ data = "dGVzdA==",
+ createTime = Instant.parse("2024-01-01T00:00:00Z"),
+ deliveryAttempts = 0
+ )
+ val response = MessagesResponse(listOf(msg))
+ assert(response.messages.size == 1)
+ assert(response.messages[0].id == 1L)
+ }
+}
+
+class TestAckNackRequests {
+ @Test
+ fun `ack request`() {
+ val request = AckMessagesRequest(listOf("ack-1", "ack-2"))
+ assert(request.ackIds == listOf("ack-1", "ack-2"))
+ }
+
+ @Test
+ fun `nack request`() {
+ val request = NackMessagesRequest(listOf("nack-1", "nack-2"))
+ assert(request.nackIds == listOf("nack-1", "nack-2"))
+ }
+}
diff --git a/src/test/kotlin/cloud/stackit/pubsub/sdk/publisher/TestPublisher.kt b/src/test/kotlin/cloud/stackit/pubsub/sdk/publisher/TestPublisher.kt
new file mode 100644
index 0000000..2ba95ef
--- /dev/null
+++ b/src/test/kotlin/cloud/stackit/pubsub/sdk/publisher/TestPublisher.kt
@@ -0,0 +1,447 @@
+package cloud.stackit.pubsub.sdk.publisher
+
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import cloud.stackit.pubsub.sdk.errors.PubSubError
+import cloud.stackit.pubsub.sdk.models.PublishMessage
+import cloud.stackit.pubsub.sdk.retry.RetryConfig
+import cloud.stackit.pubsub.sdk.test.MockAuthProvider
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.Assertions.*
+import java.util.UUID
+
+class TestPubSubPublisherInit {
+ @Test
+ fun `creates publisher with valid topicId`() {
+ val server = MockWebServer()
+ server.start()
+ try {
+ val authProvider = MockAuthProvider()
+ val publisher = PubSubPublisher(
+ topicId = UUID.randomUUID(),
+ baseDataplaneUrl = server.url("/").toString(),
+ authProvider = authProvider
+ )
+ assertNotNull(publisher)
+ } finally {
+ server.shutdown()
+ }
+ }
+}
+
+class TestPublisherPublish {
+ private val server = MockWebServer()
+ private lateinit var baseUrl: String
+ private lateinit var authProvider: MockAuthProvider
+
+ @BeforeEach
+ fun setUp() {
+ server.start()
+ baseUrl = server.url("/").toString()
+ authProvider = MockAuthProvider()
+ }
+
+ @AfterEach
+ fun tearDown() {
+ server.shutdown()
+ }
+
+ private fun createPublisher(
+ response: MockResponse,
+ retryConfig: RetryConfig = RetryConfig.Default
+ ): PubSubPublisher {
+ server.enqueue(response)
+ return PubSubPublisher(
+ topicId = UUID.randomUUID(),
+ baseDataplaneUrl = baseUrl,
+ authProvider = authProvider,
+ retryConfig = retryConfig
+ )
+ }
+
+ @Test
+ fun `publish returns response with messageIds`() {
+ val response = MockResponse()
+ .setResponseCode(200)
+ .setBody("""{"messageIds":[1,2,3]}""")
+ .addHeader("Content-Type", "application/json")
+
+ val publisher = createPublisher(response)
+ val messages = listOf(PublishMessage("aGVsbG8="), PublishMessage("d29ybGQ="))
+ val result = publisher.publish(messages)
+
+ assertEquals(listOf(1L, 2L, 3L), result.messageIds)
+ assertEquals(2, publisher.messagesSent.get())
+ assertEquals(1, publisher.publishCalls.get())
+ }
+
+ @Test
+ fun `publish sends correct payload`() {
+ val response = MockResponse()
+ .setResponseCode(200)
+ .setBody("""{"messageIds":[1]}""")
+ .addHeader("Content-Type", "application/json")
+
+ val publisher = createPublisher(response)
+ publisher.publish(listOf(PublishMessage("dGVzdA==")))
+
+ val recordedRequest = server.takeRequest()
+ val requestBody = recordedRequest.body.readUtf8()
+ assertTrue(requestBody.contains("messages"))
+ }
+
+ @Test
+ fun `publish empty list throws`() {
+ val response = MockResponse()
+ .setResponseCode(200)
+ .setBody("""{"messageIds":[]}""")
+
+ val publisher = createPublisher(response)
+ assertThrows(IllegalArgumentException::class.java) {
+ publisher.publish(emptyList())
+ }
+ }
+
+ @Test
+ fun `publish too many messages throws`() {
+ val response = MockResponse()
+ .setResponseCode(200)
+ .setBody("""{"messageIds":[]}""")
+
+ val publisher = createPublisher(response)
+ val messages = List(101) { PublishMessage("dGVzdA==") }
+ assertThrows(IllegalArgumentException::class.java) {
+ publisher.publish(messages)
+ }
+ }
+
+ @Test
+ fun `publish batch splits messages`() {
+ val response = MockResponse()
+ .setResponseCode(200)
+ .setBody("""{"messageIds":[1]}""")
+
+ server.enqueue(response)
+ server.enqueue(response)
+ server.enqueue(response)
+
+ val publisher = PubSubPublisher(
+ topicId = UUID.randomUUID(),
+ baseDataplaneUrl = baseUrl,
+ authProvider = authProvider
+ )
+ val messages = List(5) { PublishMessage("dGVzdA==") }
+ val results = publisher.publishBatch(messages, batchSize = 2)
+
+ assertEquals(3, results.size)
+ assertEquals(3, publisher.publishCalls.get())
+ }
+
+ @Test
+ fun `publish batch empty returns empty list`() {
+ val response = MockResponse()
+ .setResponseCode(200)
+ .setBody("""{"messageIds":[]}""")
+
+ val publisher = createPublisher(response)
+ val results = publisher.publishBatch(emptyList())
+ assertTrue(results.isEmpty())
+ }
+
+ @Test
+ fun `publish 503 triggers retry then succeeds`() {
+ val errorResponse = MockResponse()
+ .setResponseCode(503)
+ .setBody("Service Unavailable")
+ val okResponse = MockResponse()
+ .setResponseCode(200)
+ .setBody("""{"messageIds":[42]}""")
+ .addHeader("Content-Type", "application/json")
+
+ server.enqueue(errorResponse)
+ server.enqueue(errorResponse)
+ server.enqueue(okResponse)
+
+ val retryConfig = RetryConfig(
+ maxRetries = 3,
+ baseDelay = 10,
+ maxDelay = 50,
+ multiplier = 1.0
+ )
+
+ val publisher = PubSubPublisher(
+ topicId = UUID.randomUUID(),
+ baseDataplaneUrl = baseUrl,
+ authProvider = authProvider,
+ retryConfig = retryConfig
+ )
+ val result = publisher.publish(listOf(PublishMessage("dGVzdA==")))
+
+ assertEquals(listOf(42L), result.messageIds)
+ assertEquals(3, server.requestCount)
+ }
+
+ @Test
+ fun `publish exhausts retries`() {
+ val errorResponse = MockResponse()
+ .setResponseCode(503)
+ .setBody("Service Unavailable")
+
+ repeat(10) {
+ server.enqueue(errorResponse)
+ }
+
+ val retryConfig = RetryConfig(
+ maxRetries = 2,
+ baseDelay = 10,
+ maxDelay = 50,
+ multiplier = 1.0
+ )
+
+ val publisher = PubSubPublisher(
+ topicId = UUID.randomUUID(),
+ baseDataplaneUrl = baseUrl,
+ authProvider = authProvider,
+ retryConfig = retryConfig
+ )
+ val thrown = assertThrows(PubSubError.HttpError.ServerError::class.java) {
+ publisher.publish(listOf(PublishMessage("dGVzdA==")))
+ }
+ assertTrue(thrown.message!!.contains("retries"))
+ assertEquals(3, server.requestCount)
+ }
+
+ @Test
+ fun `publish 400 throws bad request`() {
+ val errorResponse = MockResponse()
+ .setResponseCode(400)
+ .setBody("Bad request")
+
+ val publisher = createPublisher(errorResponse)
+ val thrown = assertThrows(PubSubError.HttpError.BadRequest::class.java) {
+ publisher.publish(listOf(PublishMessage("dGVzdA==")))
+ }
+ assertTrue(thrown.message!!.contains("Bad request"))
+ }
+
+ @Test
+ fun `publish 404 throws not found`() {
+ val errorResponse = MockResponse()
+ .setResponseCode(404)
+ .setBody("Not found")
+
+ val publisher = createPublisher(errorResponse)
+ val thrown = assertThrows(PubSubError.HttpError.NotFound::class.java) {
+ publisher.publish(listOf(PublishMessage("dGVzdA==")))
+ }
+ assertTrue(thrown.message!!.contains("Not found"))
+ }
+
+ @Test
+ fun `publish 401 throws unauthorized`() {
+ val errorResponse = MockResponse()
+ .setResponseCode(401)
+ .setBody("Unauthorized")
+
+ val publisher = createPublisher(errorResponse)
+ val thrown = assertThrows(PubSubError.HttpError.Unauthorized::class.java) {
+ publisher.publish(listOf(PublishMessage("dGVzdA==")))
+ }
+ assertTrue(thrown.message!!.contains("Unauthorized"))
+ }
+}
+
+class TestPublisherPurge {
+ private val server = MockWebServer()
+ private lateinit var baseUrl: String
+ private lateinit var authProvider: MockAuthProvider
+
+ @BeforeEach
+ fun setUp() {
+ server.start()
+ baseUrl = server.url("/").toString()
+ authProvider = MockAuthProvider()
+ }
+
+ @AfterEach
+ fun tearDown() {
+ server.shutdown()
+ }
+
+ private fun createPublisher(response: MockResponse): PubSubPublisher {
+ server.enqueue(response)
+ return PubSubPublisher(
+ topicId = UUID.randomUUID(),
+ baseDataplaneUrl = baseUrl,
+ authProvider = authProvider
+ )
+ }
+
+ @Test
+ fun `purge topic succeeds`() {
+ val response = MockResponse()
+ .setResponseCode(204)
+ .setBody("")
+
+ val publisher = createPublisher(response)
+ publisher.purgeTopic()
+ assertEquals(1, publisher.purgeCalls.get())
+ }
+
+ @Test
+ fun `purge 404 throws not found`() {
+ val response = MockResponse()
+ .setResponseCode(404)
+ .setBody("Not found")
+
+ val publisher = createPublisher(response)
+ val thrown = assertThrows(PubSubError.HttpError.NotFound::class.java) {
+ publisher.purgeTopic()
+ }
+ assertTrue(thrown.message!!.contains("Not found"))
+ }
+
+ @Test
+ fun `purge 500 throws after retries`() {
+ val response = MockResponse()
+ .setResponseCode(500)
+ .setBody("Internal Server Error")
+
+ repeat(5) {
+ server.enqueue(response)
+ }
+
+ val retryConfig = RetryConfig(
+ maxRetries = 2,
+ baseDelay = 10,
+ maxDelay = 50,
+ multiplier = 1.0
+ )
+
+ val publisher = PubSubPublisher(
+ topicId = UUID.randomUUID(),
+ baseDataplaneUrl = baseUrl,
+ authProvider = authProvider,
+ retryConfig = retryConfig
+ )
+ val thrown = assertThrows(PubSubError.HttpError.ServerError::class.java) {
+ publisher.purgeTopic()
+ }
+ assertTrue(thrown.message!!.contains("500"))
+ }
+}
+
+class TestPublisherErrorResponses {
+ private val server = MockWebServer()
+ private lateinit var baseUrl: String
+ private lateinit var authProvider: MockAuthProvider
+
+ @BeforeEach
+ fun setUp() {
+ server.start()
+ baseUrl = server.url("/").toString()
+ authProvider = MockAuthProvider()
+ }
+
+ @AfterEach
+ fun tearDown() {
+ server.shutdown()
+ }
+
+ private fun createPublisher(response: MockResponse): PubSubPublisher {
+ server.enqueue(response)
+ return PubSubPublisher(
+ topicId = UUID.randomUUID(),
+ baseDataplaneUrl = baseUrl,
+ authProvider = authProvider
+ )
+ }
+
+ @Test
+ fun `publish 413 throws too large`() {
+ val response = MockResponse()
+ .setResponseCode(413)
+ .setBody("Request body too large")
+
+ val publisher = createPublisher(response)
+ val thrown = assertThrows(PubSubError.HttpError.PayloadTooLarge::class.java) {
+ publisher.publish(listOf(PublishMessage("dGVzdA==")))
+ }
+ assertTrue(thrown.message!!.contains("too large"))
+ }
+
+ @Test
+ fun `publish 500 throws after retries`() {
+ val response = MockResponse()
+ .setResponseCode(500)
+ .setBody("Internal Server Error")
+
+ repeat(5) {
+ server.enqueue(response)
+ }
+
+ val retryConfig = RetryConfig(
+ maxRetries = 2,
+ baseDelay = 10,
+ maxDelay = 50,
+ multiplier = 1.0
+ )
+
+ val publisher = PubSubPublisher(
+ topicId = UUID.randomUUID(),
+ baseDataplaneUrl = baseUrl,
+ authProvider = authProvider,
+ retryConfig = retryConfig
+ )
+ val thrown = assertThrows(PubSubError.HttpError.ServerError::class.java) {
+ publisher.publish(listOf(PublishMessage("dGVzdA==")))
+ }
+ assertTrue(thrown.message!!.contains("500"))
+ }
+
+ @Test
+ fun `publish 502 throws after retries`() {
+ val response = MockResponse()
+ .setResponseCode(502)
+ .setBody("Bad Gateway")
+
+ repeat(5) {
+ server.enqueue(response)
+ }
+
+ val retryConfig = RetryConfig(
+ maxRetries = 2,
+ baseDelay = 10,
+ maxDelay = 50,
+ multiplier = 1.0
+ )
+
+ val publisher = PubSubPublisher(
+ topicId = UUID.randomUUID(),
+ baseDataplaneUrl = baseUrl,
+ authProvider = authProvider,
+ retryConfig = retryConfig
+ )
+ val thrown = assertThrows(PubSubError.HttpError.ServerError::class.java) {
+ publisher.publish(listOf(PublishMessage("dGVzdA==")))
+ }
+ assertTrue(thrown.message!!.contains("502"))
+ }
+
+ @Test
+ fun `publish IOException throws request failed`() {
+ server.shutdown()
+
+ val publisher = PubSubPublisher(
+ topicId = UUID.randomUUID(),
+ baseDataplaneUrl = baseUrl,
+ authProvider = authProvider
+ )
+ val thrown = assertThrows(PubSubError.RequestFailed::class.java) {
+ publisher.publish(listOf(PublishMessage("dGVzdA==")))
+ }
+ assertTrue(thrown.message!!.contains("Request failed"))
+ }
+}
diff --git a/src/test/kotlin/cloud/stackit/pubsub/sdk/retry/TestRetry.kt b/src/test/kotlin/cloud/stackit/pubsub/sdk/retry/TestRetry.kt
new file mode 100644
index 0000000..6069e80
--- /dev/null
+++ b/src/test/kotlin/cloud/stackit/pubsub/sdk/retry/TestRetry.kt
@@ -0,0 +1,206 @@
+package cloud.stackit.pubsub.sdk.retry
+
+import cloud.stackit.pubsub.sdk.errors.PubSubError
+import org.junit.jupiter.api.Test
+import java.io.IOException
+
+class TestRetryConfig {
+ @Test
+ fun `has correct default values`() {
+ val config = RetryConfig.Default
+ assert(config.maxRetries == 3)
+ assert(config.baseDelay == 500L)
+ assert(config.maxDelay == 30_000L)
+ assert(config.multiplier == 2.0)
+ assert(config.jitterFactor == 0.25)
+ }
+
+ @Test
+ fun `accepts custom values`() {
+ val config = RetryConfig(
+ maxRetries = 5,
+ baseDelay = 1000,
+ maxDelay = 60_000,
+ multiplier = 3.0,
+ jitterFactor = 0.5
+ )
+ assert(config.maxRetries == 5)
+ assert(config.baseDelay == 1000L)
+ assert(config.maxDelay == 60_000L)
+ assert(config.multiplier == 3.0)
+ assert(config.jitterFactor == 0.5)
+ }
+}
+
+class TestRetryableStatus {
+ @Test
+ fun `500 is retryable`() {
+ assert(isRetryableStatusCode(500))
+ }
+
+ @Test
+ fun `502 is retryable`() {
+ assert(isRetryableStatusCode(502))
+ }
+
+ @Test
+ fun `503 is retryable`() {
+ assert(isRetryableStatusCode(503))
+ }
+
+ @Test
+ fun `504 is retryable`() {
+ assert(isRetryableStatusCode(504))
+ }
+
+ @Test
+ fun `200 is not retryable`() {
+ assert(!isRetryableStatusCode(200))
+ }
+
+ @Test
+ fun `400 is not retryable`() {
+ assert(!isRetryableStatusCode(400))
+ }
+
+ @Test
+ fun `401 is not retryable`() {
+ assert(!isRetryableStatusCode(401))
+ }
+
+ @Test
+ fun `404 is not retryable`() {
+ assert(!isRetryableStatusCode(404))
+ }
+}
+
+class TestRetryableException {
+ @Test
+ fun `ConnectException is retryable`() {
+ assert(isRetryableException(IOException("connection refused")))
+ }
+
+ @Test
+ fun `SocketTimeoutException is retryable`() {
+ val socketTimeout = java.net.SocketTimeoutException("timeout")
+ assert(isRetryableException(socketTimeout))
+ }
+
+ @Test
+ fun `IOException is retryable`() {
+ assert(isRetryableException(IOException("network error")))
+ }
+
+ @Test
+ fun `TimeoutException is retryable`() {
+ assert(isRetryableException(java.util.concurrent.TimeoutException()))
+ }
+
+ @Test
+ fun `PubSubError is not retryable`() {
+ assert(!isRetryableException(PubSubError.AuthError.TokenAcquisitionFailed("token error")))
+ }
+
+ @Test
+ fun `nested retryable exception is retryable`() {
+ val nested = RuntimeException(IOException("nested"))
+ assert(isRetryable(nested))
+ }
+
+ @Test
+ fun `RuntimeException is not retryable`() {
+ assert(!isRetryableException(RuntimeException("generic error")))
+ }
+
+ @Test
+ fun `IllegalArgumentException is not retryable`() {
+ assert(!isRetryableException(IllegalArgumentException("invalid argument")))
+ }
+}
+
+class TestCalculateDelay {
+ @Test
+ fun `first attempt returns at least base delay`() {
+ val config = RetryConfig(baseDelay = 500L)
+ val delay = getRetryDelay(config, 0)
+ assert(delay >= 500L) { "Delay should be at least base delay, got $delay" }
+ }
+
+ @Test
+ fun `second attempt returns increased delay`() {
+ val config = RetryConfig(baseDelay = 500L, multiplier = 2.0)
+ val delay0 = getRetryDelay(config, 0)
+ val delay1 = getRetryDelay(config, 1)
+ assert(delay1 > delay0) { "Delay should increase with attempt count" }
+ }
+
+ @Test
+ fun `respects max delay cap`() {
+ val config = RetryConfig(baseDelay = 500L, maxDelay = 1000L, multiplier = 10.0)
+ val delay = getRetryDelay(config, 10)
+ assert(delay <= 1000L + (1000L * 0.25)) { "Delay should respect max cap, got $delay" }
+ }
+
+ @Test
+ fun `delays increase monotonically without jitter`() {
+ val config = RetryConfig(baseDelay = 500L, multiplier = 2.0, jitterFactor = 0.0)
+ val delays = (0..5).map { getRetryDelay(config, it) }
+ for (i in 1 until delays.size) {
+ assert(delays[i] > delays[i - 1]) { "Delay should increase: ${delays[i - 1]} -> ${delays[i]}" }
+ }
+ }
+}
+
+class TestRetryFunction {
+ @Test
+ fun `succeeds on first try`() {
+ var called = false
+ runCatching {
+ retry {
+ called = true
+ }
+ }
+ assert(called)
+ }
+
+ @Test
+ fun `succeeds after retries`() {
+ var attempts = 0
+ runCatching {
+ retry {
+ attempts++
+ if (attempts < 3) {
+ throw IOException("transient")
+ }
+ }
+ }
+ assert(attempts == 3)
+ }
+
+ @Test
+ fun `does not retry non-retryable exception`() {
+ var attempts = 0
+ val result = runCatching {
+ retry {
+ attempts++
+ throw PubSubError.AuthError.TokenAcquisitionFailed("validation failed")
+ }
+ }
+ assert(result.isFailure)
+ assert(attempts == 1)
+ }
+
+ @Test
+ fun `throws after exhausting retries`() {
+ var attempts = 0
+ val config = RetryConfig(maxRetries = 2)
+ val result = runCatching {
+ retry(config = config) {
+ attempts++
+ throw IOException("connection failed")
+ }
+ }
+ assert(result.isFailure)
+ assert(attempts == 3) // 1 initial + 2 retries
+ }
+}
diff --git a/src/test/kotlin/cloud/stackit/pubsub/sdk/subscriber/TestSubscriber.kt b/src/test/kotlin/cloud/stackit/pubsub/sdk/subscriber/TestSubscriber.kt
new file mode 100644
index 0000000..5db171a
--- /dev/null
+++ b/src/test/kotlin/cloud/stackit/pubsub/sdk/subscriber/TestSubscriber.kt
@@ -0,0 +1,550 @@
+package cloud.stackit.pubsub.sdk.subscriber
+
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import cloud.stackit.pubsub.sdk.errors.PubSubError
+import cloud.stackit.pubsub.sdk.test.MockAuthProvider
+import cloud.stackit.pubsub.sdk.retry.RetryConfig
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.Assertions.*
+import java.util.UUID
+
+class TestPubSubSubscriberInit {
+ @Test
+ fun `creates subscriber with defaults`() {
+ val server = MockWebServer()
+ server.start()
+ try {
+ val authProvider = MockAuthProvider()
+ val subscriber = PubSubSubscriber(
+ subscriptionId = UUID.randomUUID(),
+ baseDataplaneUrl = server.url("/").toString(),
+ authProvider = authProvider
+ )
+ assertNotNull(subscriber)
+ } finally {
+ server.shutdown()
+ }
+ }
+}
+
+class TestSubscriberPull {
+ private val server = MockWebServer()
+ private lateinit var baseUrl: String
+ private lateinit var authProvider: MockAuthProvider
+
+ @BeforeEach
+ fun setUp() {
+ server.start()
+ baseUrl = server.url("/").toString()
+ authProvider = MockAuthProvider()
+ }
+
+ @AfterEach
+ fun tearDown() {
+ server.shutdown()
+ }
+
+ private fun createSubscriber(
+ response: MockResponse,
+ retryConfig: RetryConfig = RetryConfig.Default
+ ): PubSubSubscriber {
+ server.enqueue(response)
+ return PubSubSubscriber(
+ subscriptionId = UUID.randomUUID(),
+ baseDataplaneUrl = baseUrl,
+ authProvider = authProvider,
+ retryConfig = retryConfig
+ )
+ }
+
+ @Test
+ fun `pull returns messages`() {
+ val response = MockResponse()
+ .setResponseCode(200)
+ .setBody("""{"messages":[{"id":1,"ackId":"ack-1","data":"SGVsbG8sIFdvcmxkIQ==","createTime":"2024-01-01T00:00:00Z","deliveryAttempts":1},{"id":2,"ackId":"ack-2","data":"U2Vjb25kIG1lc3NhZ2U=","createTime":"2024-01-01T00:01:00Z","deliveryAttempts":1}]}""")
+ .addHeader("Content-Type", "application/json")
+
+ val subscriber = createSubscriber(response)
+ val messages = subscriber.pull()
+
+ assertEquals(2, messages.size)
+ assertEquals("ack-1", messages[0].ackId)
+ assertEquals(1L, messages[0].id)
+ assertEquals("Hello, World!", messages[0].text)
+ }
+
+ @Test
+ fun `pull empty returns empty list`() {
+ val response = MockResponse()
+ .setResponseCode(200)
+ .setBody("""{"messages":[]}""")
+
+ val subscriber = createSubscriber(response)
+ assertTrue(subscriber.pull().isEmpty())
+ }
+
+ @Test
+ fun `pull sends maxMessages header`() {
+ val response = MockResponse()
+ .setResponseCode(200)
+ .setBody("""{"messages":[]}""")
+
+ val subscriber = createSubscriber(response)
+ subscriber.pull(maxMessages = 5)
+
+ val recordedRequest = server.takeRequest()
+ assertEquals("5", recordedRequest.getHeader("PubSub-Max-Messages"))
+ }
+
+ @Test
+ fun `pull sends longPullDurationMs header`() {
+ val response = MockResponse()
+ .setResponseCode(200)
+ .setBody("""{"messages":[]}""")
+
+ val subscriber = createSubscriber(response)
+ subscriber.pull(longPullDurationMs = 3000)
+
+ val recordedRequest = server.takeRequest()
+ assertEquals("3000", recordedRequest.getHeader("PubSub-Long-Pull-Duration"))
+ }
+
+ @Test
+ fun `pull invalid maxMessages throws`() {
+ val response = MockResponse()
+ .setResponseCode(200)
+ .setBody("""{"messages":[]}""")
+
+ val subscriber = createSubscriber(response)
+ assertThrows(IllegalArgumentException::class.java) {
+ subscriber.pull(maxMessages = 50)
+ }
+ }
+
+ @Test
+ fun `pull invalid maxMessages min throws`() {
+ val response = MockResponse()
+ .setResponseCode(200)
+ .setBody("""{"messages":[]}""")
+
+ val subscriber = createSubscriber(response)
+ assertThrows(IllegalArgumentException::class.java) {
+ subscriber.pull(maxMessages = 0)
+ }
+ }
+
+ @Test
+ fun `pull invalid longPullDurationMs in subscriber init throws`() {
+ assertThrows(IllegalArgumentException::class.java) {
+ PubSubSubscriber(
+ subscriptionId = UUID.randomUUID(),
+ baseDataplaneUrl = "https://test.pubsub.eu01.onstackit.cloud",
+ authProvider = MockAuthProvider(),
+ longPullDurationMs = 10000
+ )
+ }
+ }
+
+ @Test
+ fun `pull invalid longPullDurationMs below minimum in subscriber init throws`() {
+ assertThrows(IllegalArgumentException::class.java) {
+ PubSubSubscriber(
+ subscriptionId = UUID.randomUUID(),
+ baseDataplaneUrl = "https://test.pubsub.eu01.onstackit.cloud",
+ authProvider = MockAuthProvider(),
+ longPullDurationMs = 50
+ )
+ }
+ }
+
+ @Test
+ fun `pull 503 triggers retry then succeeds`() {
+ val errorResponse = MockResponse()
+ .setResponseCode(503)
+ .setBody("Service Unavailable")
+ val okResponse = MockResponse()
+ .setResponseCode(200)
+ .setBody("""{"messages":[]}""")
+
+ server.enqueue(errorResponse)
+ server.enqueue(errorResponse)
+ server.enqueue(okResponse)
+
+ val retryConfig = RetryConfig(
+ maxRetries = 3,
+ baseDelay = 10,
+ maxDelay = 50,
+ multiplier = 1.0
+ )
+
+ val subscriber = PubSubSubscriber(
+ subscriptionId = UUID.randomUUID(),
+ baseDataplaneUrl = baseUrl,
+ authProvider = authProvider,
+ retryConfig = retryConfig
+ )
+ subscriber.pull()
+ assertEquals(3, server.requestCount)
+ }
+
+ @Test
+ fun `pull exhausts retries`() {
+ val errorResponse = MockResponse()
+ .setResponseCode(503)
+ .setBody("Service Unavailable")
+
+ repeat(10) {
+ server.enqueue(errorResponse)
+ }
+
+ val retryConfig = RetryConfig(
+ maxRetries = 2,
+ baseDelay = 10,
+ maxDelay = 50,
+ multiplier = 1.0
+ )
+
+ val subscriber = PubSubSubscriber(
+ subscriptionId = UUID.randomUUID(),
+ baseDataplaneUrl = baseUrl,
+ authProvider = authProvider,
+ retryConfig = retryConfig
+ )
+ val thrown = assertThrows(PubSubError.HttpError.ServerError::class.java) {
+ subscriber.pull()
+ }
+ assertTrue(thrown.message!!.contains("retries"))
+ assertEquals(3, server.requestCount)
+ }
+}
+
+class TestSubscriberAck {
+ private val server = MockWebServer()
+ private lateinit var baseUrl: String
+ private lateinit var authProvider: MockAuthProvider
+
+ @BeforeEach
+ fun setUp() {
+ server.start()
+ baseUrl = server.url("/").toString()
+ authProvider = MockAuthProvider()
+ }
+
+ @AfterEach
+ fun tearDown() {
+ server.shutdown()
+ }
+
+ private fun createSubscriber(response: MockResponse): PubSubSubscriber {
+ server.enqueue(response)
+ return PubSubSubscriber(
+ subscriptionId = UUID.randomUUID(),
+ baseDataplaneUrl = baseUrl,
+ authProvider = authProvider
+ )
+ }
+
+ @Test
+ fun `ack success`() {
+ val response = MockResponse()
+ .setResponseCode(204)
+ .setBody("")
+
+ val subscriber = createSubscriber(response)
+ subscriber.ack(listOf("ack-1", "ack-2"))
+ }
+
+ @Test
+ fun `ack empty list throws`() {
+ val response = MockResponse()
+ .setResponseCode(204)
+
+ val subscriber = createSubscriber(response)
+ assertThrows(IllegalArgumentException::class.java) {
+ subscriber.ack(emptyList())
+ }
+ }
+
+ @Test
+ fun `ack sends correct payload`() {
+ val response = MockResponse()
+ .setResponseCode(204)
+ .setBody("")
+
+ val subscriber = createSubscriber(response)
+ subscriber.ack(listOf("ack-1", "ack-2"))
+
+ val recordedRequest = server.takeRequest()
+ val body = recordedRequest.body.readUtf8()
+ assertTrue(body.contains("ack-1"))
+ assertTrue(body.contains("ack-2"))
+ }
+}
+
+class TestSubscriberNack {
+ private val server = MockWebServer()
+ private lateinit var baseUrl: String
+ private lateinit var authProvider: MockAuthProvider
+
+ @BeforeEach
+ fun setUp() {
+ server.start()
+ baseUrl = server.url("/").toString()
+ authProvider = MockAuthProvider()
+ }
+
+ @AfterEach
+ fun tearDown() {
+ server.shutdown()
+ }
+
+ private fun createSubscriber(response: MockResponse): PubSubSubscriber {
+ server.enqueue(response)
+ return PubSubSubscriber(
+ subscriptionId = UUID.randomUUID(),
+ baseDataplaneUrl = baseUrl,
+ authProvider = authProvider
+ )
+ }
+
+ @Test
+ fun `nack success`() {
+ val response = MockResponse()
+ .setResponseCode(204)
+ .setBody("")
+
+ val subscriber = createSubscriber(response)
+ subscriber.nack(listOf("nack-1", "nack-2"))
+ }
+
+ @Test
+ fun `nack empty list throws`() {
+ val response = MockResponse()
+ .setResponseCode(204)
+
+ val subscriber = createSubscriber(response)
+ assertThrows(IllegalArgumentException::class.java) {
+ subscriber.nack(emptyList())
+ }
+ }
+
+ @Test
+ fun `nack sends correct payload`() {
+ val response = MockResponse()
+ .setResponseCode(204)
+ .setBody("")
+
+ val subscriber = createSubscriber(response)
+ subscriber.nack(listOf("nack-1", "nack-2"))
+
+ val recordedRequest = server.takeRequest()
+ val body = recordedRequest.body.readUtf8()
+ assertTrue(body.contains("nack-1"))
+ assertTrue(body.contains("nack-2"))
+ }
+}
+
+class TestSubscriberErrorHandling {
+ private val server = MockWebServer()
+ private lateinit var baseUrl: String
+ private lateinit var authProvider: MockAuthProvider
+
+ @BeforeEach
+ fun setUp() {
+ server.start()
+ baseUrl = server.url("/").toString()
+ authProvider = MockAuthProvider()
+ }
+
+ @AfterEach
+ fun tearDown() {
+ server.shutdown()
+ }
+
+ private fun createSubscriber(response: MockResponse): PubSubSubscriber {
+ server.enqueue(response)
+ return PubSubSubscriber(
+ subscriptionId = UUID.randomUUID(),
+ baseDataplaneUrl = baseUrl,
+ authProvider = authProvider
+ )
+ }
+
+ @Test
+ fun `pull 404 throws not found`() {
+ val response = MockResponse()
+ .setResponseCode(404)
+ .setBody("Not found")
+
+ val subscriber = createSubscriber(response)
+ val thrown = assertThrows(PubSubError.HttpError.NotFound::class.java) {
+ subscriber.pull()
+ }
+ assertTrue(thrown.message!!.contains("Not found"))
+ }
+
+ @Test
+ fun `pull 401 throws unauthorized`() {
+ val response = MockResponse()
+ .setResponseCode(401)
+ .setBody("Unauthorized")
+
+ val subscriber = createSubscriber(response)
+ val thrown = assertThrows(PubSubError.HttpError.Unauthorized::class.java) {
+ subscriber.pull()
+ }
+ assertTrue(thrown.message!!.contains("Unauthorized"))
+ }
+
+ @Test
+ fun `ack 404 throws not found`() {
+ val response = MockResponse()
+ .setResponseCode(404)
+ .setBody("Not found")
+
+ val subscriber = createSubscriber(response)
+ assertThrows(PubSubError.HttpError.NotFound::class.java) {
+ subscriber.ack(listOf("ack-1"))
+ }
+ }
+
+ @Test
+ fun `nack 404 throws not found`() {
+ val response = MockResponse()
+ .setResponseCode(404)
+ .setBody("Not found")
+
+ val subscriber = createSubscriber(response)
+ assertThrows(PubSubError.HttpError.NotFound::class.java) {
+ subscriber.nack(listOf("nack-1"))
+ }
+ }
+
+ @Test
+ fun `pull 413 throws too large`() {
+ val response = MockResponse()
+ .setResponseCode(413)
+ .setBody("Request body too large")
+
+ val subscriber = createSubscriber(response)
+ val thrown = assertThrows(PubSubError.HttpError.PayloadTooLarge::class.java) {
+ subscriber.pull()
+ }
+ assertTrue(thrown.message!!.contains("too large"))
+ }
+
+ @Test
+ fun `pull 500 throws after retries`() {
+ val response = MockResponse()
+ .setResponseCode(500)
+ .setBody("Internal Server Error")
+
+ repeat(5) {
+ server.enqueue(response)
+ }
+
+ val retryConfig = RetryConfig(
+ maxRetries = 2,
+ baseDelay = 10,
+ maxDelay = 50,
+ multiplier = 1.0
+ )
+
+ val subscriber = PubSubSubscriber(
+ subscriptionId = UUID.randomUUID(),
+ baseDataplaneUrl = baseUrl,
+ authProvider = authProvider,
+ retryConfig = retryConfig
+ )
+ val thrown = assertThrows(PubSubError.HttpError.ServerError::class.java) {
+ subscriber.pull()
+ }
+ assertTrue(thrown.message!!.contains("500"))
+ }
+
+ @Test
+ fun `ack 413 throws too large`() {
+ val response = MockResponse()
+ .setResponseCode(413)
+ .setBody("Request body too large")
+
+ val subscriber = createSubscriber(response)
+ val thrown = assertThrows(PubSubError.HttpError.PayloadTooLarge::class.java) {
+ subscriber.ack(listOf("ack-1"))
+ }
+ assertTrue(thrown.message!!.contains("too large"))
+ }
+
+ @Test
+ fun `nack 500 throws after retries`() {
+ val response = MockResponse()
+ .setResponseCode(500)
+ .setBody("Internal Server Error")
+
+ repeat(5) {
+ server.enqueue(response)
+ }
+
+ val retryConfig = RetryConfig(
+ maxRetries = 2,
+ baseDelay = 10,
+ maxDelay = 50,
+ multiplier = 1.0
+ )
+
+ val subscriber = PubSubSubscriber(
+ subscriptionId = UUID.randomUUID(),
+ baseDataplaneUrl = baseUrl,
+ authProvider = authProvider,
+ retryConfig = retryConfig
+ )
+ val thrown = assertThrows(PubSubError.HttpError.ServerError::class.java) {
+ subscriber.nack(listOf("nack-1"))
+ }
+ assertTrue(thrown.message!!.contains("500"))
+ }
+
+ @Test
+ fun `pull IOException throws request failed`() {
+ server.shutdown()
+
+ val subscriber = PubSubSubscriber(
+ subscriptionId = UUID.randomUUID(),
+ baseDataplaneUrl = baseUrl,
+ authProvider = authProvider
+ )
+ val thrown = assertThrows(PubSubError.RequestFailed::class.java) {
+ subscriber.pull()
+ }
+ assertTrue(thrown.message!!.contains("Request failed"))
+ }
+
+ @Test
+ fun `pull 429 throws unexpected error`() {
+ val response = MockResponse()
+ .setResponseCode(429)
+ .setBody("Too Many Requests")
+
+ val subscriber = createSubscriber(response)
+ val thrown = assertThrows(PubSubError.HttpError.Unexpected::class.java) {
+ subscriber.pull()
+ }
+ assertTrue(thrown.message!!.contains("429"))
+ }
+
+ @Test
+ fun `ack 429 throws unexpected error`() {
+ val response = MockResponse()
+ .setResponseCode(429)
+ .setBody("Too Many Requests")
+
+ val subscriber = createSubscriber(response)
+ val thrown = assertThrows(PubSubError.HttpError.Unexpected::class.java) {
+ subscriber.ack(listOf("ack-1"))
+ }
+ assertTrue(thrown.message!!.contains("429"))
+ }
+}
diff --git a/src/test/kotlin/cloud/stackit/pubsub/sdk/test/MockAuthProvider.kt b/src/test/kotlin/cloud/stackit/pubsub/sdk/test/MockAuthProvider.kt
new file mode 100644
index 0000000..c733eb3
--- /dev/null
+++ b/src/test/kotlin/cloud/stackit/pubsub/sdk/test/MockAuthProvider.kt
@@ -0,0 +1,20 @@
+package cloud.stackit.pubsub.sdk.test
+
+import cloud.stackit.pubsub.sdk.auth.AuthProvider
+import java.util.concurrent.atomic.AtomicReference
+
+class MockAuthProvider : AuthProvider {
+ private val tokenRef = AtomicReference(null)
+
+ fun setToken(token: String) {
+ tokenRef.set(token)
+ }
+
+ override fun getToken(): String {
+ return tokenRef.get() ?: "mock-fake-token-12345"
+ }
+
+ override fun getAuthHeader(): Map {
+ return mapOf("Authorization" to "Bearer ${getToken()}")
+ }
+}