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()}") + } +}