From be50ad8b0765df9105a1ac3d8d078d63056416e8 Mon Sep 17 00:00:00 2001 From: Cy Scott Date: Mon, 25 May 2026 13:11:00 -0400 Subject: [PATCH 01/10] feat: adding Kotlin support --- .github/copilot-instructions.md | 34 +- .github/workflows/pr-tests.yml | 36 ++- .github/workflows/release-and-publish.yml | 62 +++- .gitignore | 3 + README.md | 58 +++- src/kotlin/README.md | 223 +++++++++++++ src/kotlin/build.gradle.kts | 45 +++ src/kotlin/settings.gradle.kts | 1 + src/kotlin/src/main/kotlin/polybus/Headers.kt | 8 + .../src/main/kotlin/polybus/IPolyBus.kt | 26 ++ src/kotlin/src/main/kotlin/polybus/PolyBus.kt | 67 ++++ .../src/main/kotlin/polybus/PolyBusBuilder.kt | 38 +++ .../src/main/kotlin/polybus/PolyBusError.kt | 3 + .../kotlin/polybus/transport/ITransport.kt | 15 + .../transport/PolyBusNotStartedError.kt | 8 + .../polybus/transport/TransportFactory.kt | 6 + .../transport/inmemory/InMemoryEndpoint.kt | 68 ++++ .../inmemory/InMemoryMessageBroker.kt | 111 +++++++ .../transactions/IncomingTransaction.kt | 8 + .../IncomingTransactionFactory.kt | 11 + .../transactions/OutgoingTransaction.kt | 5 + .../OutgoingTransactionFactory.kt | 6 + .../transport/transactions/Transaction.kt | 25 ++ .../transactions/messages/IncomingMessage.kt | 14 + .../transactions/messages/Message.kt | 11 + .../transactions/messages/MessageInfo.kt | 79 +++++ .../transactions/messages/MessageType.kt | 6 + .../transactions/messages/Messages.kt | 36 +++ .../transactions/messages/OutgoingMessage.kt | 18 ++ .../messages/PolyBusMessageNotFoundError.kt | 8 + .../messages/handlers/IncomingHandler.kt | 5 + .../messages/handlers/OutgoingHandler.kt | 5 + .../messages/handlers/error/ErrorHandler.kt | 73 +++++ .../handlers/serializers/JsonHandlers.kt | 27 ++ .../transport/inmemory/AlphaCommand.kt | 9 + .../polybus/transport/inmemory/AlphaEvent.kt | 9 + .../inmemory/InMemoryTransportTests.kt | 194 +++++++++++ .../transport/inmemory/TestEndpoint.kt | 18 ++ .../transport/inmemory/TestEnvironment.kt | 48 +++ .../transactions/messages/Command.kt | 4 + .../transport/transactions/messages/Event.kt | 4 + .../transactions/messages/MessageInfoTests.kt | 235 ++++++++++++++ .../messages/MessageWithoutAttribute.kt | 3 + .../transactions/messages/MessagesTests.kt | 150 +++++++++ .../handlers/error/ErrorHandlerTestMessage.kt | 7 + .../handlers/error/ErrorHandlerTests.kt | 303 ++++++++++++++++++ .../messages/handlers/error/TestBus.kt | 28 ++ .../messages/handlers/error/TestTransport.kt | 24 ++ .../serializers/JsonHandlerTestMessage.kt | 9 + .../handlers/serializers/JsonHandlersTests.kt | 28 ++ 50 files changed, 2217 insertions(+), 5 deletions(-) create mode 100644 src/kotlin/README.md create mode 100644 src/kotlin/build.gradle.kts create mode 100644 src/kotlin/settings.gradle.kts create mode 100644 src/kotlin/src/main/kotlin/polybus/Headers.kt create mode 100644 src/kotlin/src/main/kotlin/polybus/IPolyBus.kt create mode 100644 src/kotlin/src/main/kotlin/polybus/PolyBus.kt create mode 100644 src/kotlin/src/main/kotlin/polybus/PolyBusBuilder.kt create mode 100644 src/kotlin/src/main/kotlin/polybus/PolyBusError.kt create mode 100644 src/kotlin/src/main/kotlin/polybus/transport/ITransport.kt create mode 100644 src/kotlin/src/main/kotlin/polybus/transport/PolyBusNotStartedError.kt create mode 100644 src/kotlin/src/main/kotlin/polybus/transport/TransportFactory.kt create mode 100644 src/kotlin/src/main/kotlin/polybus/transport/inmemory/InMemoryEndpoint.kt create mode 100644 src/kotlin/src/main/kotlin/polybus/transport/inmemory/InMemoryMessageBroker.kt create mode 100644 src/kotlin/src/main/kotlin/polybus/transport/transactions/IncomingTransaction.kt create mode 100644 src/kotlin/src/main/kotlin/polybus/transport/transactions/IncomingTransactionFactory.kt create mode 100644 src/kotlin/src/main/kotlin/polybus/transport/transactions/OutgoingTransaction.kt create mode 100644 src/kotlin/src/main/kotlin/polybus/transport/transactions/OutgoingTransactionFactory.kt create mode 100644 src/kotlin/src/main/kotlin/polybus/transport/transactions/Transaction.kt create mode 100644 src/kotlin/src/main/kotlin/polybus/transport/transactions/messages/IncomingMessage.kt create mode 100644 src/kotlin/src/main/kotlin/polybus/transport/transactions/messages/Message.kt create mode 100644 src/kotlin/src/main/kotlin/polybus/transport/transactions/messages/MessageInfo.kt create mode 100644 src/kotlin/src/main/kotlin/polybus/transport/transactions/messages/MessageType.kt create mode 100644 src/kotlin/src/main/kotlin/polybus/transport/transactions/messages/Messages.kt create mode 100644 src/kotlin/src/main/kotlin/polybus/transport/transactions/messages/OutgoingMessage.kt create mode 100644 src/kotlin/src/main/kotlin/polybus/transport/transactions/messages/PolyBusMessageNotFoundError.kt create mode 100644 src/kotlin/src/main/kotlin/polybus/transport/transactions/messages/handlers/IncomingHandler.kt create mode 100644 src/kotlin/src/main/kotlin/polybus/transport/transactions/messages/handlers/OutgoingHandler.kt create mode 100644 src/kotlin/src/main/kotlin/polybus/transport/transactions/messages/handlers/error/ErrorHandler.kt create mode 100644 src/kotlin/src/main/kotlin/polybus/transport/transactions/messages/handlers/serializers/JsonHandlers.kt create mode 100644 src/kotlin/src/test/kotlin/polybus/transport/inmemory/AlphaCommand.kt create mode 100644 src/kotlin/src/test/kotlin/polybus/transport/inmemory/AlphaEvent.kt create mode 100644 src/kotlin/src/test/kotlin/polybus/transport/inmemory/InMemoryTransportTests.kt create mode 100644 src/kotlin/src/test/kotlin/polybus/transport/inmemory/TestEndpoint.kt create mode 100644 src/kotlin/src/test/kotlin/polybus/transport/inmemory/TestEnvironment.kt create mode 100644 src/kotlin/src/test/kotlin/polybus/transport/transactions/messages/Command.kt create mode 100644 src/kotlin/src/test/kotlin/polybus/transport/transactions/messages/Event.kt create mode 100644 src/kotlin/src/test/kotlin/polybus/transport/transactions/messages/MessageInfoTests.kt create mode 100644 src/kotlin/src/test/kotlin/polybus/transport/transactions/messages/MessageWithoutAttribute.kt create mode 100644 src/kotlin/src/test/kotlin/polybus/transport/transactions/messages/MessagesTests.kt create mode 100644 src/kotlin/src/test/kotlin/polybus/transport/transactions/messages/handlers/error/ErrorHandlerTestMessage.kt create mode 100644 src/kotlin/src/test/kotlin/polybus/transport/transactions/messages/handlers/error/ErrorHandlerTests.kt create mode 100644 src/kotlin/src/test/kotlin/polybus/transport/transactions/messages/handlers/error/TestBus.kt create mode 100644 src/kotlin/src/test/kotlin/polybus/transport/transactions/messages/handlers/error/TestTransport.kt create mode 100644 src/kotlin/src/test/kotlin/polybus/transport/transactions/messages/handlers/serializers/JsonHandlerTestMessage.kt create mode 100644 src/kotlin/src/test/kotlin/polybus/transport/transactions/messages/handlers/serializers/JsonHandlersTests.kt diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index ac4c523..42e6150 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -2,7 +2,7 @@ ## Project Overview -PolyBus is a **polyglot messaging framework** that enables seamless communication between applications written in different programming languages. It provides unified interfaces across TypeScript, Python, and .NET (with PHP planned) for building interoperable distributed systems and microservices. +PolyBus is a **polyglot messaging framework** that enables seamless communication between applications written in different programming languages. It provides unified interfaces across TypeScript, Python, .NET, and Kotlin (with PHP planned) for building interoperable distributed systems and microservices. ## Core Architecture Principles @@ -111,6 +111,28 @@ class IPolyBus(ABC): - Use `./lint.sh` for code quality checks - Follow `.editorconfig` and `.DotSettings` conventions +### Kotlin (`src/kotlin/`) + +**Technology Stack:** +- Kotlin/JVM 2.1.x +- JDK 17 toolchain +- Gradle Kotlin DSL (`build.gradle.kts`) +- JUnit 5 + Kotlin test + JaCoCo coverage + +**Coding Conventions:** +- Use coroutines (`suspend`) for async workflows +- Keep naming consistent with cross-language parity (`IPolyBus`, `PolyBusBuilder`, `createTransaction`) +- Prefer immutable values and data classes for message models + +**Testing and Coverage:** +- Run tests with `gradle test` +- Generate coverage with `gradle coverage` +- JaCoCo XML report path: `build/reports/jacoco/test/jacocoTestReport.xml` + +**Publishing Notes:** +- Use Gradle publish workflow (`gradle publish`) with repository credentials from CI secrets +- Keep package coordinates stable unless intentionally changing group/artifact naming + ## Cross-Language Consistency ### Naming Conventions @@ -351,6 +373,16 @@ dotnet test # Run tests ./lint.sh # Run linting ``` +### Kotlin +```bash +cd src/kotlin +gradle clean # Clean build outputs +gradle build # Compile and run checks +gradle test # Run tests +gradle coverage # Generate JaCoCo XML/HTML coverage +gradle publish # Publish package (CI credentials required) +``` + ## Resources - **Main README**: `/README.md` diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml index ff4d20e..6438dc5 100644 --- a/.github/workflows/pr-tests.yml +++ b/.github/workflows/pr-tests.yml @@ -42,6 +42,39 @@ jobs: flags: dotnet name: dotnet-coverage + kotlin-tests: + name: Kotlin Tests + runs-on: ubuntu-latest + defaults: + run: + working-directory: src/kotlin + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Build + run: gradle build + + - name: Run tests with coverage + run: gradle coverage + + - name: Upload Kotlin coverage + uses: codecov/codecov-action@v4 + with: + files: ./src/kotlin/build/reports/jacoco/test/jacocoTestReport.xml + flags: kotlin + name: kotlin-coverage + python-tests: name: Python Tests runs-on: ubuntu-latest @@ -108,7 +141,7 @@ jobs: coverage-summary: name: Coverage Summary runs-on: ubuntu-latest - needs: [dotnet-tests, python-tests, typescript-tests] + needs: [dotnet-tests, python-tests, typescript-tests, kotlin-tests] if: always() steps: @@ -120,3 +153,4 @@ jobs: echo "- ✅ .NET Tests: ${{ needs.dotnet-tests.result }}" >> $GITHUB_STEP_SUMMARY echo "- ✅ Python Tests: ${{ needs.python-tests.result }}" >> $GITHUB_STEP_SUMMARY echo "- ✅ TypeScript Tests: ${{ needs.typescript-tests.result }}" >> $GITHUB_STEP_SUMMARY + echo "- ✅ Kotlin Tests: ${{ needs.kotlin-tests.result }}" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/release-and-publish.yml b/.github/workflows/release-and-publish.yml index d61baac..e2e4985 100644 --- a/.github/workflows/release-and-publish.yml +++ b/.github/workflows/release-and-publish.yml @@ -117,6 +117,22 @@ jobs: dotnet test --configuration Release dotnet pack --configuration Release --output ./nupkg + # Kotlin build and test + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Build Kotlin package + working-directory: src/kotlin + run: | + gradle clean build + gradle coverage + # Upload build artifacts - name: Upload TypeScript artifact uses: actions/upload-artifact@v4 @@ -136,6 +152,12 @@ jobs: name: dotnet-package path: src/dotnet/PolyBus/nupkg/ + - name: Upload Kotlin artifact + uses: actions/upload-artifact@v4 + with: + name: kotlin-package + path: src/kotlin/build/libs/ + # Publish to npm publish-npm: name: Publish to npm @@ -243,11 +265,45 @@ jobs: run: | dotnet nuget push "./nupkg/*.nupkg" --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate + # Publish Gradle package + publish-gradle: + name: Publish Gradle Package + runs-on: ubuntu-latest + needs: [determine-version, build-and-test] + if: needs.determine-version.outputs.should_release == 'true' + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v3 + + - name: Update package version + working-directory: src/kotlin + run: | + sed -i "s/^version = .*/version = \"${{ needs.determine-version.outputs.version }}\"/" build.gradle.kts + + - name: Build and publish + working-directory: src/kotlin + env: + MAVEN_REPOSITORY_URL: ${{ secrets.MAVEN_REPOSITORY_URL }} + MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }} + MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} + run: | + gradle clean build + gradle publish -PmavenRepoUrl="$MAVEN_REPOSITORY_URL" -PmavenUsername="$MAVEN_USERNAME" -PmavenPassword="$MAVEN_PASSWORD" + # Summary job publish-summary: name: Publish Summary runs-on: ubuntu-latest - needs: [determine-version, publish-pypi, publish-nuget] + needs: [determine-version, publish-npm, publish-pypi, publish-nuget, publish-gradle] if: always() && needs.determine-version.outputs.should_release == 'true' steps: - name: Summary @@ -259,6 +315,7 @@ jobs: echo "- 📦 **npm**: ${{ needs.publish-npm.result }}" >> $GITHUB_STEP_SUMMARY echo "- 🐍 **PyPI**: ${{ needs.publish-pypi.result }}" >> $GITHUB_STEP_SUMMARY echo "- ⚡ **NuGet**: ${{ needs.publish-nuget.result }}" >> $GITHUB_STEP_SUMMARY + echo "- 🟠 **Gradle/Maven**: ${{ needs.publish-gradle.result }}" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "### Installation Commands" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY @@ -271,4 +328,7 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY echo "# .NET" >> $GITHUB_STEP_SUMMARY echo "dotnet add package PolyBus --version ${{ needs.determine-version.outputs.version }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "# Kotlin (Gradle/Maven)" >> $GITHUB_STEP_SUMMARY + echo "implementation(\"com.cyascott:poly-bus:${{ needs.determine-version.outputs.version }}\")" >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY diff --git a/.gitignore b/.gitignore index dd3d86c..676d90e 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,9 @@ npm-debug.log* yarn-debug.log* yarn-error.log* +# Gradle +**/.gradle/* + # Environment .env .env.local diff --git a/README.md b/README.md index e8c72fb..db38b14 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ PolyBus provides a unified interface for sending and receiving messages between ## 🌟 Key Features -- **🔄 Multi-Language Support**: Native implementations for TypeScript, Python, and .NET +- **🔄 Multi-Language Support**: Native implementations for TypeScript, Python, .NET, and Kotlin - **🚀 Flexible Transport**: Pluggable transport layer supporting various messaging systems - **⚡ Async/Await**: Modern asynchronous APIs in all language implementations - **🔌 Middleware Pipeline**: Extensible handler chains for incoming and outgoing messages @@ -26,6 +26,7 @@ PolyBus provides a unified interface for sending and receiving messages between | **TypeScript/JavaScript** | Node.js 14+ | ✅ Stable | CommonJS, ESM, UMD | | **Python** | 3.8-3.12 | ✅ Stable | PyPI package | | **.NET** | .NET Standard 2.1 | ✅ Stable | NuGet package | +| **Kotlin** | JDK 17+ | ✅ Stable | Gradle/JVM package | | **PHP** | 8.0+ | 🚧 Planned | - | ## 🚀 Quick Start @@ -56,6 +57,38 @@ await transaction.commit(); await bus.stop(); ``` +### Kotlin + +```kotlin +import kotlinx.coroutines.runBlocking +import polybus.PolyBusBuilder +import polybus.transport.transactions.messages.MessageInfo +import polybus.transport.transactions.messages.MessageType +import polybus.transport.transactions.messages.handlers.serializers.JsonHandlers + +@MessageInfo(MessageType.EVENT, "users", "user-created", 1, 0, 0) +data class UserCreated(val userId: Int) + +fun main() = runBlocking { + val builder = PolyBusBuilder() + builder.name = "my-service" + + val jsonHandlers = JsonHandlers() + builder.incomingPipeline.add(jsonHandlers::deserializer) + builder.outgoingPipeline.add(jsonHandlers::serializer) + builder.messages.add(UserCreated::class.java) + + val bus = builder.build() + bus.start() + + val transaction = bus.createOutgoingTransaction() + transaction.add(UserCreated(userId = 123)) + transaction.commit() + + bus.stop() +} +``` + ### Python ```python @@ -217,6 +250,18 @@ dotnet test See [.NET README](src/dotnet/README.md) for detailed development instructions. +### Kotlin Development + +```bash +cd src/kotlin +gradle clean +gradle build +gradle test +gradle coverage +``` + +See [Kotlin README](src/kotlin/README.md) for detailed development instructions. + ## 🧪 Testing All implementations include comprehensive test suites: @@ -248,6 +293,15 @@ pip install poly-bus dotnet add package PolyBus ``` +### Kotlin + +```bash +cd src/kotlin +gradle build +``` + +For Kotlin package usage and publishing details, see [Kotlin README](src/kotlin/README.md). + ## 🤝 Contributing We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details on: @@ -277,6 +331,6 @@ This project is licensed under the terms specified in the [LICENSE](LICENSE) fil ## ⭐ Project Status -PolyBus is actively maintained and production-ready for TypeScript, Python, and .NET implementations. PHP support is planned for future releases. +PolyBus is actively maintained and production-ready for TypeScript, Python, .NET, and Kotlin implementations. PHP support is planned for future releases. If you find PolyBus useful, please consider giving it a star ⭐ on GitHub! \ No newline at end of file diff --git a/src/kotlin/README.md b/src/kotlin/README.md new file mode 100644 index 0000000..485f5f5 --- /dev/null +++ b/src/kotlin/README.md @@ -0,0 +1,223 @@ +# PolyBus Kotlin + +A Kotlin implementation of the PolyBus messaging library, providing a unified interface for message transport across different messaging systems. + +## Prerequisites + +- [JDK 17](https://adoptium.net/) or later +- [Gradle](https://gradle.org/install/) (if you are not using a Gradle wrapper) +- Any IDE that supports Kotlin/JVM development (IntelliJ IDEA, VS Code, Android Studio) + +## Quick Start + +### Building the Project + +```bash +# Navigate to the kotlin directory +cd src/kotlin + +# Build the project +gradle build +``` + +### Running Tests + +```bash +# Run all tests +gradle test + +# Force all tests to run even if Gradle considers them up-to-date +gradle test --rerun-tasks + +# Run checks (includes tests) +gradle check + +# Run tests with detailed output +gradle test --info +``` + +Note: `gradle test` may finish very quickly when the `test` task is up-to-date. +That means Gradle reused previous test results instead of re-running tests. +Use `gradle test --rerun-tasks` (or `gradle clean test`) when you want a full re-run. + +### Generating Coverage (Codecov XML) + +Codecov accepts JaCoCo XML reports. This project generates that format via Gradle. + +```bash +# Run tests and produce coverage reports (XML + HTML) +gradle coverage + +# Equivalent explicit command +gradle test jacocoTestReport +``` + +Coverage report output files: +- XML (for Codecov): `build/reports/jacoco/test/jacocoTestReport.xml` +- HTML (local viewing): `build/reports/jacoco/test/html/index.html` + +If you only need to regenerate coverage from a fresh run: + +```bash +gradle clean coverage +``` + +### Running Specific Tests + +```bash +# Run one test class +gradle test --tests "polybus.transport.inmemory.InMemoryTransportTests" + +# Run tests matching a name pattern +gradle test --tests "*JsonHandlersTests*" +``` + +## Development Workflow + +### Code Quality and Validation + +This module uses Kotlin and Gradle defaults with JUnit 5 test execution: + +```bash +# Clean build artifacts +gradle clean + +# Compile and run all checks +gradle build + +# Run only verification lifecycle tasks +gradle check + +# Run tests only +gradle test + +# Force test re-run (ignores up-to-date optimization) +gradle test --rerun-tasks + +# Run tests and generate Codecov-compatible XML coverage +gradle coverage +``` + +### IDE Integration + +#### Visual Studio Code +1. Install the Kotlin extension and Java Extension Pack +2. Open the `src/kotlin` folder in VS Code +3. Use the Java/Kotlin test explorer to run and debug tests +4. Use Problems panel output for compile and test failures + +#### IntelliJ IDEA / Android Studio +1. Open the `src/kotlin` folder as a Gradle project +2. Let Gradle import and resolve dependencies +3. Run tests from the gutter or the Gradle tool window + +## Configuration + +### Build Configuration + +The project is configured with Gradle Kotlin DSL (`build.gradle.kts`): + +- **Kotlin Plugin**: `org.jetbrains.kotlin.jvm` 2.1.10 +- **Java Toolchain**: JDK 17 +- **Test Platform**: JUnit 5 (`useJUnitPlatform()`) +- **Project Name**: `Poly.Bus` + +### Source Layout + +- Main sources: `src/main/kotlin/polybus/` +- Tests: `src/test/kotlin/polybus/` + +Core areas in this module include: +- Bus APIs and builders (`IPolyBus`, `PolyBus`, `PolyBusBuilder`) +- Transport abstractions and in-memory transport +- Transaction and message models +- Handler pipeline and JSON serializers + +## Dependencies + +### Main +- `org.jetbrains.kotlinx:kotlinx-coroutines-core` (1.10.2) +- `com.fasterxml.jackson.core:jackson-databind` (2.18.2) +- `com.fasterxml.jackson.module:jackson-module-kotlin` (2.18.2) + +### Test +- `org.jetbrains.kotlin:kotlin-test` (via Kotlin plugin) +- `org.junit.jupiter:junit-jupiter` (5.11.4) + +## Common Commands + +```bash +# Clean all generated files +gradle clean + +# Compile main source +gradle compileKotlin + +# Compile tests +gradle compileTestKotlin + +# Run all tests +gradle test + +# Run tests and generate JaCoCo XML/HTML coverage +gradle coverage + +# Generate JaCoCo report explicitly +gradle jacocoTestReport + +# Build artifact and run checks +gradle build + +# Show available tasks +gradle tasks + +# Inspect dependency tree +gradle dependencies +``` + +## Troubleshooting + +### Build Issues + +1. **Wrong Java version**: verify JDK 17+ + ```bash + java -version + ``` + +2. **Gradle cache/dependency problems**: refresh dependencies + ```bash + gradle --refresh-dependencies + ``` + +3. **Stale build state**: clean and rebuild + ```bash + gradle clean build + ``` + +### Test Issues + +1. **Tests not discovered**: ensure test classes are in `src/test/kotlin` and named with `*Test`/`*Tests` +2. **JUnit 5 mismatch**: ensure `useJUnitPlatform()` remains enabled in `build.gradle.kts` + +### Coverage Issues + +1. **No XML report found**: run `gradle clean coverage`, then check `build/reports/jacoco/test/jacocoTestReport.xml` +2. **Stale coverage report**: use `gradle clean coverage` to force a fresh test + report generation + +## Contributing + +1. Follow the established Kotlin coding style and existing module patterns +2. Run `gradle test` before committing +3. Run `gradle build` to validate compilation and checks +4. Add tests for new functionality +5. Update documentation when behavior or APIs change + +## Additional Resources + +- [Kotlin Documentation](https://kotlinlang.org/docs/home.html) +- [Gradle User Manual](https://docs.gradle.org/current/userguide/userguide.html) +- [JUnit 5 Documentation](https://junit.org/junit5/docs/current/user-guide/) + +## License + +See the main project LICENSE file for licensing information. diff --git a/src/kotlin/build.gradle.kts b/src/kotlin/build.gradle.kts new file mode 100644 index 0000000..77ab010 --- /dev/null +++ b/src/kotlin/build.gradle.kts @@ -0,0 +1,45 @@ +plugins { + kotlin("jvm") version "2.1.10" + jacoco +} + +group = "com.cyascott" +version = "1.0.0" + +repositories { + mavenCentral() +} + +dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2") + implementation("com.fasterxml.jackson.core:jackson-databind:2.18.2") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.18.2") + + testImplementation(kotlin("test")) + testImplementation("org.junit.jupiter:junit-jupiter:5.11.4") +} + +tasks.test { + useJUnitPlatform() + finalizedBy(tasks.jacocoTestReport) +} + +tasks.jacocoTestReport { + dependsOn(tasks.test) + + reports { + xml.required.set(true) + html.required.set(true) + csv.required.set(false) + } +} + +tasks.register("coverage") { + group = "verification" + description = "Runs tests and generates JaCoCo XML/HTML coverage reports." + dependsOn(tasks.jacocoTestReport) +} + +kotlin { + jvmToolchain(17) +} diff --git a/src/kotlin/settings.gradle.kts b/src/kotlin/settings.gradle.kts new file mode 100644 index 0000000..842a27a --- /dev/null +++ b/src/kotlin/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "Poly.Bus" diff --git a/src/kotlin/src/main/kotlin/polybus/Headers.kt b/src/kotlin/src/main/kotlin/polybus/Headers.kt new file mode 100644 index 0000000..9764bc4 --- /dev/null +++ b/src/kotlin/src/main/kotlin/polybus/Headers.kt @@ -0,0 +1,8 @@ +package polybus + +object Headers { + const val CORRELATION_ID = "correlation-id" + const val CONTENT_TYPE = "content-type" + const val MESSAGE_TYPE = "x-type" + const val REQUEST_ID = "request-id" +} diff --git a/src/kotlin/src/main/kotlin/polybus/IPolyBus.kt b/src/kotlin/src/main/kotlin/polybus/IPolyBus.kt new file mode 100644 index 0000000..47702e8 --- /dev/null +++ b/src/kotlin/src/main/kotlin/polybus/IPolyBus.kt @@ -0,0 +1,26 @@ +package polybus + +import polybus.transport.ITransport +import polybus.transport.transactions.IncomingTransaction +import polybus.transport.transactions.OutgoingTransaction +import polybus.transport.transactions.Transaction +import polybus.transport.transactions.messages.IncomingMessage +import polybus.transport.transactions.messages.Messages +import polybus.transport.transactions.messages.handlers.IncomingHandler +import polybus.transport.transactions.messages.handlers.OutgoingHandler + +interface IPolyBus { + val properties: MutableMap + var transport: ITransport + val incomingPipeline: MutableList + val outgoingPipeline: MutableList + val messages: Messages + + suspend fun createIncomingTransaction(message: IncomingMessage): IncomingTransaction + suspend fun createOutgoingTransaction(): OutgoingTransaction + suspend fun send(transaction: Transaction) + suspend fun start() + suspend fun stop() + + val name: String +} diff --git a/src/kotlin/src/main/kotlin/polybus/PolyBus.kt b/src/kotlin/src/main/kotlin/polybus/PolyBus.kt new file mode 100644 index 0000000..a0cb7a4 --- /dev/null +++ b/src/kotlin/src/main/kotlin/polybus/PolyBus.kt @@ -0,0 +1,67 @@ +package polybus + +import polybus.transport.ITransport +import polybus.transport.transactions.IncomingTransaction +import polybus.transport.transactions.OutgoingTransaction +import polybus.transport.transactions.Transaction +import polybus.transport.transactions.messages.IncomingMessage +import polybus.transport.transactions.messages.Messages +import polybus.transport.transactions.messages.handlers.IncomingHandler +import polybus.transport.transactions.messages.handlers.OutgoingHandler + +class PolyBus(private val _builder: PolyBusBuilder) : IPolyBus { + override val properties: MutableMap + get() = _builder.properties + + override lateinit var transport: ITransport + + override val incomingPipeline: MutableList = _builder.incomingPipeline + override val outgoingPipeline: MutableList = _builder.outgoingPipeline + override val messages: Messages = _builder.messages + + override suspend fun createIncomingTransaction(message: IncomingMessage): IncomingTransaction = + _builder.incomingTransactionFactory(_builder, this, message) + + override suspend fun createOutgoingTransaction(): OutgoingTransaction = + _builder.outgoingTransactionFactory(_builder, this) + + override suspend fun send(transaction: Transaction) { + var step: suspend () -> Unit = { transport.handle(transaction) } + + when (transaction) { + is IncomingTransaction -> { + for (index in transaction.bus.incomingPipeline.indices.reversed()) { + val handler = transaction.bus.incomingPipeline[index] + val next = step + step = { handler(transaction, next) } + } + } + + is OutgoingTransaction -> { + for (index in transaction.bus.outgoingPipeline.indices.reversed()) { + val handler = transaction.bus.outgoingPipeline[index] + val next = step + step = { handler(transaction, next) } + } + } + } + + try { + step() + } catch (error: Throwable) { + transaction.abort() + throw error + } + } + + override suspend fun start() { + transport.start() + } + + override suspend fun stop() { + transport.stop() + } + + override val name: String + get() = _builder.name +} diff --git a/src/kotlin/src/main/kotlin/polybus/PolyBusBuilder.kt b/src/kotlin/src/main/kotlin/polybus/PolyBusBuilder.kt new file mode 100644 index 0000000..f7e2ed3 --- /dev/null +++ b/src/kotlin/src/main/kotlin/polybus/PolyBusBuilder.kt @@ -0,0 +1,38 @@ +package polybus + +import polybus.transport.TransportFactory +import polybus.transport.inmemory.InMemoryMessageBroker +import polybus.transport.transactions.IncomingTransaction +import polybus.transport.transactions.IncomingTransactionFactory +import polybus.transport.transactions.OutgoingTransaction +import polybus.transport.transactions.OutgoingTransactionFactory +import polybus.transport.transactions.messages.Messages +import polybus.transport.transactions.messages.handlers.IncomingHandler +import polybus.transport.transactions.messages.handlers.OutgoingHandler + +open class PolyBusBuilder { + var incomingTransactionFactory: IncomingTransactionFactory = { _, bus, message -> + IncomingTransaction(bus, message) + } + + var outgoingTransactionFactory: OutgoingTransactionFactory = { _, bus -> + OutgoingTransaction(bus) + } + + var transportFactory: TransportFactory = { builder, bus -> + val transport = InMemoryMessageBroker() + transport.addEndpoint(builder, bus) + } + + val properties: MutableMap = mutableMapOf() + val incomingPipeline: MutableList = mutableListOf() + val outgoingPipeline: MutableList = mutableListOf() + val messages: Messages = Messages() + var name: String = "polybus" + + open suspend fun build(): IPolyBus { + val bus = PolyBus(this) + bus.transport = transportFactory(this, bus) + return bus + } +} diff --git a/src/kotlin/src/main/kotlin/polybus/PolyBusError.kt b/src/kotlin/src/main/kotlin/polybus/PolyBusError.kt new file mode 100644 index 0000000..0301f99 --- /dev/null +++ b/src/kotlin/src/main/kotlin/polybus/PolyBusError.kt @@ -0,0 +1,3 @@ +package polybus + +open class PolyBusError(val errorCode: Int, message: String) : RuntimeException(message) diff --git a/src/kotlin/src/main/kotlin/polybus/transport/ITransport.kt b/src/kotlin/src/main/kotlin/polybus/transport/ITransport.kt new file mode 100644 index 0000000..11d98a6 --- /dev/null +++ b/src/kotlin/src/main/kotlin/polybus/transport/ITransport.kt @@ -0,0 +1,15 @@ +package polybus.transport + +import polybus.transport.transactions.Transaction +import polybus.transport.transactions.messages.MessageInfoValue + +interface ITransport { + val deadLetterEndpoint: String + suspend fun handle(transaction: Transaction) + val supportsDelayedCommands: Boolean + val supportsCommandMessages: Boolean + suspend fun subscribe(messageInfo: MessageInfoValue) + val supportsSubscriptions: Boolean + suspend fun start() + suspend fun stop() +} diff --git a/src/kotlin/src/main/kotlin/polybus/transport/PolyBusNotStartedError.kt b/src/kotlin/src/main/kotlin/polybus/transport/PolyBusNotStartedError.kt new file mode 100644 index 0000000..7f3b33d --- /dev/null +++ b/src/kotlin/src/main/kotlin/polybus/transport/PolyBusNotStartedError.kt @@ -0,0 +1,8 @@ +package polybus.transport + +import polybus.PolyBusError + +class PolyBusNotStartedError : PolyBusError( + 1, + "PolyBus has not been started. Please call IPolyBus.start() before using the bus." +) diff --git a/src/kotlin/src/main/kotlin/polybus/transport/TransportFactory.kt b/src/kotlin/src/main/kotlin/polybus/transport/TransportFactory.kt new file mode 100644 index 0000000..9d21b19 --- /dev/null +++ b/src/kotlin/src/main/kotlin/polybus/transport/TransportFactory.kt @@ -0,0 +1,6 @@ +package polybus.transport + +import polybus.IPolyBus +import polybus.PolyBusBuilder + +typealias TransportFactory = suspend (builder: PolyBusBuilder, bus: IPolyBus) -> ITransport diff --git a/src/kotlin/src/main/kotlin/polybus/transport/inmemory/InMemoryEndpoint.kt b/src/kotlin/src/main/kotlin/polybus/transport/inmemory/InMemoryEndpoint.kt new file mode 100644 index 0000000..f52d468 --- /dev/null +++ b/src/kotlin/src/main/kotlin/polybus/transport/inmemory/InMemoryEndpoint.kt @@ -0,0 +1,68 @@ +package polybus.transport.inmemory + +import polybus.IPolyBus +import polybus.transport.ITransport +import polybus.transport.PolyBusNotStartedError +import polybus.transport.transactions.Transaction +import polybus.transport.transactions.messages.IncomingMessage +import polybus.transport.transactions.messages.MessageInfoValue +import java.util.concurrent.ConcurrentHashMap + +open class InMemoryEndpoint( + private val _broker: InMemoryMessageBroker, + private val _bus: IPolyBus +) : ITransport { + val bus: IPolyBus + get() = _bus + + var deadLetterHandler: ((IncomingMessage) -> Unit)? = null + + var active: Boolean = false + private set + + override val deadLetterEndpoint: String + get() = "${_bus.name}.dead.letters" + + open suspend fun handleMessage(message: IncomingMessage, isDeadLetter: Boolean) { + if (!active) { + return + } + if (isDeadLetter) { + deadLetterHandler?.invoke(message) + return + } + val transaction = _bus.createIncomingTransaction(message) + _bus.send(transaction) + } + + override suspend fun handle(transaction: Transaction) { + if (!active) { + throw PolyBusNotStartedError() + } + _broker.send(transaction) + } + + override val supportsDelayedCommands: Boolean = true + override val supportsCommandMessages: Boolean = true + override val supportsSubscriptions: Boolean = true + + private val _subscriptions = ConcurrentHashMap() + + override suspend fun subscribe(messageInfo: MessageInfoValue) { + if (!active) { + throw PolyBusNotStartedError() + } + _subscriptions[messageInfo.toHeaderString(false)] = true + } + + fun isSubscribed(messageInfo: MessageInfoValue): Boolean = + _subscriptions.containsKey(messageInfo.toHeaderString(false)) + + override suspend fun start() { + active = true + } + + override suspend fun stop() { + active = false + } +} diff --git a/src/kotlin/src/main/kotlin/polybus/transport/inmemory/InMemoryMessageBroker.kt b/src/kotlin/src/main/kotlin/polybus/transport/inmemory/InMemoryMessageBroker.kt new file mode 100644 index 0000000..f337d4b --- /dev/null +++ b/src/kotlin/src/main/kotlin/polybus/transport/inmemory/InMemoryMessageBroker.kt @@ -0,0 +1,111 @@ +package polybus.transport.inmemory + +import polybus.IPolyBus +import polybus.PolyBusBuilder +import polybus.transport.ITransport +import polybus.transport.transactions.Transaction +import polybus.transport.transactions.messages.IncomingMessage +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.time.Duration +import java.time.Instant +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger +import java.util.logging.Level +import java.util.logging.Logger + +open class InMemoryMessageBroker { + val endpoints: ConcurrentHashMap = ConcurrentHashMap() + var log: Logger = Logger.getLogger(InMemoryMessageBroker::class.java.name) + + private val _scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val _activeSends = AtomicInteger(0) + private val _jobs: ConcurrentHashMap.KeySetView = ConcurrentHashMap.newKeySet() + + open suspend fun addEndpoint(builder: PolyBusBuilder, bus: IPolyBus): ITransport { + val endpoint = InMemoryEndpoint(this, bus) + endpoints[bus.name] = endpoint + return endpoint + } + + open fun send(transaction: Transaction) { + if (transaction.outgoingMessages.isEmpty()) { + return + } + + val job = _scope.launch { + _activeSends.incrementAndGet() + try { + val now = Instant.now() + transaction.outgoingMessages.forEach { message -> + endpoints.values.forEach { endpoint -> + val isDeadLetter = endpoint.deadLetterEndpoint == message.endpoint + val shouldSend = isDeadLetter || + endpoint.bus.name == message.endpoint || + (message.endpoint == null && + (message.messageInfo.endpoint == endpoint.bus.name || + endpoint.isSubscribed(message.messageInfo))) + + if (!shouldSend) { + return@forEach + } + + val incomingMessage = IncomingMessage(endpoint.bus, message.body, message.messageInfo) + incomingMessage.headers = message.headers.toMutableMap() + + val deliverAt = message.deliverAt + if (deliverAt != null) { + val wait = Duration.between(now, deliverAt) + if (!wait.isNegative && !wait.isZero) { + delayedSend(endpoint, incomingMessage, wait, isDeadLetter) + return@forEach + } + } + + endpoint.handleMessage(incomingMessage, isDeadLetter) + } + } + } catch (error: Throwable) { + log.log(Level.SEVERE, error.message, error) + } finally { + _activeSends.decrementAndGet() + } + } + + _jobs.add(job) + job.invokeOnCompletion { _jobs.remove(job) } + } + + private fun delayedSend( + endpoint: InMemoryEndpoint, + message: IncomingMessage, + delayBy: Duration, + isDeadLetter: Boolean + ) { + val job = _scope.launch { + try { + delay(delayBy.toMillis()) + endpoint.handleMessage(message, isDeadLetter) + } catch (_: CancellationException) { + } catch (error: Throwable) { + log.log(Level.SEVERE, error.message, error) + } + } + + _jobs.add(job) + job.invokeOnCompletion { _jobs.remove(job) } + } + + open suspend fun stop() { + endpoints.values.forEach { it.stop() } + _jobs.forEach { it.cancel() } + _jobs.toList().forEach { it.join() } + while (_activeSends.get() > 0) { + delay(10) + } + } +} diff --git a/src/kotlin/src/main/kotlin/polybus/transport/transactions/IncomingTransaction.kt b/src/kotlin/src/main/kotlin/polybus/transport/transactions/IncomingTransaction.kt new file mode 100644 index 0000000..9e59b45 --- /dev/null +++ b/src/kotlin/src/main/kotlin/polybus/transport/transactions/IncomingTransaction.kt @@ -0,0 +1,8 @@ +package polybus.transport.transactions + +import polybus.IPolyBus +import polybus.transport.transactions.messages.IncomingMessage + +open class IncomingTransaction(bus: IPolyBus, incomingMessage: IncomingMessage) : Transaction(bus) { + open var incomingMessage: IncomingMessage = incomingMessage +} diff --git a/src/kotlin/src/main/kotlin/polybus/transport/transactions/IncomingTransactionFactory.kt b/src/kotlin/src/main/kotlin/polybus/transport/transactions/IncomingTransactionFactory.kt new file mode 100644 index 0000000..76e3fab --- /dev/null +++ b/src/kotlin/src/main/kotlin/polybus/transport/transactions/IncomingTransactionFactory.kt @@ -0,0 +1,11 @@ +package polybus.transport.transactions + +import polybus.IPolyBus +import polybus.PolyBusBuilder +import polybus.transport.transactions.messages.IncomingMessage + +typealias IncomingTransactionFactory = suspend ( + builder: PolyBusBuilder, + bus: IPolyBus, + message: IncomingMessage +) -> IncomingTransaction diff --git a/src/kotlin/src/main/kotlin/polybus/transport/transactions/OutgoingTransaction.kt b/src/kotlin/src/main/kotlin/polybus/transport/transactions/OutgoingTransaction.kt new file mode 100644 index 0000000..1119a6d --- /dev/null +++ b/src/kotlin/src/main/kotlin/polybus/transport/transactions/OutgoingTransaction.kt @@ -0,0 +1,5 @@ +package polybus.transport.transactions + +import polybus.IPolyBus + +open class OutgoingTransaction(bus: IPolyBus) : Transaction(bus) diff --git a/src/kotlin/src/main/kotlin/polybus/transport/transactions/OutgoingTransactionFactory.kt b/src/kotlin/src/main/kotlin/polybus/transport/transactions/OutgoingTransactionFactory.kt new file mode 100644 index 0000000..28f0301 --- /dev/null +++ b/src/kotlin/src/main/kotlin/polybus/transport/transactions/OutgoingTransactionFactory.kt @@ -0,0 +1,6 @@ +package polybus.transport.transactions + +import polybus.IPolyBus +import polybus.PolyBusBuilder + +typealias OutgoingTransactionFactory = suspend (builder: PolyBusBuilder, bus: IPolyBus) -> OutgoingTransaction diff --git a/src/kotlin/src/main/kotlin/polybus/transport/transactions/Transaction.kt b/src/kotlin/src/main/kotlin/polybus/transport/transactions/Transaction.kt new file mode 100644 index 0000000..d0ce100 --- /dev/null +++ b/src/kotlin/src/main/kotlin/polybus/transport/transactions/Transaction.kt @@ -0,0 +1,25 @@ +package polybus.transport.transactions + +import polybus.IPolyBus +import polybus.transport.transactions.messages.OutgoingMessage + +open class Transaction(private val _bus: IPolyBus) { + val bus: IPolyBus + get() = _bus + + open val state: MutableMap = mutableMapOf() + open val outgoingMessages: MutableList = mutableListOf() + + open fun add(message: Any, endpoint: String? = null): OutgoingMessage { + val outgoingMessage = OutgoingMessage(_bus, message, endpoint) + outgoingMessages.add(outgoingMessage) + return outgoingMessage + } + + open suspend fun abort() { + } + + open suspend fun commit() { + bus.send(this) + } +} diff --git a/src/kotlin/src/main/kotlin/polybus/transport/transactions/messages/IncomingMessage.kt b/src/kotlin/src/main/kotlin/polybus/transport/transactions/messages/IncomingMessage.kt new file mode 100644 index 0000000..a8101b0 --- /dev/null +++ b/src/kotlin/src/main/kotlin/polybus/transport/transactions/messages/IncomingMessage.kt @@ -0,0 +1,14 @@ +package polybus.transport.transactions.messages + +import polybus.IPolyBus + +open class IncomingMessage( + bus: IPolyBus, + body: String, + messageInfo: MessageInfoValue +) : Message(bus) { + open var messageInfo: MessageInfoValue = messageInfo + open var messageType: Class<*> = bus.messages.getTypeByMessageInfo(messageInfo) + open var body: String = body + open var message: Any = body +} diff --git a/src/kotlin/src/main/kotlin/polybus/transport/transactions/messages/Message.kt b/src/kotlin/src/main/kotlin/polybus/transport/transactions/messages/Message.kt new file mode 100644 index 0000000..a70f99f --- /dev/null +++ b/src/kotlin/src/main/kotlin/polybus/transport/transactions/messages/Message.kt @@ -0,0 +1,11 @@ +package polybus.transport.transactions.messages + +import polybus.IPolyBus + +open class Message(private val _bus: IPolyBus) { + open val state: MutableMap = mutableMapOf() + open var headers: MutableMap = mutableMapOf() + + open val bus: IPolyBus + get() = _bus +} diff --git a/src/kotlin/src/main/kotlin/polybus/transport/transactions/messages/MessageInfo.kt b/src/kotlin/src/main/kotlin/polybus/transport/transactions/messages/MessageInfo.kt new file mode 100644 index 0000000..5dd52b1 --- /dev/null +++ b/src/kotlin/src/main/kotlin/polybus/transport/transactions/messages/MessageInfo.kt @@ -0,0 +1,79 @@ +package polybus.transport.transactions.messages + +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +annotation class MessageInfo( + val type: MessageType, + val endpoint: String, + val name: String, + val major: Int, + val minor: Int, + val patch: Int +) + +class MessageInfoValue( + val type: MessageType, + val endpoint: String, + val name: String, + val major: Int, + val minor: Int, + val patch: Int +) { + fun equalsInfo(other: MessageInfoValue?): Boolean { + return other != null + && type == other.type + && endpoint == other.endpoint + && name == other.name + && major == other.major + } + + override fun equals(other: Any?): Boolean = other is MessageInfoValue && equalsInfo(other) + + override fun hashCode(): Int { + var result = type.hashCode() + result = 31 * result + endpoint.hashCode() + result = 31 * result + name.hashCode() + result = 31 * result + major + result = 31 * result + minor + result = 31 * result + patch + return result + } + + fun toHeaderString(includeVersion: Boolean): String { + val base = "endpoint=$endpoint, type=$type, name=$name" + return if (includeVersion) "$base, version=$major.$minor.$patch" else base + } + + override fun toString(): String = toHeaderString(true) + + companion object { + private val _headerPattern = Regex( + "^endpoint\\s*=\\s*(?[^,\\s]+),\\s*type\\s*=\\s*(?[^,\\s]+),\\s*name\\s*=\\s*(?[^,\\s]+),\\s*version\\s*=\\s*(?\\d+)\\.(?\\d+)\\.(?\\d+)", + setOf(RegexOption.IGNORE_CASE) + ) + + fun getAttributeFromHeader(header: String): MessageInfoValue? { + val match = _headerPattern.find(header) ?: return null + val endpoint = match.groups["endpoint"]?.value ?: return null + val name = match.groups["name"]?.value ?: return null + val typeText = match.groups["type"]?.value ?: return null + val type = MessageType.entries.firstOrNull { it.name.equals(typeText, ignoreCase = true) } + ?: throw IllegalArgumentException("Requested value '$typeText' was not found.") + val major = match.groups["major"]?.value?.toIntOrNull() ?: return null + val minor = match.groups["minor"]?.value?.toIntOrNull() ?: return null + val patch = match.groups["patch"]?.value?.toIntOrNull() ?: return null + return MessageInfoValue(type, endpoint, name, major, minor, patch) + } + + fun fromAnnotation(annotation: MessageInfo): MessageInfoValue { + return MessageInfoValue( + type = annotation.type, + endpoint = annotation.endpoint, + name = annotation.name, + major = annotation.major, + minor = annotation.minor, + patch = annotation.patch + ) + } + } +} diff --git a/src/kotlin/src/main/kotlin/polybus/transport/transactions/messages/MessageType.kt b/src/kotlin/src/main/kotlin/polybus/transport/transactions/messages/MessageType.kt new file mode 100644 index 0000000..51d912d --- /dev/null +++ b/src/kotlin/src/main/kotlin/polybus/transport/transactions/messages/MessageType.kt @@ -0,0 +1,6 @@ +package polybus.transport.transactions.messages + +enum class MessageType { + COMMAND, + EVENT +} diff --git a/src/kotlin/src/main/kotlin/polybus/transport/transactions/messages/Messages.kt b/src/kotlin/src/main/kotlin/polybus/transport/transactions/messages/Messages.kt new file mode 100644 index 0000000..d41f608 --- /dev/null +++ b/src/kotlin/src/main/kotlin/polybus/transport/transactions/messages/Messages.kt @@ -0,0 +1,36 @@ +package polybus.transport.transactions.messages + +class Messages { + protected val types: MutableMap, Pair> = mutableMapOf() + + open fun getMessageInfo(type: Class<*>): MessageInfoValue { + return types[type]?.first ?: throw PolyBusMessageNotFoundError() + } + + open fun getHeaderByMessageInfo(messageInfo: MessageInfoValue): String { + val contains = types.values.any { it.first.equalsInfo(messageInfo) } + if (!contains) { + throw PolyBusMessageNotFoundError() + } + return messageInfo.toHeaderString(true) + } + + open fun add(messageType: Class<*>): MessageInfoValue { + val annotation = messageType.getAnnotation(MessageInfo::class.java) + ?: throw PolyBusMessageNotFoundError() + val attribute = MessageInfoValue.fromAnnotation(annotation) + val header = attribute.toHeaderString(true) + + if (types.containsKey(messageType)) { + throw PolyBusMessageNotFoundError() + } + + types[messageType] = attribute to header + return attribute + } + + open fun getTypeByMessageInfo(messageInfo: MessageInfoValue): Class<*> { + return types.entries.firstOrNull { it.value.first.equalsInfo(messageInfo) }?.key + ?: throw PolyBusMessageNotFoundError() + } +} diff --git a/src/kotlin/src/main/kotlin/polybus/transport/transactions/messages/OutgoingMessage.kt b/src/kotlin/src/main/kotlin/polybus/transport/transactions/messages/OutgoingMessage.kt new file mode 100644 index 0000000..c7e7e03 --- /dev/null +++ b/src/kotlin/src/main/kotlin/polybus/transport/transactions/messages/OutgoingMessage.kt @@ -0,0 +1,18 @@ +package polybus.transport.transactions.messages + +import polybus.IPolyBus +import java.time.Instant + +open class OutgoingMessage( + bus: IPolyBus, + message: Any, + endpoint: String? = null, + messageInfo: MessageInfoValue? = null +) : Message(bus) { + open var deliverAt: Instant? = null + open var messageInfo: MessageInfoValue = messageInfo ?: bus.messages.getMessageInfo(message.javaClass) + open var messageType: Class<*> = message.javaClass + open var endpoint: String? = endpoint + open var body: String = "" + open var message: Any = message +} diff --git a/src/kotlin/src/main/kotlin/polybus/transport/transactions/messages/PolyBusMessageNotFoundError.kt b/src/kotlin/src/main/kotlin/polybus/transport/transactions/messages/PolyBusMessageNotFoundError.kt new file mode 100644 index 0000000..f71d7fb --- /dev/null +++ b/src/kotlin/src/main/kotlin/polybus/transport/transactions/messages/PolyBusMessageNotFoundError.kt @@ -0,0 +1,8 @@ +package polybus.transport.transactions.messages + +import polybus.PolyBusError + +class PolyBusMessageNotFoundError : PolyBusError( + 2, + "The requested type, attribute/decorator, or header was not found." +) diff --git a/src/kotlin/src/main/kotlin/polybus/transport/transactions/messages/handlers/IncomingHandler.kt b/src/kotlin/src/main/kotlin/polybus/transport/transactions/messages/handlers/IncomingHandler.kt new file mode 100644 index 0000000..a4c0787 --- /dev/null +++ b/src/kotlin/src/main/kotlin/polybus/transport/transactions/messages/handlers/IncomingHandler.kt @@ -0,0 +1,5 @@ +package polybus.transport.transactions.messages.handlers + +import polybus.transport.transactions.IncomingTransaction + +typealias IncomingHandler = suspend (transaction: IncomingTransaction, next: suspend () -> Unit) -> Unit diff --git a/src/kotlin/src/main/kotlin/polybus/transport/transactions/messages/handlers/OutgoingHandler.kt b/src/kotlin/src/main/kotlin/polybus/transport/transactions/messages/handlers/OutgoingHandler.kt new file mode 100644 index 0000000..ff067fc --- /dev/null +++ b/src/kotlin/src/main/kotlin/polybus/transport/transactions/messages/handlers/OutgoingHandler.kt @@ -0,0 +1,5 @@ +package polybus.transport.transactions.messages.handlers + +import polybus.transport.transactions.OutgoingTransaction + +typealias OutgoingHandler = suspend (transaction: OutgoingTransaction, next: suspend () -> Unit) -> Unit diff --git a/src/kotlin/src/main/kotlin/polybus/transport/transactions/messages/handlers/error/ErrorHandler.kt b/src/kotlin/src/main/kotlin/polybus/transport/transactions/messages/handlers/error/ErrorHandler.kt new file mode 100644 index 0000000..e75d9ef --- /dev/null +++ b/src/kotlin/src/main/kotlin/polybus/transport/transactions/messages/handlers/error/ErrorHandler.kt @@ -0,0 +1,73 @@ +package polybus.transport.transactions.messages.handlers.error + +import polybus.transport.transactions.IncomingTransaction +import polybus.transport.transactions.messages.OutgoingMessage +import java.time.Instant +import java.time.temporal.ChronoUnit +import java.util.logging.Level +import java.util.logging.Logger + +open class ErrorHandler { + open var log: Logger = Logger.getLogger(ErrorHandler::class.java.name) + open var delayIncrement: Int = 30 + open var delayedRetryCount: Int = 3 + open var immediateRetryCount: Int = 3 + open var errorMessageHeader: String = "x-error-message" + open var errorStackTraceHeader: String = "x-error-stack-trace" + open var retryCountHeader: String = "x-retry-count" + + open suspend fun retrier(transaction: IncomingTransaction, next: suspend () -> Unit) { + var delayedAttempt = transaction.incomingMessage.headers[retryCountHeader]?.toIntOrNull() ?: 0 + val delayedRetries = maxOf(1, delayedRetryCount) + val immediateRetries = maxOf(1, immediateRetryCount) + + for (immediateAttempt in 0 until immediateRetries) { + try { + next() + break + } catch (error: Throwable) { + log.log( + Level.SEVERE, + "Error processing message ${transaction.incomingMessage.messageInfo} (immediate attempts: $immediateAttempt, delayed attempts: $delayedAttempt): ${error.message}", + error + ) + + transaction.outgoingMessages.clear() + + if (immediateAttempt < immediateRetries - 1) { + continue + } + + if (transaction.incomingMessage.bus.transport.supportsDelayedCommands && delayedAttempt < delayedRetries) { + delayedAttempt += 1 + val delayedMessage = OutgoingMessage( + transaction.bus, + transaction.incomingMessage.message, + transaction.bus.name, + transaction.incomingMessage.messageInfo + ) + delayedMessage.deliverAt = getNextRetryTime(delayedAttempt) + delayedMessage.headers = transaction.incomingMessage.headers.toMutableMap() + delayedMessage.headers[retryCountHeader] = delayedAttempt.toString() + transaction.outgoingMessages.add(delayedMessage) + continue + } + + val deadLetterMessage = OutgoingMessage( + transaction.bus, + transaction.incomingMessage.message, + transaction.bus.transport.deadLetterEndpoint, + transaction.incomingMessage.messageInfo + ) + deadLetterMessage.headers = transaction.incomingMessage.headers.toMutableMap() + deadLetterMessage.headers[errorMessageHeader] = error.message.orEmpty() + deadLetterMessage.headers[errorStackTraceHeader] = + if (error.stackTrace.isEmpty()) "" else error.stackTraceToString() + transaction.outgoingMessages.add(deadLetterMessage) + } + } + } + + open fun getNextRetryTime(attempt: Int): Instant = + Instant.now().plus((attempt * delayIncrement).toLong(), ChronoUnit.SECONDS) +} diff --git a/src/kotlin/src/main/kotlin/polybus/transport/transactions/messages/handlers/serializers/JsonHandlers.kt b/src/kotlin/src/main/kotlin/polybus/transport/transactions/messages/handlers/serializers/JsonHandlers.kt new file mode 100644 index 0000000..f3bd202 --- /dev/null +++ b/src/kotlin/src/main/kotlin/polybus/transport/transactions/messages/handlers/serializers/JsonHandlers.kt @@ -0,0 +1,27 @@ +package polybus.transport.transactions.messages.handlers.serializers + +import polybus.Headers +import polybus.transport.transactions.IncomingTransaction +import polybus.transport.transactions.OutgoingTransaction +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.registerKotlinModule + +open class JsonHandlers { + open var objectMapper: ObjectMapper = ObjectMapper().registerKotlinModule() + open var contentType: String = "application/json" + open var header: String = Headers.CONTENT_TYPE + + open suspend fun deserializer(transaction: IncomingTransaction, next: suspend () -> Unit) { + val incomingMessage = transaction.incomingMessage + incomingMessage.message = objectMapper.readValue(incomingMessage.body, incomingMessage.messageType) + next() + } + + open suspend fun serializer(transaction: OutgoingTransaction, next: suspend () -> Unit) { + transaction.outgoingMessages.forEach { message -> + message.body = objectMapper.writeValueAsString(message.message) + message.headers[header] = contentType + } + next() + } +} diff --git a/src/kotlin/src/test/kotlin/polybus/transport/inmemory/AlphaCommand.kt b/src/kotlin/src/test/kotlin/polybus/transport/inmemory/AlphaCommand.kt new file mode 100644 index 0000000..bad0732 --- /dev/null +++ b/src/kotlin/src/test/kotlin/polybus/transport/inmemory/AlphaCommand.kt @@ -0,0 +1,9 @@ +package polybus.transport.inmemory + +import polybus.transport.transactions.messages.MessageInfo +import polybus.transport.transactions.messages.MessageType + +@MessageInfo(MessageType.COMMAND, "alpha", "alpha-command", 1, 0, 0) +class AlphaCommand { + lateinit var name: String +} diff --git a/src/kotlin/src/test/kotlin/polybus/transport/inmemory/AlphaEvent.kt b/src/kotlin/src/test/kotlin/polybus/transport/inmemory/AlphaEvent.kt new file mode 100644 index 0000000..7c9dc97 --- /dev/null +++ b/src/kotlin/src/test/kotlin/polybus/transport/inmemory/AlphaEvent.kt @@ -0,0 +1,9 @@ +package polybus.transport.inmemory + +import polybus.transport.transactions.messages.MessageInfo +import polybus.transport.transactions.messages.MessageType + +@MessageInfo(MessageType.EVENT, "alpha", "alpha-event", 1, 0, 0) +class AlphaEvent { + lateinit var name: String +} diff --git a/src/kotlin/src/test/kotlin/polybus/transport/inmemory/InMemoryTransportTests.kt b/src/kotlin/src/test/kotlin/polybus/transport/inmemory/InMemoryTransportTests.kt new file mode 100644 index 0000000..cad0fd5 --- /dev/null +++ b/src/kotlin/src/test/kotlin/polybus/transport/inmemory/InMemoryTransportTests.kt @@ -0,0 +1,194 @@ +package polybus.transport.inmemory + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import polybus.transport.PolyBusNotStartedError +import java.time.Instant + +class InMemoryTransportTests { + private lateinit var testEnvironment: TestEnvironment + + @BeforeEach + fun setUp() = runBlocking { + testEnvironment = TestEnvironment() + testEnvironment.setup() + } + + @AfterEach + fun tearDown() = runBlocking { + testEnvironment.stop() + } + + @Test + fun sendBeforeStarting() = runBlocking { + // Arrange + val transaction = testEnvironment.beta.bus.createOutgoingTransaction() + val called = CompletableDeferred() + testEnvironment.alpha.onMessageReceived = { called.complete(true) } + + // Act + transaction.add(AlphaCommand().apply { name = "Test" }) + + // Assert - should throw an error because the transport is not started + assertThrows { runBlocking { transaction.commit() } } + assertFalse(called.isCompleted) + } + + @Test + fun sendAfterStarted() = runBlocking { + // Arrange + val transaction = testEnvironment.beta.bus.createOutgoingTransaction() + val called = CompletableDeferred() + testEnvironment.alpha.onMessageReceived = { called.complete(true) } + + // Act - send a command from the beta endpoint to alpha endpoint + testEnvironment.start() + transaction.add(AlphaCommand().apply { name = "Test" }) + transaction.commit() + + // Assert + assertTrue(called.await()) + } + + @Test + fun sendWithExplicitEndpoint() = runBlocking { + // Arrange + val transaction = testEnvironment.alpha.bus.createOutgoingTransaction() + val result = CompletableDeferred() + testEnvironment.alpha.onMessageReceived = { + // This should NOT be called + result.complete(it.bus.name) + } + testEnvironment.alpha.transport.deadLetterHandler = { + // This should be called + result.complete(testEnvironment.alpha.transport.deadLetterEndpoint) + } + val endpoint = testEnvironment.alpha.transport.deadLetterEndpoint + + // Act - send the alpha command to dead letter endpoint + testEnvironment.start() + transaction.add(AlphaCommand().apply { name = "Test" }, endpoint = endpoint) + transaction.commit() + + // Assert + assertEquals(endpoint, result.await()) + } + + @Test + fun sendWithHeaders() = runBlocking { + // Arrange + val key = "X-Custom-Header" + val value = "HeaderValue" + val transaction = testEnvironment.alpha.bus.createOutgoingTransaction() + val result = CompletableDeferred() + testEnvironment.alpha.onMessageReceived = { + result.complete(it.incomingMessage.headers[key].orEmpty()) + } + + // Act - send a command with a custom header + testEnvironment.start() + val message = transaction.add(AlphaCommand().apply { name = "Test" }) + message.headers[key] = value + transaction.commit() + + // Assert + assertEquals(value, result.await()) + } + + @Test + fun sendWithDelay() = runBlocking { + // Arrange + val delayMs = 5000L // 5 seconds + val transaction = testEnvironment.alpha.bus.createOutgoingTransaction() + val result = CompletableDeferred() + var startedAt = 0L + testEnvironment.alpha.onMessageReceived = { + result.complete(System.currentTimeMillis() - startedAt) + } + + // Act - send to the dead letters queue instead of normal processing queue + testEnvironment.start() + val message = transaction.add(AlphaCommand().apply { name = "Test" }) + message.deliverAt = Instant.now().plusMillis(delayMs) + startedAt = System.currentTimeMillis() + transaction.commit() + val elapsedMs = result.await() + + // Assert - allow 1 second of leeway + assertTrue(elapsedMs in (delayMs - 1000)..(delayMs + 1000)) + } + + @Test + fun sendWithExpiredDelay() = runBlocking { + // Arrange + val transaction = testEnvironment.alpha.bus.createOutgoingTransaction() + val called = CompletableDeferred() + testEnvironment.alpha.onMessageReceived = { + called.complete(true) + } + + // Act - schedule command to be delivered in the past + testEnvironment.start() + val message = transaction.add(AlphaCommand().apply { name = "Test" }) + message.deliverAt = Instant.now().minusMillis(1000) // 1 second in the past + transaction.commit() + + // Assert + assertTrue(called.await()) + } + + @Test + fun startWhenAlreadyStarted() = runBlocking { + // Act + testEnvironment.start() + + // Assert - starting again should not throw an error + testEnvironment.start() + } + + @Test + fun subscribeBeforeStarted() = runBlocking { + // Arrange + val transaction = testEnvironment.alpha.bus.createOutgoingTransaction() + val called = CompletableDeferred() + testEnvironment.beta.onMessageReceived = { called.complete(true) } + + // Act + Assert - subscribing and sending before starting should throw + assertThrows { + runBlocking { + testEnvironment.beta.transport.subscribe( + testEnvironment.beta.bus.messages.getMessageInfo(AlphaEvent::class.java) + ) + } + } + transaction.add(AlphaEvent().apply { name = "Test" }) + assertThrows { runBlocking { transaction.commit() } } + + // Assert + assertFalse(called.isCompleted) + } + + @Test + fun subscribe() = runBlocking { + // Arrange + val transaction = testEnvironment.alpha.bus.createOutgoingTransaction() + val called = CompletableDeferred() + testEnvironment.beta.onMessageReceived = { called.complete(true) } + + // Act - subscribing before starting should throw an error + testEnvironment.start() + testEnvironment.beta.transport.subscribe( + testEnvironment.beta.bus.messages.getMessageInfo(AlphaEvent::class.java) + ) + transaction.add(AlphaEvent().apply { name = "Test" }) + transaction.commit() + assertTrue(called.await()) + } +} diff --git a/src/kotlin/src/test/kotlin/polybus/transport/inmemory/TestEndpoint.kt b/src/kotlin/src/test/kotlin/polybus/transport/inmemory/TestEndpoint.kt new file mode 100644 index 0000000..88342ac --- /dev/null +++ b/src/kotlin/src/test/kotlin/polybus/transport/inmemory/TestEndpoint.kt @@ -0,0 +1,18 @@ +package polybus.transport.inmemory + +import polybus.IPolyBus +import polybus.PolyBusBuilder +import polybus.transport.transactions.IncomingTransaction + +class TestEndpoint { + var onMessageReceived: suspend (IncomingTransaction) -> Unit = {} + lateinit var bus: IPolyBus + val builder: PolyBusBuilder = PolyBusBuilder() + val transport: InMemoryEndpoint + get() = bus.transport as InMemoryEndpoint + + suspend fun handler(transaction: IncomingTransaction, next: suspend () -> Unit) { + onMessageReceived(transaction) + next() + } +} diff --git a/src/kotlin/src/test/kotlin/polybus/transport/inmemory/TestEnvironment.kt b/src/kotlin/src/test/kotlin/polybus/transport/inmemory/TestEnvironment.kt new file mode 100644 index 0000000..f89bea4 --- /dev/null +++ b/src/kotlin/src/test/kotlin/polybus/transport/inmemory/TestEnvironment.kt @@ -0,0 +1,48 @@ +package polybus.transport.inmemory + +import polybus.transport.transactions.messages.handlers.serializers.JsonHandlers +import java.util.logging.Logger + +class TestEnvironment { + val inMemoryMessageBroker: InMemoryMessageBroker = InMemoryMessageBroker() + val alpha: TestEndpoint = TestEndpoint() + val beta: TestEndpoint = TestEndpoint() + + suspend fun setup() { + setupEndpoint(alpha, "alpha") + setupEndpoint(beta, "beta") + } + + private suspend fun setupEndpoint(testEndpoint: TestEndpoint, name: String) { + val jsonHandlers = JsonHandlers() + + // add handlers for incoming messages + testEndpoint.builder.incomingPipeline.add(jsonHandlers::deserializer) + testEndpoint.builder.incomingPipeline.add(testEndpoint::handler) + + // add messages + testEndpoint.builder.messages.add(AlphaCommand::class.java) + testEndpoint.builder.messages.add(AlphaEvent::class.java) + testEndpoint.builder.name = name + + // add handlers for outgoing messages + testEndpoint.builder.outgoingPipeline.add(jsonHandlers::serializer) + + // configure InMemory transport + testEndpoint.builder.transportFactory = inMemoryMessageBroker::addEndpoint + inMemoryMessageBroker.log = Logger.getLogger(InMemoryMessageBroker::class.java.name) + + // create the bus instance + testEndpoint.bus = testEndpoint.builder.build() + } + + suspend fun start() { + alpha.bus.start() + beta.bus.start() + } + + suspend fun stop() { + alpha.bus.stop() + beta.bus.stop() + } +} diff --git a/src/kotlin/src/test/kotlin/polybus/transport/transactions/messages/Command.kt b/src/kotlin/src/test/kotlin/polybus/transport/transactions/messages/Command.kt new file mode 100644 index 0000000..7298674 --- /dev/null +++ b/src/kotlin/src/test/kotlin/polybus/transport/transactions/messages/Command.kt @@ -0,0 +1,4 @@ +package polybus.transport.transactions.messages + +@MessageInfo(MessageType.COMMAND, "polybus", "polybus-command", 1, 0, 0) +class Command diff --git a/src/kotlin/src/test/kotlin/polybus/transport/transactions/messages/Event.kt b/src/kotlin/src/test/kotlin/polybus/transport/transactions/messages/Event.kt new file mode 100644 index 0000000..135e15d --- /dev/null +++ b/src/kotlin/src/test/kotlin/polybus/transport/transactions/messages/Event.kt @@ -0,0 +1,4 @@ +package polybus.transport.transactions.messages + +@MessageInfo(MessageType.EVENT, "polybus", "polybus-event", 2, 1, 3) +class Event diff --git a/src/kotlin/src/test/kotlin/polybus/transport/transactions/messages/MessageInfoTests.kt b/src/kotlin/src/test/kotlin/polybus/transport/transactions/messages/MessageInfoTests.kt new file mode 100644 index 0000000..370c677 --- /dev/null +++ b/src/kotlin/src/test/kotlin/polybus/transport/transactions/messages/MessageInfoTests.kt @@ -0,0 +1,235 @@ +package polybus.transport.transactions.messages + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource + +class MessageInfoTests { + @Test + fun getAttributeFromHeaderWithValidHeaderReturnsCorrectAttribute() { + // Arrange + val header = "endpoint=user-service, type=COMMAND, name=create-user, version=1.2.3" + + // Act + val result = MessageInfoValue.getAttributeFromHeader(header) + + // Assert + assertNotNull(result) + assertEquals("user-service", result!!.endpoint) + assertEquals(MessageType.COMMAND, result.type) + assertEquals("create-user", result.name) + assertEquals(1, result.major) + assertEquals(2, result.minor) + assertEquals(3, result.patch) + } + + @Test + fun getAttributeFromHeaderWithEventTypeReturnsCorrectAttribute() { + // Arrange + val header = "endpoint=notification-service, type=EVENT, name=user-created, version=2.0.1" + + // Act + val result = MessageInfoValue.getAttributeFromHeader(header) + + // Assert + assertNotNull(result) + assertEquals("notification-service", result!!.endpoint) + assertEquals(MessageType.EVENT, result.type) + assertEquals("user-created", result.name) + assertEquals(2, result.major) + assertEquals(0, result.minor) + assertEquals(1, result.patch) + } + + @Test + fun getAttributeFromHeaderWithExtraSpacesReturnsCorrectAttribute() { + // Arrange - the current regex doesn't handle spaces within values well, so testing valid spacing + val header = "endpoint=payment-service, type=COMMAND, name=process-payment, version=3.14.159" + + // Act + val result = MessageInfoValue.getAttributeFromHeader(header) + + // Assert + assertNotNull(result) + assertEquals("payment-service", result!!.endpoint) + assertEquals(MessageType.COMMAND, result.type) + assertEquals("process-payment", result.name) + assertEquals(3, result.major) + assertEquals(14, result.minor) + assertEquals(159, result.patch) + } + + @Test + fun getAttributeFromHeaderWithCaseInsensitiveTypeReturnsCorrectAttribute() { + // Arrange + val header = "endpoint=order-service, type=command, name=place-order, version=1.0.0" + + // Act + val result = MessageInfoValue.getAttributeFromHeader(header) + + // Assert + assertNotNull(result) + assertEquals(MessageType.COMMAND, result!!.type) + } + + @ParameterizedTest + @ValueSource( + strings = [ + "", + "invalid header", + "endpoint=test", + "endpoint=test, type=COMMAND", + "endpoint=test, type=COMMAND, name=test", + "endpoint=test, type=COMMAND, name=test, version=invalid", + "type=COMMAND, name=test, version=1.0.0" + ] + ) + fun getAttributeFromHeaderWithInvalidHeaderReturnsNull(header: String) { + // Act + val result = MessageInfoValue.getAttributeFromHeader(header) + + // Assert + assertNull(result) + } + + @Test + fun getAttributeFromHeaderWithInvalidEnumTypeThrowsIllegalArgumentException() { + // Arrange + val header = "endpoint=test, type=INVALID_TYPE, name=test, version=1.0.0" + + // Act & Assert + assertThrows(IllegalArgumentException::class.java) { + MessageInfoValue.getAttributeFromHeader(header) + } + } + + @Test + fun getAttributeFromHeaderWithMissingVersionReturnsNull() { + // Arrange + val header = "endpoint=test-service, type=COMMAND, name=test-command, version=" + + // Act + val result = MessageInfoValue.getAttributeFromHeader(header) + + // Assert + assertNull(result) + } + + @Test + fun getAttributeFromHeaderWithIncompleteVersionReturnsNull() { + // Arrange + val header = "endpoint=test-service, type=COMMAND, name=test-command, version=1.0" + + // Act + val result = MessageInfoValue.getAttributeFromHeader(header) + + // Assert + assertNull(result) + } + + @Test + fun equalsWithIdenticalAttributesReturnsTrue() { + // Arrange + val attr1 = MessageInfoValue(MessageType.COMMAND, "user-service", "create-user", 1, 2, 3) + val attr2 = MessageInfoValue(MessageType.COMMAND, "user-service", "create-user", 1, 2, 3) + + // Act & Assert + assertTrue(attr1.equals(attr2)) + assertTrue(attr2.equals(attr1)) + } + + @Test + fun equalsWithSameObjectReturnsTrue() { + // Arrange + val attr = MessageInfoValue(MessageType.COMMAND, "user-service", "create-user", 1, 2, 3) + + // Act & Assert + assertTrue(attr.equals(attr)) + } + + @Test + fun equalsWithDifferentTypeReturnsFalse() { + // Arrange + val attr1 = MessageInfoValue(MessageType.COMMAND, "user-service", "create-user", 1, 2, 3) + val attr2 = MessageInfoValue(MessageType.EVENT, "user-service", "create-user", 1, 2, 3) + + // Act & Assert + assertFalse(attr1.equals(attr2)) + } + + @Test + fun equalsWithDifferentMajorVersionReturnsFalse() { + // Arrange + val attr1 = MessageInfoValue(MessageType.COMMAND, "user-service", "create-user", 1, 2, 3) + val attr2 = MessageInfoValue(MessageType.COMMAND, "user-service", "create-user", 2, 2, 3) + + // Act & Assert + assertFalse(attr1.equals(attr2)) + } + + @Test + fun equalsWithDifferentMinorVersionReturnsTrue() { + // Arrange + val attr1 = MessageInfoValue(MessageType.COMMAND, "user-service", "create-user", 1, 2, 3) + val attr2 = MessageInfoValue(MessageType.COMMAND, "user-service", "create-user", 1, 3, 3) + + // Act & Assert + assertTrue(attr1.equals(attr2)) + } + + @Test + fun equalsWithDifferentPatchVersionReturnsTrue() { + val attr1 = MessageInfoValue(MessageType.COMMAND, "user-service", "create-user", 1, 2, 3) + val attr2 = MessageInfoValue(MessageType.COMMAND, "user-service", "create-user", 1, 2, 4) + + // Act & Assert + assertTrue(attr1.equals(attr2)) + } + + @Test + fun equalsWithNullObjectReturnsFalse() { + // Arrange + val attr = MessageInfoValue(MessageType.COMMAND, "user-service", "create-user", 1, 2, 3) + + // Act & Assert + assertFalse(attr.equals(null)) + } + + @Test + fun equalsWithDifferentObjectTypeReturnsFalse() { + // Arrange + val attr = MessageInfoValue(MessageType.COMMAND, "user-service", "create-user", 1, 2, 3) + val other: Any = "not a MessageAttribute" + + // Act & Assert + assertFalse(attr.equals(other)) + } + + @Test + fun getHashCodeWithIdenticalAttributesReturnsSameHashCode() { + // Arrange + val attr1 = MessageInfoValue(MessageType.COMMAND, "user-service", "create-user", 1, 2, 3) + val attr2 = MessageInfoValue(MessageType.COMMAND, "user-service", "create-user", 1, 2, 3) + + // Act & Assert + assertEquals(attr1.hashCode(), attr2.hashCode()) + } + + @Test + fun getHashCodeWithDifferentAttributesReturnsDifferentHashCodes() { + // Arrange + val attr1 = MessageInfoValue(MessageType.COMMAND, "user-service", "create-user", 1, 2, 3) + val attr2 = MessageInfoValue(MessageType.EVENT, "user-service", "create-user", 1, 2, 3) + + // Act & Assert + assertNotEquals(attr1.hashCode(), attr2.hashCode()) + } + +} diff --git a/src/kotlin/src/test/kotlin/polybus/transport/transactions/messages/MessageWithoutAttribute.kt b/src/kotlin/src/test/kotlin/polybus/transport/transactions/messages/MessageWithoutAttribute.kt new file mode 100644 index 0000000..0392b75 --- /dev/null +++ b/src/kotlin/src/test/kotlin/polybus/transport/transactions/messages/MessageWithoutAttribute.kt @@ -0,0 +1,3 @@ +package polybus.transport.transactions.messages + +class MessageWithoutAttribute diff --git a/src/kotlin/src/test/kotlin/polybus/transport/transactions/messages/MessagesTests.kt b/src/kotlin/src/test/kotlin/polybus/transport/transactions/messages/MessagesTests.kt new file mode 100644 index 0000000..67c88bd --- /dev/null +++ b/src/kotlin/src/test/kotlin/polybus/transport/transactions/messages/MessagesTests.kt @@ -0,0 +1,150 @@ +package polybus.transport.transactions.messages + +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class MessagesTests { + private lateinit var messages: Messages + + @BeforeEach + fun setUp() { + messages = Messages() + } + + @Test + fun addValidMessageTypeReturnsMessageInfo() { + // Act + val result = messages.add(Command::class.java) + + // Assert + assertNotNull(result) + assertEquals(MessageType.COMMAND, result.type) + assertEquals("polybus", result.endpoint) + assertEquals("polybus-command", result.name) + assertEquals(1, result.major) + assertEquals(0, result.minor) + assertEquals(0, result.patch) + } + + @Test + fun addMessageTypeWithoutAttributeThrowsError() { + // Act & Assert + assertThrows { messages.add(MessageWithoutAttribute::class.java) } + } + + @Test + fun getMessageInfoExistingTypeReturnsCorrectMessageInfo() { + // Arrange + messages.add(Command::class.java) + + // Act + val result = messages.getMessageInfo(Command::class.java) + + // Assert + assertNotNull(result) + assertEquals(MessageType.COMMAND, result.type) + assertEquals("polybus", result.endpoint) + assertEquals("polybus-command", result.name) + } + + @Test + fun getMessageInfoNonExistentTypeThrowsError() { + // Act & Assert + assertThrows { messages.getMessageInfo(Command::class.java) } + } + + @Test + fun getTypeByMessageInfoExistingMessageInfoReturnsCorrectType() { + // Arrange + messages.add(Event::class.java) + val messageInfo = MessageInfoValue(MessageType.EVENT, "polybus", "polybus-event", 2, 1, 3) + + // Act + val result = messages.getTypeByMessageInfo(messageInfo) + + // Assert + assertEquals(Event::class.java, result) + } + + @Test + fun getTypeByMessageInfoNonExistentMessageInfoThrowsError() { + // Arrange + val messageInfo = MessageInfoValue(MessageType.COMMAND, "unknown", "unknown-command", 1, 0, 0) + + // Act & Assert + assertThrows { messages.getTypeByMessageInfo(messageInfo) } + } + + @Test + fun getTypeByMessageInfoDifferentMinorPatchVersionsReturnsType() { + // Arrange + messages.add(Event::class.java) // Has version 2.1.3 + val messageInfoDifferentMinor = MessageInfoValue(MessageType.EVENT, "polybus", "polybus-event", 2, 5, 3) + val messageInfoDifferentPatch = MessageInfoValue(MessageType.EVENT, "polybus", "polybus-event", 2, 1, 9) + + // Act + val result1 = messages.getTypeByMessageInfo(messageInfoDifferentMinor) + val result2 = messages.getTypeByMessageInfo(messageInfoDifferentPatch) + + // Assert + assertEquals(Event::class.java, result1) + assertEquals(Event::class.java, result2) + } + + @Test + fun getTypeByMessageInfoDifferentMajorVersionThrowsError() { + // Arrange + messages.add(Event::class.java) // Has version 2.1.3 + val messageInfoDifferentMajor = MessageInfoValue(MessageType.EVENT, "polybus", "polybus-event", 3, 1, 3) + + // Act & Assert + assertThrows { messages.getTypeByMessageInfo(messageInfoDifferentMajor) } + } + + @Test + fun addSameTypeTwiceThrowsError() { + // Arrange + messages.add(Command::class.java) + + // Act & Assert + assertThrows { messages.add(Command::class.java) } + } + + @Test + fun getHeaderByMessageInfoExistingMessageInfoReturnsCorrectHeader() { + // Arrange + messages.add(Command::class.java) + val messageInfo = MessageInfoValue(MessageType.COMMAND, "polybus", "polybus-command", 1, 0, 0) + + // Act + val result = messages.getHeaderByMessageInfo(messageInfo) + + // Assert + assertNotNull(result) + assertFalse(result.isEmpty()) + assertEquals(messageInfo.toHeaderString(true), result) + } + + @Test + fun getHeaderByMessageInfoNonExistentMessageInfoThrowsError() { + // Arrange + val messageInfo = MessageInfoValue(MessageType.COMMAND, "unknown", "unknown-command", 1, 0, 0) + + // Act & Assert + assertThrows { messages.getHeaderByMessageInfo(messageInfo) } + } + + @Test + fun getHeaderByMessageInfoDifferentMajorVersionThrowsError() { + // Arrange + messages.add(Event::class.java) // Has version 2.1.3 + val messageInfoDifferentMajor = MessageInfoValue(MessageType.EVENT, "polybus", "polybus-event", 3, 1, 3) + + // Act & Assert + assertThrows { messages.getHeaderByMessageInfo(messageInfoDifferentMajor) } + } +} diff --git a/src/kotlin/src/test/kotlin/polybus/transport/transactions/messages/handlers/error/ErrorHandlerTestMessage.kt b/src/kotlin/src/test/kotlin/polybus/transport/transactions/messages/handlers/error/ErrorHandlerTestMessage.kt new file mode 100644 index 0000000..1314104 --- /dev/null +++ b/src/kotlin/src/test/kotlin/polybus/transport/transactions/messages/handlers/error/ErrorHandlerTestMessage.kt @@ -0,0 +1,7 @@ +package polybus.transport.transactions.messages.handlers.error + +import polybus.transport.transactions.messages.MessageInfo +import polybus.transport.transactions.messages.MessageType + +@MessageInfo(MessageType.COMMAND, "polybus", "error-handler-test-message", 1, 0, 0) +class ErrorHandlerTestMessage diff --git a/src/kotlin/src/test/kotlin/polybus/transport/transactions/messages/handlers/error/ErrorHandlerTests.kt b/src/kotlin/src/test/kotlin/polybus/transport/transactions/messages/handlers/error/ErrorHandlerTests.kt new file mode 100644 index 0000000..333c9eb --- /dev/null +++ b/src/kotlin/src/test/kotlin/polybus/transport/transactions/messages/handlers/error/ErrorHandlerTests.kt @@ -0,0 +1,303 @@ +package polybus.transport.transactions.messages.handlers.error + +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import polybus.transport.transactions.IncomingTransaction +import polybus.transport.transactions.messages.IncomingMessage +import polybus.transport.transactions.messages.handlers.error.ErrorHandler +import java.time.Instant +import java.time.temporal.ChronoUnit + +class ErrorHandlerTests { + private lateinit var testBus: TestBus + private lateinit var transaction: IncomingTransaction + private lateinit var errorHandler: TestableErrorHandler + + @BeforeEach + fun setUp() { + testBus = TestBus("TestBus") + testBus.messages.add(ErrorHandlerTestMessage::class.java) + val incomingMessage = IncomingMessage( + bus = testBus, + body = "{}", + messageInfo = testBus.messages.getMessageInfo(ErrorHandlerTestMessage::class.java) + ) + transaction = IncomingTransaction(testBus, incomingMessage) + errorHandler = TestableErrorHandler() + } + + @Test + fun retrierSucceedsOnFirstAttemptDoesNotRetry() = runBlocking { + // Arrange + var nextCalled = false + + // Act + errorHandler.retrier(transaction) { + nextCalled = true + } + + // Assert + assertTrue(nextCalled) + assertEquals(0, transaction.outgoingMessages.size) + } + + @Test + fun retrierFailsOnceRetriesImmediately() = runBlocking { + // Arrange + var callCount = 0 + + // Act + errorHandler.retrier(transaction) { + callCount++ + if (callCount == 1) { + throw RuntimeException("Test error") + } + } + + // Assert + assertEquals(2, callCount) + assertEquals(0, transaction.outgoingMessages.size) + } + + @Test + fun retrierFailsAllImmediateRetriesSchedulesDelayedRetry() = runBlocking { + // Arrange + val expectedRetryTime = Instant.now().plusSeconds(300) + errorHandler.setNextRetryTime(expectedRetryTime) + var callCount = 0 + + // Act + errorHandler.retrier(transaction) { + callCount++ + throw RuntimeException("Test error") + } + + // Assert + assertEquals(errorHandler.immediateRetryCount, callCount) + assertEquals(1, transaction.outgoingMessages.size) + + val delayedMessage = transaction.outgoingMessages.first() + assertEquals(expectedRetryTime, delayedMessage.deliverAt) + assertEquals("1", delayedMessage.headers[errorHandler.retryCountHeader]) + assertEquals("TestBus", delayedMessage.endpoint) + } + + @Test + fun retrierWithExistingRetryCountIncrementsCorrectly() = runBlocking { + // Arrange + transaction.incomingMessage.headers[errorHandler.retryCountHeader] = "2" + val expectedRetryTime = Instant.now().plusSeconds(600) + errorHandler.setNextRetryTime(expectedRetryTime) + + // Act + errorHandler.retrier(transaction) { + throw RuntimeException("Test error") + } + + // Assert + assertEquals(1, transaction.outgoingMessages.size) + + val delayedMessage = transaction.outgoingMessages.first() + assertEquals("3", delayedMessage.headers[errorHandler.retryCountHeader]) + assertEquals(expectedRetryTime, delayedMessage.deliverAt) + } + + @Test + fun retrierExceedsMaxDelayedRetriesSendsToDeadLetter() = runBlocking { + // Arrange + transaction.incomingMessage.headers[errorHandler.retryCountHeader] = + errorHandler.delayedRetryCount.toString() + val testException = RuntimeException("Final error") + + // Act + errorHandler.retrier(transaction) { + throw testException + } + + // Assert + assertEquals(1, transaction.outgoingMessages.size) + + val deadLetterMessage = transaction.outgoingMessages.first() + assertEquals(TestTransport.DEFAULT_DEAD_LETTER_ENDPOINT, deadLetterMessage.endpoint) + assertEquals("Final error", deadLetterMessage.headers[errorHandler.errorMessageHeader]) + assertNotNull(deadLetterMessage.headers[errorHandler.errorStackTraceHeader]) + } + + @Test + fun retrierClearsOutgoingMessagesOnEachRetry() = runBlocking { + // Arrange + var callCount = 0 + + // Act + errorHandler.retrier(transaction) { + callCount++ + transaction.add(ErrorHandlerTestMessage()) + throw RuntimeException("Test error") + } + + // Assert + assertEquals(errorHandler.immediateRetryCount, callCount) + // Should only have the delayed retry message, not the messages added in next() + assertEquals(1, transaction.outgoingMessages.size) + assertTrue( + transaction.outgoingMessages.first().headers.containsKey(errorHandler.retryCountHeader) + ) + } + + @Test + fun retrierWithZeroImmediateRetriesSchedulesDelayedRetryImmediately() = runBlocking { + // Arrange + errorHandler.immediateRetryCount = 0 + val expectedRetryTime = Instant.now().plusSeconds(300) + errorHandler.setNextRetryTime(expectedRetryTime) + var callCount = 0 + + // Act + errorHandler.retrier(transaction) { + callCount++ + throw RuntimeException("Test error") + } + + // Assert + assertEquals(1, callCount) // Should enforce minimum of 1 + assertEquals(1, transaction.outgoingMessages.size) + assertEquals("1", transaction.outgoingMessages.first().headers[errorHandler.retryCountHeader]) + } + + @Test + fun retrierWithZeroDelayedRetriesStillGetsMinimumOfOne() = runBlocking { + // Arrange + errorHandler.delayedRetryCount = 0 + val expectedRetryTime = Instant.now().plusSeconds(300) + errorHandler.setNextRetryTime(expectedRetryTime) + + // Act + errorHandler.retrier(transaction) { + throw RuntimeException("Test error") + } + + // Assert + assertEquals(1, transaction.outgoingMessages.size) + assertEquals("1", transaction.outgoingMessages.first().headers[errorHandler.retryCountHeader]) + assertEquals(expectedRetryTime, transaction.outgoingMessages.first().deliverAt) + } + + @Test + fun getNextRetryTimeDefaultImplementationUsesDelayCorrectly() { + // Arrange + val handler = ErrorHandler().apply { delayIncrement = 60 } + val beforeTime = Instant.now() + + // Act + val result1 = handler.getNextRetryTime(1) + val result2 = handler.getNextRetryTime(2) + val result3 = handler.getNextRetryTime(3) + + val afterTime = Instant.now() + + // Assert + assertTrue(result1 >= beforeTime.plus(60, ChronoUnit.SECONDS)) + assertTrue(result1 <= afterTime.plus(60, ChronoUnit.SECONDS)) + + assertTrue(result2 >= beforeTime.plus(120, ChronoUnit.SECONDS)) + assertTrue(result2 <= afterTime.plus(120, ChronoUnit.SECONDS)) + + assertTrue(result3 >= beforeTime.plus(180, ChronoUnit.SECONDS)) + assertTrue(result3 <= afterTime.plus(180, ChronoUnit.SECONDS)) + } + + @Test + fun retrierSucceedsAfterSomeImmediateRetriesStopsRetrying() = runBlocking { + // Arrange + var callCount = 0 + + // Act + errorHandler.retrier(transaction) { + callCount++ + if (callCount < 3) { + throw RuntimeException("Test error") + } + } + + // Assert + assertEquals(3, callCount) + assertEquals(0, transaction.outgoingMessages.size) + } + + @Test + fun retrierInvalidRetryCountHeaderTreatsAsZero() = runBlocking { + // Arrange + transaction.incomingMessage.headers[errorHandler.retryCountHeader] = "invalid" + val expectedRetryTime = Instant.now().plusSeconds(300) + errorHandler.setNextRetryTime(expectedRetryTime) + + // Act + errorHandler.retrier(transaction) { + throw RuntimeException("Test error") + } + + // Assert + assertEquals(1, transaction.outgoingMessages.size) + val delayedMessage = transaction.outgoingMessages.first() + assertEquals("1", delayedMessage.headers[errorHandler.retryCountHeader]) + } + + @Test + fun retrierExceptionStackTraceIsStoredInHeader() = runBlocking { + // Arrange + transaction.incomingMessage.headers[errorHandler.retryCountHeader] = + errorHandler.delayedRetryCount.toString() + + val exceptionWithStackTrace = RuntimeException("Error with stack trace") + + // Act + errorHandler.retrier(transaction) { + throw exceptionWithStackTrace + } + + // Assert + assertEquals(1, transaction.outgoingMessages.size) + val deadLetterMessage = transaction.outgoingMessages.first() + val storedStackTrace = deadLetterMessage.headers[errorHandler.errorStackTraceHeader] + assertNotNull(storedStackTrace) + assertFalse(storedStackTrace.isNullOrEmpty()) + } + + @Test + fun retrierExceptionWithNullStackTraceUsesEmptyString() = runBlocking { + // Arrange + transaction.incomingMessage.headers[errorHandler.retryCountHeader] = + errorHandler.delayedRetryCount.toString() + + // Create an exception with null StackTrace using custom exception + val exceptionWithoutStackTrace = RuntimeException("Error without stack trace") + exceptionWithoutStackTrace.stackTrace = emptyArray() + + // Act + errorHandler.retrier(transaction) { + throw exceptionWithoutStackTrace + } + + // Assert + assertEquals(1, transaction.outgoingMessages.size) + val deadLetterMessage = transaction.outgoingMessages.first() + assertEquals("", deadLetterMessage.headers[errorHandler.errorStackTraceHeader]) + } + + private class TestableErrorHandler : ErrorHandler() { + private var _nextRetryTime: Instant? = null + + fun setNextRetryTime(retryTime: Instant) { + _nextRetryTime = retryTime + } + + override fun getNextRetryTime(attempt: Int): Instant { + return _nextRetryTime ?: super.getNextRetryTime(attempt) + } + } +} diff --git a/src/kotlin/src/test/kotlin/polybus/transport/transactions/messages/handlers/error/TestBus.kt b/src/kotlin/src/test/kotlin/polybus/transport/transactions/messages/handlers/error/TestBus.kt new file mode 100644 index 0000000..aba41ed --- /dev/null +++ b/src/kotlin/src/test/kotlin/polybus/transport/transactions/messages/handlers/error/TestBus.kt @@ -0,0 +1,28 @@ +package polybus.transport.transactions.messages.handlers.error + +import polybus.IPolyBus +import polybus.transport.ITransport +import polybus.transport.transactions.IncomingTransaction +import polybus.transport.transactions.OutgoingTransaction +import polybus.transport.transactions.Transaction +import polybus.transport.transactions.messages.IncomingMessage +import polybus.transport.transactions.messages.Messages +import polybus.transport.transactions.messages.handlers.IncomingHandler +import polybus.transport.transactions.messages.handlers.OutgoingHandler + +class TestBus(override val name: String) : IPolyBus { + override val properties: MutableMap = mutableMapOf() + override var transport: ITransport = TestTransport() + override val incomingPipeline: MutableList = mutableListOf() + override val outgoingPipeline: MutableList = mutableListOf() + override val messages: Messages = Messages() + + override suspend fun createIncomingTransaction(message: IncomingMessage): IncomingTransaction = + IncomingTransaction(this, message) + + override suspend fun createOutgoingTransaction(): OutgoingTransaction = OutgoingTransaction(this) + + override suspend fun send(transaction: Transaction) {} + override suspend fun start() {} + override suspend fun stop() {} +} diff --git a/src/kotlin/src/test/kotlin/polybus/transport/transactions/messages/handlers/error/TestTransport.kt b/src/kotlin/src/test/kotlin/polybus/transport/transactions/messages/handlers/error/TestTransport.kt new file mode 100644 index 0000000..81f75c6 --- /dev/null +++ b/src/kotlin/src/test/kotlin/polybus/transport/transactions/messages/handlers/error/TestTransport.kt @@ -0,0 +1,24 @@ +package polybus.transport.transactions.messages.handlers.error + +import polybus.transport.ITransport +import polybus.transport.transactions.Transaction +import polybus.transport.transactions.messages.MessageInfoValue + +class TestTransport : ITransport { + companion object { + const val DEFAULT_DEAD_LETTER_ENDPOINT = "dead-letters" + } + + override val deadLetterEndpoint: String = DEFAULT_DEAD_LETTER_ENDPOINT + override suspend fun handle(transaction: Transaction) { + throw NotImplementedError() + } + + override val supportsCommandMessages: Boolean = true + override val supportsDelayedCommands: Boolean = true + override val supportsSubscriptions: Boolean = false + + override suspend fun subscribe(messageInfo: MessageInfoValue) {} + override suspend fun start() {} + override suspend fun stop() {} +} diff --git a/src/kotlin/src/test/kotlin/polybus/transport/transactions/messages/handlers/serializers/JsonHandlerTestMessage.kt b/src/kotlin/src/test/kotlin/polybus/transport/transactions/messages/handlers/serializers/JsonHandlerTestMessage.kt new file mode 100644 index 0000000..98dce50 --- /dev/null +++ b/src/kotlin/src/test/kotlin/polybus/transport/transactions/messages/handlers/serializers/JsonHandlerTestMessage.kt @@ -0,0 +1,9 @@ +package polybus.transport.transactions.messages.handlers.serializers + +import polybus.transport.transactions.messages.MessageInfo +import polybus.transport.transactions.messages.MessageType + +@MessageInfo(MessageType.COMMAND, "polybus", "json-handler-test-message", 1, 0, 0) +class JsonHandlerTestMessage { + lateinit var text: String +} diff --git a/src/kotlin/src/test/kotlin/polybus/transport/transactions/messages/handlers/serializers/JsonHandlersTests.kt b/src/kotlin/src/test/kotlin/polybus/transport/transactions/messages/handlers/serializers/JsonHandlersTests.kt new file mode 100644 index 0000000..a803bf1 --- /dev/null +++ b/src/kotlin/src/test/kotlin/polybus/transport/transactions/messages/handlers/serializers/JsonHandlersTests.kt @@ -0,0 +1,28 @@ +package polybus.transport.transactions.messages.handlers.serializers + +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import polybus.Headers +import polybus.PolyBusBuilder + +class JsonHandlersTests { + @Test + fun serializerSetsBodyAndContentType() = runBlocking { + // Arrange + val jsonHandlers = JsonHandlers() + val builder = PolyBusBuilder() + builder.messages.add(JsonHandlerTestMessage::class.java) + val bus = builder.build() + val transaction = bus.createOutgoingTransaction() + val message = JsonHandlerTestMessage().apply { text = "Hello, World!" } + val outgoing = transaction.add(message) + + // Act + jsonHandlers.serializer(transaction) {} + + // Assert + assertTrue(outgoing.body.isNotBlank()) + assertTrue(outgoing.headers[Headers.CONTENT_TYPE] == "application/json") + } +} From f430246bcbe8a16a4ef8ef6ecc69b7cf45401cbb Mon Sep 17 00:00:00 2001 From: Cy Scott <3858469+CyAScott@users.noreply.github.com> Date: Mon, 25 May 2026 13:38:17 -0400 Subject: [PATCH 02/10] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/pr-tests.yml | 4 ++-- .github/workflows/release-and-publish.yml | 11 ++++++++--- src/kotlin/settings.gradle.kts | 2 +- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml index 6438dc5..a124a99 100644 --- a/.github/workflows/pr-tests.yml +++ b/.github/workflows/pr-tests.yml @@ -63,10 +63,10 @@ jobs: uses: gradle/actions/setup-gradle@v3 - name: Build - run: gradle build + run: ./gradlew build - name: Run tests with coverage - run: gradle coverage + run: ./gradlew coverage - name: Upload Kotlin coverage uses: codecov/codecov-action@v4 diff --git a/.github/workflows/release-and-publish.yml b/.github/workflows/release-and-publish.yml index e2e4985..baf5a7a 100644 --- a/.github/workflows/release-and-publish.yml +++ b/.github/workflows/release-and-publish.yml @@ -130,8 +130,9 @@ jobs: - name: Build Kotlin package working-directory: src/kotlin run: | - gradle clean build - gradle coverage + chmod +x ./gradlew + ./gradlew clean build + ./gradlew coverage # Upload build artifacts - name: Upload TypeScript artifact @@ -297,7 +298,11 @@ jobs: MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} run: | gradle clean build - gradle publish -PmavenRepoUrl="$MAVEN_REPOSITORY_URL" -PmavenUsername="$MAVEN_USERNAME" -PmavenPassword="$MAVEN_PASSWORD" + if gradle tasks --all | grep -qE '(^|[[:space:]])publish([[:space:]]|$)'; then + gradle publish -PmavenRepoUrl="$MAVEN_REPOSITORY_URL" -PmavenUsername="$MAVEN_USERNAME" -PmavenPassword="$MAVEN_PASSWORD" + else + echo "Skipping Gradle publish: no 'publish' task is configured in src/kotlin." + fi # Summary job publish-summary: diff --git a/src/kotlin/settings.gradle.kts b/src/kotlin/settings.gradle.kts index 842a27a..7c0e8e4 100644 --- a/src/kotlin/settings.gradle.kts +++ b/src/kotlin/settings.gradle.kts @@ -1 +1 @@ -rootProject.name = "Poly.Bus" +rootProject.name = "poly-bus" From 2a9f3f247038e7960c625d9efa8569e58797bc3a Mon Sep 17 00:00:00 2001 From: Cy Scott Date: Mon, 25 May 2026 13:58:04 -0400 Subject: [PATCH 03/10] feat: adding gradle wrapper --- src/kotlin/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 48462 bytes .../gradle/wrapper/gradle-wrapper.properties | 9 + src/kotlin/gradlew | 248 ++++++++++++++++++ src/kotlin/gradlew.bat | 82 ++++++ 4 files changed, 339 insertions(+) create mode 100644 src/kotlin/gradle/wrapper/gradle-wrapper.jar create mode 100644 src/kotlin/gradle/wrapper/gradle-wrapper.properties create mode 100755 src/kotlin/gradlew create mode 100644 src/kotlin/gradlew.bat diff --git a/src/kotlin/gradle/wrapper/gradle-wrapper.jar b/src/kotlin/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..b1b8ef56b44f16b14dc800fa8103a6d89abb526f GIT binary patch literal 48462 zcma&NV{|3jwk;gnwr$(CRk3Z`Sy9Ed?Nn^ruGlsztklcC=e7I2x9>aqJFB(1eyu-q z%|3b`eLzVT6buar3JMAc2#EOW{C^)LAZQ?YaW!FjX$1*JIcZUG1yyl%HEd!6f#E+}*Jo*NafvM<-FbE0;-_L#rp}qdn%JEoAVNlEB#J^Oq`mU_#*ev4HLmc> zjXz_hFft^><#omb;Zer-%wm4hxo!wjuX3hBldg(^-RiOleKin`>KHfL3P*{k?(rji(#j2Cc0K509#>qu=-T&B!-5EBi(+ zIuTD-qfcAYgS@`Fb2^-p)4#o6A3z0&fp?~cV=CRsAeCmO4ZQ5kKgC%0el=Q&Rhd#k zaGmAbUW8uKC}-C0s~2);d{;mpsNBx9rn__66W{AhaSvJEK+c0b6ARO+l(CI7E|S5x zhaYP--@F<|99X&)9`q^2(^-Zu^Tzfm)v|gkTJHQ!G*zIg5hzoygeXZoYUEJ;iFkE# zq^r$*c|>Hmn3GapzcDYnjgSFiO^NFyTR5AH#mh%zRToMpEi(r)1$5)h455DuV}0al z!*psWuL@Ke-2gvftfMEGf9YEi^<{B@qru zINgo+YsE&LN?)1qItJoNhISp-fZ86`XR#*6xcvM~_7=JHUX;K9*=Gu5X~ zix|O2d=&C#u_w{=B$eCpJ4L*6i7={j+{Og~`Emz@&98}6s<-p^)`0fXE4cJBP{>)Ltb>JwcqI>yz z0-r-SEhC@p)XOoh|1|XgjFaREHfsu4dAGVz*k#m+V<4 zHqvlud6=;#QWHUoTR_a8Y8+heN?M%n1@0YLiaN@GuOPNd26tik7eKulTx?mM-R!1H znB6+H{^krFXg_b{y=QeCT~qR3T4}l+b!Oz9;~|3*6F<3?#|DYYW&1RtFE)ILZ!`85 zVmvrZkLTzf31unH7Cc5E0iFShqlBE9hgEnRJH1juII*vyp&xd!g`q}X_6WT6E$hhQ`Vdp9k^<)VS?lj!cTh z7FQcQAVA@jL^cXod8cnhKG2TS9+;QU6Kq>}UOY3&TL9gXbl{Fv8@WsF=z7>X0To@$ zY@Oi1uc|MdJ$>Kn{@!g_e`-I&Tpwfg9cr>(iakDX1qciCG_1y!Di#4_)lE!bWJbrp z5aUonb6m-?tiQyR_`P#~SOu+tb_ev6JO>EbEhHK@KbeT0_FDo>dl9bMg)>xmCNB*g zG5NC8ABavuTEZVGW6jP*nAqRt3W?7Iigc-EE~zpNJXRAE z>`~RO9$892j&I1kV;9U)xT8^}IeV`n{}QDtj2o-RBt`DGZUOO;O*lFCb_vpyGh*;95PfeGu!dyrmZ9VJ3Z*upg z6R-3Lr%_55$Hw1^{+KWx0#z`T7O6sXo1h;m?B_ur`X2bFz-SzDrL zpk^@B<+I6imc@7vip za%1jMB7q@1j# zz{u?YojZMW{5j$@h=v4iu2mTu7IzI|)Sxn!74=*J>1a&?Xjt z2%JhSi#4huEcD9qdR9Lj4vwmfnL{%+vQ{f-KgYeqin(OPd8+(g*Uq#TLxQjD4 zLCL%ul(V&PAPlAx8D`@K8Rc`{GPecQ<)d=KWel0ejFeeXGQ6o7601B!!I@RY&eDriADD6wP6DcFKDLZ|lO#YwnrNCZ)zRJpdxX_nPZa4j#$j6v!h|6p!dH}MY6#B`@%6=) z-HigguDACKBULnon^FKzazF|Y1{t(U5rUGnEU|}djVsWT-F>@@mNx?_$kF51QF4C5 zStKR$^3(fw85(4HGs9{mUTtn1)3PwxTN?6}j;32&vJ^BiPHfndLkdU5sOemXKGyCZ z@<7j(k>DNeo~QXyJkFWk!7(y1SB%nA3{v~P2c8ooKa4auM!el!Q_=;lJ$c5ADqE+^ zX8*|A99v;jWPrm(8=h;2ZAj|(vVbx~wQ{N%v;eYLD_BB2LAEWCs@xauyBDl(_HIBvA(XJ7B1E;O zJYCJ8xFJh7f5sr;Y#Wp_`$4Z_H4e9bGiBp?Qu&2!@%Bl2dT5evfFO*^hLDiBu2%Jl z*WAlL5PaQ7skJa(qVysky}DQquZ8U?2@UyJ8zB#=U_E>MgE%XA$CtfL31m$rATJvC zs@!crc0=128PM=Zp zW_5Czv9))n_8Ru?{pxM2F8^r%*O41}RnONbSj*piG%`nyF>6ky=|;B&k8iot(J=kyoU3p<_zaAX(1ijzf*uXA zZ_5jeC{Lks+&QeFIlmzZi3+fsF4fNW^~kvC4Q*T-vrNP!x9xnen12lZQM=1_MdW76LKX(GuW`%T~dM^YX6+ras|Xy4Qhfcq=D+z-P-ea z`T;^gj3+grr3^hwqcNTJErl$z+k>{bYFm6QV%7Opth?9+>|Dn)O@`7F@=j-XSqGPW zjUAu%b3Er@;j1%RZxVDhI3sakg-gvTLOSV7;FV6ED=(5;UG??=WADZw^=$4AyFh#}VMe3afM^pF zFa}-nM8X=K?Jy02*o02@6k{ z%O!hBhjXlXKdhy3A{xGB<##e|j3^dFv~~%v2_H{t(mN7NVeS~51?D&Ozbxa`qwZ_4 z;C#Q#fL1sua%ggucgIEHZtcY=Ag&GgE|h7Q{77D!WUq`;SSGEE0pU;aoj<7-JCAvf zduN=(tx3Mb+EUXKoax|v;8b@#HJ&Q|!g4ryrl|R>WlAv?IH`bk)I24;eE4NIq@SLK31LD4+w~#3iN{=<`<1R!t^$@K5>U6%W=%8_ANuR5 zs(IDuI18ftirTDARnGmF%;iz+4{MlMihJw_l!0Y)NttXC_t+s)V<EY>=Xin*nGX79k6vQ?beRk zy_J>@YSC_gMIG$yjO-y&o>S6xtfT27aSs>e|`x(f2R1bM}*518~%x>1Yct=18b&Z>GiS*>VB$+i2876zL)1cT zN33g=g|>xWE2)dds5m2+8Vy)m-u@NHOlGYxxjam21r1;xWtT0TgqKZrl}*LSkqFt4 zNTI1=3o%C*!-i;iWnlca$stRdwITA1?#fD~5OIqIQAM18BwO_u>hqL&OAANiF|8rG z_IZ9mp?FA-{Gq9+Ky<#NgL1gWJixfO0ziP$4T4G>vsvqC-NQh+A64F4! z-(t<=AbPSG%`mTl6BJtH~3RmvPhQlE-EUkEoBIP(_WMN zK~Fe!siee{M*ns1hkp5(2}vX#%u+T!Abh=<_gEx_QW?h4V@B>uOCEetEe01tl)^`V z(=cOLmuOB;8&&m%_6pcyrt83UXkJ`f9I&0KxY09}RTTs!l^_7~8$tPA%Hm#&$k0;# zF;O0zCGo0IN)X~SyKDoY1DW{Ulce|V9w=ld;U`z$t$>8U!Gu8V?_LAJAudt3eI#*! z2i9~F=kP5m>!bmb%1e~b1!1gz01Py(Yw5gOsFN#o1a&d|=PpgN(#UVreY9^99I0iG zaYE@>(C^V7pnoB~#w$2C1_TIb1N5Je&iao?S2A*TF>@vpHg`31{uk<9{zf_}s&z%dL-Fo)C$yl$%pAdqU!HJgp zh_{m1imk{&{ScyeuziqZHu5cto0{S}^BlXu% z0~;>_yHGd#?Kt8ErxK)z6ojj5SacQobw)-8`c!$HOI*V6eyqou{1Upm%_p!BY^t(D zDtn(oQ!jff`ddGSD;P8Hes!v)OKW-*>mS&#i0ow87;h>(=Cu0>b4)|=EegbN5=Xkh z9Ge13=3z#sk+fT<)PuUUf_%Nx@l!P?t*mni^94p^Ax6b2SVL5U>9dHH!H4DL4}@?@ z?Gpq$C**OmWliYA{5s<|EZ@QI2{-K#brFxfA~AIqq&-WSALHWQ8}%mvaNFasrtnE{ zg=sB4-RF!?)nf{>Wo~kNFgYefoFHBcSr*;iF9B!R=5Np|jv>Uf+mcarG-XGy*kP{z zISVyoPcl_9cOg-@613Qx16OGF#sH&2NTHDa_}vyidmxS~pMfY#AeQvu?AXpWNzi7A z*6&7a7!C9HRU+N{>WYTh0GXoBnXw{lQby^XShgDOw@e8TP}9Y*oFV4MVF#@Ds2A+A zXBEt3a@-IIl)TOcXx;0P;|ihR%Tq@DXeG5p-O{!T7Sg$s1 z8OA4iOx-!>6eK^x{jU-0SvByimK|nZik5zKIvvWVGE)4=x^&5Nx%Qgje!k3VoizaB zip#?$u(R8u{wUFC>tVR8oA%7fs?xEu(gYn>y6BB%vwPR9&RoZE%%RK! zl#Qnkl^+Y*Y4L{Xk(YX&aGj|zSpqO_;C3CTepA!L#4EXO|(eA`Fi+2EQ3!C zo^SpVP?{chQ3uaxu7y>w213e22cdA#l-M2kStPE%sq6vE4M*?3At!S7tIp(tQg(Ml zECjeJw8)*#LYYk_+Txv3rxsH9jJZBRrHp29yJ(^;_PEdn%#U1q`r89}38;XeF{ee& zsZEsUbJ{LtwOjU{vjL(Wvs2!Bx;#^Mzld&TjS@oo3kk=0P36MC-Ie6eHNN&{8b^s z0@jcbdejrrj!>r#Wu=3H1dgjeOI}NkhmE}K+UK&M>%7b!n&{0Zixk%^)6#@=V~IZN zxG>9kl&STQth}qScidfg58d2dF|v_U<@+V^eE@$4x;7oS3)MvWusA?9+%rN>aY#eA_6 zic@S(@e9$9tQM-&-7>X8~#n{5G}nuOu=dSyN+b~jA;_SExZ1H9Q1A}}Rz;XtXUIOP0~ zZzS|~T+%de-nGI$s?wxaJoe+99vmo%xm8o8SNEsAqAE)4LNvHc-1AX24C4k4u3vZmov^_VcxgGxapV(8)_K(^8= z2d{xCrmk(x&514Ly?e{Mf6}h3=oeP7+ZE{%B^c-kK8g0W{tYw3q%zty_Rd@1nbnyHMwabNp-sSyzpV4v>QsnKcQjF67%g~n&3t^1MesVxCzfJ5b=SOI#YfPP^^JGQw=9L1RCMFbrU{8O0LWOUdBK#j&{`tzXX zpe2_{+-8$a+o#%8MUlL4$yK`*--z&3{@Y?jP!m{g5nM+Ht=bD3o}Ok~sBQ_!^!->! z?NDVtyLXzmGYCEmjSCDK*q?Aq1;8fz9l9|z@~l{)R6GfKELc^(nV+TjjI^n0M+S0i z@YOu*Tk>|M6a0_n$(E;#^1Zgif<-CpYiMvyT+Y*9Z?&~IKSwsLa5Q#p_?FqK3lKIw zlp6Hk%lio6)yq>m-`QT2Nj-q!aX7~Hlm^Xh6FNbw z$#ri(Kk*GUHXORu@`aYQU@ zB~S-oIO^~abRPocemkm!W73dbb!j^_xgo_@#W#6p12>w^{){VfeX?U71Xyn9&E zHa1#*!4c;?r}jv7dMN`g#&R_S215)dccDOJr=uz%LIz@zia+LIFjRakROr?P zQ|Xw0Pa8o7&W=fw17`+SqepsQ-Os5v3ncD5|N?N(AHH&`>hLY+CLOluJ z_ErpaT49zK(UcdNmQ%iA-`jS`A_1c|$W86{d_T_T2V-HH3xUqpX0QJSH%i>1i>#vK z&y{;5)^pMB=u;&_DEWakQU>j&+opIrBf~2GUh{`kG{|Z&2Z}5dwG}>Y{W_uQHaR$_ zYH%}$c`CGC-FGCetRdQ@RZ2-%ucC_|R?mHzYEnqC%u9zRBH8wx7po`=EVPMpq+hL2 zTdjVhQn$)++17^cn;<3=bxJy0Z$U;i3AqJMPJO&SuieU&0eVX?eLEEI7Av@#PV_ZQ zsa>I>B5HE996O$z6HyJfhEt^aC><@AnzeN`xs@lv>^pPFtcodrcGyqPSB?#C`Piu0 zh5=hAW|OtT9hs*G?7}@*mG_f7ae@-Nz4{qvne66kco^uD$(JbCo2ttqUm-SMy@kx% z!eDt?5>w5)M!E#C!b#Iu9GqyhUs|QoYWHtR{4espRS-LUt=viY2iygF=-j3kcU#uF z{ka2=zsOuLR}s;&PbbrB`zty&NfZpV*Y;~i*W$EH0JOGS&FMS%VK@)f*%OOrcU3P9 zq4zjhMpx}oc`PWtP!o5Bdlp=(A***TZwVwuZbuB1Pibv5uiHvW{PsE-k5IfCgUz~l z0nMeZU0R>(ajoQ0G%Il)z0BgRR*bsdz5NcqJ<)niF6|PUO0i}<4)q>6wx4K(5>Y_I z4$WMkbCOQFs(krBnl zx85i0*7%Zm(&nKNP?AQ}d~6@?D9dO%@}ouN2paSR;zyUqJuw)1SRy=g%o;g(BD|Bh ztnKV(4fcBgDJ~M@%}n-6ow3xOhnC>C^d?PbS(9=TnO)k5p+W;pu2F4eiG7ts zJVL4M(NiZPQDy*9`H>-P0GWY#=UTnh8feiNF}hCs`8^ZDKy;XIL^9K4Ps&y^#DQSE z-?J z@YOQ9NQi>ZP>^ix5K`R07kWj?`R(B?E*OyR1$Vd;8p%2Y2zEYt4CJM~gVX%MO(E1B zzXhsHn~R1ifq9~dtzuH!*3&W;r`D(Sjrc)m#EI%`Car;CMWcU0c+0r?O!)HpjEvyP zb^;pO-Bn6e-+>dS^o{q&8yEH9v}vuXX`W;NPRlwJdX|59`z?~z{pFE!^u{3k{KkJ55^ zD;F0ldy9W*`d5YP|0(E6|K%}9|D^SIq>wO)4^cJ+yCa&xl*3}hpvcQ1eP_k;@>tz= zOZnw)#fxHc81jPcTM#)jgy|0?n0(jd3IPu-lJ&Tm`#F1)o$GTwYp@dlqy-qiHFCHS zKgikMUx|%x=_%B)>n_y^+HvD2=nP`}-G_0A7)I$yc4`tXS-On8qOkNp>Q^$|Ew%Jm zYx34*(*Z3SF}xw$CA?nG9O3ZH7l)@Dp4EyH>8eXDb}AFz)k*T53iA~gRu&e15u@|% z9Rw?69nQOeJhv^^unjd-VGFwbDzf9K{i(U{xxHyM@-aI+0qP{TU0G~w+Fs>taL#Ik z4+92(Z7n%+okd478;__0GkE`&(C`k8h@?UNnM=F%A~2|TKo)q9F<5`s)KwxJRw~k; z4giS~|8AIVG;rde6I^W6m9fliR^7YT*>&x7wv^?xu(5p45n{|2F>x%?9Jq+~Tqo9# zChbeGm@9!(s;uIKae_4h@`~yIj`Tqct+-M>d>~2PCiQ?UmFUioyy&~h_DTBQ--W|q zqA^UaJMTz4tEggQ*_cQ_LA7j7bLyz8#cpGggy;YBVk!%oSdufoh5-FYAQ)v=d$Bi`G$^~ zm!O;En#M9uCykPzLZ5SHa%?hDHP5P;T4HN0L6J*r9DAvC1WWPOrd{*obfr3yJ?Kl3 z^_6dnXRoi4<$Tr!=4mhHg6ig~BatHR zv%ZMJr-`8w_JyFEzUSQdp0HT>|9QQG?IXj$7Rbx4E)%HauDyY!tedHP ztIbq;D)ckd-eirAHOG7icBH23*ApHA@nG*Jdh}~G?L5C^Xw^+nLWG+>hRi&(fnpY5 z?^hj4si6I{m1u^%i_yk$tco}28X8|}g5*tAEZYF37$f(+xT%XvO^`i^Ig}%cydrwF zlpL!xdO->&@q|8MiJrAxt;z2CP*a+EvV`_2& z<1=p{zjhmmYVkpx#RV=#zuy&7^2Trn=H$nT{OBVF*0z|QH!NxBF%gbqT!BEx zKB!SsSUwSo1Zr?kMM%N)@hG=&m`vRQ6QK6=oIvnUI+|C)dGKM@jNwqG2Xi8;YCUHYRh? zbl@DN-za)+0F9kw>Yv=ioL)01uFp7@AVEB0AH-nmB%j$RC_totFy4BKd;OPCMUMBb zu3oUUK`|{AvkM+@KPZD4Tn$(VlQi&aWV*Uf@DO|FQjLOoVw&C@z~Um*h%Ka-C=n4H z@(Lf&MDJXNS{3Hs@J)11(zo9tGp>wS^b9{Q1WN=Ktn>ZieRZS?k`gb7P4n?cl^7^* zG5-oARAG#i<*z`J0ski%;QCLD-T$AbOHq<{KxIb4=QJRn@MGj=ns0WhZX+uX z=oTjz`o-VviMt1mB0W1vA*7oq1ENz{<*-EU)U;r*ODfV!G-?hdnzhM@rRZ=|qaFTN zX*t~$gc-)M7GS{#34R-n`B)eAPfebN46~61R?j^(Pg3TXR1PyQrO7Mf@xf<3VL0`4 zh(i?-SktJu8Oj?KIy4p@%5ZH;P&p5LB8 z^}7P)9h}vUP+1Hd3nNzNcbR`%1>dSZbWhiXe-CcB+s9e)_w<{bypZ(@cQT`P@ch=d zSOPhExgI31MVFPsClEXe>$~qYQ+d}7(!BE*9y%AjQ47BMDt=#>`1ie)|ES{pFFdHa zI)CK`f3x>)DtZnm!f5=e@g;3iK^jf!RU6hpjYu^V#q0uWLuJ-6={Ua3gDi9#*P7;- z`rm*5)n{2QE{UZ01PVy@_9(amogzzOwYcVgp2>LsJ(}hKbX_!ayZ7=U{!p{BHussVj(W z2z3$zu7h$KK<%}P0YBJ+)0unV*xD&6GusXqs=M=Cl&fP@Ttzfq?>H9TW#qDId+C7? zhD;;HOxDJR4dc_xI7-b6N6nZ@bUWueDk<_9Rju2I*o(i)M0&~%C^ zc)a<25M<^NrsjAccydV2HJu_-1W>b;xrB~Mi@c7FrW-94$-GnKXvF7( zA68!d!gkIo8(URS{(u{zRtrF}B$9@*)KH9POqOW-B$za4Sg-A&PM*on$>$o#L7pH~ z&YW8oJX3T!!@2r4Rr6ac0ZDbtB1b5yc$5}7oZSDvGF0FWTpZ#r7@GfM^MmC-p{9Qj z_JmmlTxO(^(NHqBc$ECU$jQp^;)%xnyr$qvNTd`R@j$8JppDCGQAHQ7?fja9McCUZ^;``VW$1+G#=<;K{_OfH- z_$fp~S3K`;jPNNZnkB@=DFQy3{6+Bq9nOf3~dr4q8zD_t{P4-^%<4kj!U z0aj`=#@G*w?!4fpM? z8Pwb15(Ka*TtDN-2aWK>*hh{R_C}*e*vSTkHdM(ETM!JrJ=1h?(_WL}2p#QXjrKZ_ z0k_yu^;~)#*r>sQP7d_4VBRvWJCzw#TxA{*hktwQI3ST{8{>3$KHJIgMGK6I!d}Q zinmfq&RLRxX8P)_@@vVr0gPu7*)uU<%xS{|Eg;*w1}2=C&?7B zSX?OLt-gZO+<4@tLeF+K0~*|xwMD__KxWgGfsUpj)KyeCM3J-f*uxe|xk;Dlqq%1< zL(PaY@U(>Z#k!C!B45JlmE^~wHSH;r1c^kWTG9_VT~1LN6$a6Yg@kNF?&b0hs+5Dw=0j zR(wcEYmdfgojx+Hzu89*C}4$I7^?^vYKhF(`>=MC)VeeFR}}?j#XeLnp8OhW9%9ND zt6utD8DHnQj5@YJv+$USdN{8apQir2)Z{8_s!BABmG2O#pz5lSh|gf#CI8X4I|U4g zhQwk=VEV+j+-KNxuIk96Bi%^(Sf9}A7o$zHJ5mV~)qP))QQY&^>9}z9z9)PWpw>8T z7#NWNEtnUoUl{DP5(lmy<3;tpLJ3hG|;CGB`3**uH0tf9>;7w;Aq9SRVg1FDpI5y~rY#B|eCNpAXD z9692@_%$t2^nu&4lU~(~_iVf|Cs|mXs-xKlY$-~FZB$!oDK#)JgHZCG)ySDURM=@(i zCpd{Er89|l&)(&5>L6LuWY3yC6)`jPz(Po8pY=AYIBnx3y2Qx6*sT42mpR$zwx!!< zHHCc~tbF^-bje?bo#~Q59Dmw_-VcliCn^FfI*EV)U1NkNA`6Cm=^%j`%M?1Zxa=1U zn#DPNc32&XHHfUfmPx*J+3_GA&g-_pd#wO=Q^5bdhzmm)>s@yO0q|>ROV(hkhJWf@ zqWjI#+9Wx%C+!kp&kxX|XPS5m9CBC&3r>}SwdFd#YF_W78A*CN6mFC)qzOjM);Z&v z#MjdXXMw63v*tbvY+$tDmuHNFunOlRM#qe|eV&|$98!xy{n)-=N?lrkr0_}U^sz|x zs0y);(2Dooa;(9zHzRi=I{GSVcv!6jl%ck@)>JODfR? z%aI)0HvbhzY9K7eYsntq#JvWzj$WCuoyGoPY7;LSPfZlFiWU)X?(-p}s4FXQcpIp00;%Jv;k0t@2vBu4i;rh-?{z}cHTLL9Rz zT8r(1Ws*H~EyH+adP$cGv|7HkeS9p6eOEI*`idH3twkEJ*72|ey4JgISglGV0Vo@qe#)f-=|g%l$S&Onwl@mmdn|sjXXYaQ4MlfzjiK1* zY&hWQyc9?G2}2s1fYnQ}LXpq{!&Kr97d?=a?_xXAU0SXrZE?T+=9os2*v9%Csph*M zW{}m4+PIRmHEI;<=c5$PMrfg#MTs);4Tb_0**o}*cimSWRcxo(;G&&NV+-?W7v*%4ACG#t5J zQP=$g-(mN*;B6s)d9JNkF0#Zz_WA>J;{=2a!IJsiqCV!YLjJ(wUJ`3b$>qcZ!HjDT z2xm;fMSbtJ|3o~tc!jJ+U8a)vX@NcxU8y#u!Puq%R~{sps0msRFO2!GM4}786S7* zxgNmf{q@|Sdnf6_he>gEGX7Hn)uih5nL&&t4`O{?V;;bdl1U~9RAnjNmt~1UPC3mh zrR8ZtHzz1(yOYSK$OjKf;InJ+7mH$WfqI^OG3dhA+S!YmIgRv>2H78?<6A=~%E{ug^P+^b*+f=j32&Nv&Ypq?DcH&Busg^AUDE|p; z8(tQxZs1+0gUX<5~Ah zT0cGckI5%nM~d`uaMJ$o%2bt^##I0UdaQ2>-bpsP4P1Vk8r7EOSr+a!D*Z4shiKFL z35Lvs^i;#;G{%ksUUo8(Nj2DY?u5->J8kqS_#{B`HqS(UkzR|K5&6XI_#FH4?$ znMXeTb$nmr1`|{n*#5H1T%vtU4-H)vrtAchme!ZG#@c+Hrf4uxx$;VU(Dr~N-ich4 zMKpdwot^bPY#kBILFgi?i3W_kV%vn2J+%R5x}TL8I?B~o#VXlmr?i=y`yJi-><;X* zPCDrsU51x;mkr+t18lPs=6)r^gEh2$saaA!qv_< zKQP13J}ptHaUjT_(*x+P}wfV-}57aU3rp#3AB&~e3%y}0ju#22u5@mUIT!GA{* zd%-e2DTmr#$(P6^$&N0oCgR)F9IPR~!Q!x6YI*7dx6LR6n8tj(#1~!0rofeMtT#g* zW%-p@V09>&o>iz0j66K^soJWg(o9#T(8Xx-P3?;J|t~nIDSGPq(?-B zOoNnc5HZhsW(m6!J+yj~kjmjV6GKvhO>%^v5`O2I@4B$Z!~DgelYWdC4P>YfmI$TR zq`atDEhIt5ua)PS;Yz1`FX@3Na6j^uBx_rNKTmgboWGwE6O5;iQiN6Q8>ZX%ApVJS zTEf6oj=@?7klS(JaijG|(gO@dTgxB3#H)4&?+@VWkTc)dl;qK|uv;WRI*cG2`6PiF z4+svy+Bfn&Fs57Jz6i!C(w$w@VWPAbRGak~oN>3vUg|Mmk0NpfURt0*DSJ_e*Gi8I zqshW4F}L&aS8x~4*#{4vOc`gKW99cx*L^69fgPj#?++q9LidItd}<@&#E{ZGz7g|c zFX$uKJ;Qv^NpN*e&EL;l@1br8j8oxO3e`g<911L_jr~Xb0)t$x$A~dFay9(}gt4&L zyb=1<`|)_7(!^xJ14xLBGKXO3`R^_;F01 zG70TiF<5(=pRsJYj!^XjLl_vFJOQPhN#Pkr#G0-m#xG>q)GAHjE4WFhe7Zi83;gte zdDv6+)qrgh3F0}$gPmtb9-Ff1m|xDD$6jX)Dcd5Ms-(@nKM_3)2+hfh6@Cs@-=%Z_ zIinf|ck6rN{EOadGmJ-rzvxZnAL)(mf108HL2v&m)%=a*?3CnX2ZfOQY?ha_11m@UzRqlkhrVbQ@0M(tSSTerx}IH@Dn2={w$iGqU#`v}PuV7I&A9JYNP%sqMn z1bTq*Ok{V>SlVH8H*4X-lO?VzaDQzAaLvc1tTL+To)YOuj^V8mQ?)K-FT(s_!ds-O zeb$rKRR-~g^+_aiGtH6kbJ)!K^ie;ipJ8e;>iy2}73i(1RY-~!(tk2zPj;pwB4k1a zVa~7lF^EE`UH=#eb**88zBH%!WkO0S?_Zu0KpRtXN+XMsAwfT56IZI}&cs+R5N~p3 zlQH7o$(zsQQBPIRmD)i>TfdcgCSKbVVD;VCmO3l1VNbV&rWc9o>Pk>ex!)Nap%NtP z&kKIFMm@k9-HeXj2$((SmG+a-dXvl7q(7n=8)cELHf!@Le+X)=++(}pKC*dcns?>G zVa*fV{2FDIJNaK_jq)WE9MvxiTm6sI%YUn|S=oP0Z`vE#GMZa`4V5byxmv0@8@Zb~ zyBOJuTAG>Im^uIL@!ZrWJy6xL{%n;pEwY87Y^xYSfmmgRcgcEDfz4TJ#{;n|g>8(> zv$(RLnp4oD1Mj>H@ar|0RCy}E{GwvuKOf1FS}O&z-Q)MmCVEK{p~b2xFj@lTn}#s4xg7h+r;n$TZDlT2AXAv z7R^$J?R|*xL^>7HI}e>7{HszA#Y_e8=~8*3zy_J$ejuhByeI0I!w-&%MW7Q-FGMKU z8qPm&IdU3w#^#`d%Vcn&q^w;EEr|w2F@ax^`R;a@p>l`U-T%~f&^`#zG}qdSV)A<0 z^*U=#=#o&gd{o+*s#j$xf+2y^t1Wj9_h}(DNi^aK#jI}z)v1rk-H)gocbgc`wB*?$ zfg~22r!^VEN+n>U8|3{Ebe#!9k|dF8lV*9c&9H~&g|$Ymc-2O^j9w$Q^I)ldd}5zv zQkBFDS2TxDn`p}-{-`br?tUCgyfr0Wbf3QeATbp=9sN|e90U^eVOu0~VT$1A5))@C zPcwzUn7bP^Gd~hLA@8EwiklMmlc^(;uPE%tLecC-iZ$_~jNJnZYn1A%r}=VE(-LG; znh6Q+b;zKz_N7)0SH7t~u#)e>Pr194w7xp;V&CpmJw5j6zBO%yB zjVf*iveYaWlrE~+p8YYym=-QmTd_F!`)ATishn6(oD}hTE2AqnVPF_os`ca^ET@@Z zoo~4YJASOBn<;8#(#3G>n1E)&@JA^3LV7mK^kaJ$((~ASWup3G(%#8O%xFX8XSiN~ zUF0&gDyT`FzIjtA`<-+9RXEKbwu%RtcrG!#-aoN0aj)i z(G|=#b_!z{o1}cIyw#n=j~Ac|NnR@<-CW$c%JFBFTi5JW0BX#4k2o2w{L0EglSN7E zFUcmFVF&U6NBA7!t`Lut>faDk>pW>Lz9BSzsqWvnI<+L#wg=zw+aeL6=70S773#Rq zG@fVM9=1ZibB`>L>hKz>rHG}`pX;dZD>I!_x~u>jsx3;0d$`Q%t7d<8^lkl8w0WZ3 z(HGiok6h^#G2EzIH}G*;!U8FW>@|C+wE+z{@e{wwWEkzUEiT0aDJo2JwZR{zcX$Bz ze2pzE&vKCc6@vE*GIv1LZ=qSg~HR)Jf|ljt#^m2hZF4z|32*7{hd|u`C7{C zjG>}`{SC3Dnc~5%D4yBa!V@}xSBtQ$ZWY^qs3)9jTuIXYMgPF5E0*&A0B(=JEntcVgC%ZO4UKHyuzuSblKNHWJ}OzVpeS z?8|{P8FtkJ=~%YMf1h*@o-YsZkLVQU!43cY~nWEmBt#&Ar%7WClZK8 zSe-!M)B8((tj^wSIm3?e5oe&mQs6BAE#Y7K*^boU^Z#aITL%-H zul5Gx*FKM}n~RnE*Ko3}nXrk8nTw0Ok-d?{|KMda<$n9cFHzkfb4wa&Dp0x>XjayP zg-KZ^Ayey*gb`NecHls@$a-2|Z!Xe^@P`uYYo`Q*jKzDQGPFf^GDQ5rd(-X3n)&f|bD>?`-DktKL<0hWK!cPS>L^@|VH6## zG*0#NtGfzpZpt+e{yL@K$|Lg*JfO%I+hp&kR;NxOJ+y2H49xZA7=^RKObPZi6 zL&R70!l_{PTFcxI#h+WsO^Y<`hE*z1vg9n7nG-6n0xBU8F8yDd}=?${Kl$qim3(S98@^W*vvSs{l zU}!oUIXap-i#nT`er(?avm4Q4-snuM&-cwu#-M{K8n;l1gP$ z3sw?`ls1z%eb%&mNBvLuEci8}-Q`|kUw6;F0-pHb?+A)+BLSn7_@my}6u%J=Ub~(* zU1n~wcfO|73IBZF;|Bhy$0FeO^>lmmZz?ZuZC8$p6<>B{Lsp-*mS05IVU00ergKWv z(LIsLS=?(>QLLQQ?bdTpyO?iiEL`;>(XJw^lA*7FCd|$g@c3VRy#tUf-Lfs*_HNs@ zZQC|>+qT`k+qP}nwz1o`ZNC1_y*J{2=fCentcZ%LwW?M`<;L&dcdwa@4GT@LCkltq=Xfy+OasOLT!lXrqy` zEW9YuDcfQtJ$oJ|Ln|b|q*_a|YPgCbBBfQ|5;-1(P3R`sK~3T`TtVV6yrtDbioJKI zPDV1BAaj#O~V^ll>$# zNC?nv_r5RiH^A2t<)qzcvns9Qd$_UU$`jN;KUSNqMCQiCFCi3A$*D#(v=FXCqz$SB zyC8vjHyJhMy$5kCi}FBy0NdSCJa6{q(|*9I^zwX1NHX*dHOIDB8bsI3_{(*-kkQV@ng|lWd*nWx!(xQ1stGMcRDjH=YUQvY2^uCZuO%-0Jw5az*F1nW_|h zR~z5DT4j&Z7527|#z9b}pmRW}p^|OrU(TWox^&Kn>YUn%%JlZJ^16vzy|O|GnZsf3 zSXEMjOhuYZlh*ikE0&zHt5va@6&GI{1&D+NPop@Tss&f!V4;}nqX@iOvdonoDa}J_ zE-u%qrrUpYVYSGU5NeXJr?#B#3dkObD8uk*U|u*zS;T2YgAk;_kdF0s4A6A*YGO4)#dKwYLQi+*i=C3N85d93 zAe#Lng7EX?@}-FPvIdp0y!`J@^1tg|IHwZ=C-i6LW7u!d>#==7<(?=6?caFCo;)AM zwwV6XHIU7}%D3 z75#&7SiVq=f6k4N*gy{?o~K9`+fsId8Co*62ksPHLm=SB>G)@44I(Fbs1stfE==|e z5WM)k7Hs~OwT#*$%<~0|BEb_6HV0F0=kYy;P zdAZbN(@{*9FL}4bSi-&#J^2;N`G{J?KFD@i^8BEXQq3$Q#~shvw_cx5r%ZlgHz2&Y z*cU<9UD1(G6qg=Yx{LRix``xh^Yi7@j|r7hm00t{(0ei78ZQbt`JV={$XlXvX91YH zxbI<;-YQG@9xrY>Ar~yWklR>hQ-X6TUxD-S!;~b9lu;Tu@f59S=euifnkTO2C*G;S z@TJZ5{$VG<^ThBbq_74=9q9r7DxC6VBngr@olJ}~W87-NEagn(;M*)7Oj2!(TG+}U zsLu!TV4B7DH{}gtanAHawLkpH5_$jk$0~;0`rM1Hjkl;4D-KsjXTl<*z|E`_8Nlb6 zroi&vNu(socja8wZ}9J>;D}esqgs4BR?_u7ZyELz2k%GQjtG%Vx+yeS&QI*AK1Q~e z;1-8)WjT?WqB>et(n%42u5UPI+!F^B7Hx#oW{i;??}{9#vpvk}lwvHPB$=-+pnIAL zGBd3sTO%TRGFw?`Nh>DzU#VeO7C?`w!-QT4ZgBE!WsS1clJ&i=m$ zHn^;?BNx^_wESMCsSKfxi542WFvUJUh%GpT-JP-b+D|wh`H$h4?*AT6uKyK)=>%&^oOXr5Al10+ld z9x<66pEk?hlV|$s!otJ~_Kz3DcB~XFzWq<@HMwvNFc2}VQuS$6g{U$+nN4G0`E zua0)-H1D8k;mm6E{(!pNomCz*qxv$pI3NvG>(+Q4AcJvK#K8 zb9SOKS@GC!pN|JW#<}*37GFj>D1wi~_)k#-N5izNy0%(q7hMm?oL_Ju8jMFGA9bKb zv$!gbC9lC0>Unx?+*3GF(6ZZH<(4j|5-Om02Y2z2IG_&xn+2Z`6;N1An(~^lQwwUQ zOiKj)?fuj7EGlb8nv@wDs4us&o=Bt%l*TAhB{h=R+Pddpm83-ms{V0T&ofYt=D7dS=Kr=V{~wzR|1=j_+3Fh+3mcp0J6k#Z&$+yVt*OJ$s$BYK zRx!5u|IH#%N;9@dV#r@$o(;Dy3GBon{2-)SK+R!>`0yL(nq~lFeelQy_)_BZt2i}m z8rSXb0|MpaMQpG<_IaUCD@=+=`KtLmC}H1)-vV;8Y!fw&`K2B6oou$QOj%XL`Ye$dX*5~GV? zjoCc8{4m*B_lFn=K@#mp@(*Vga>;sjA3Ds|(a_aGGbuFi)9-z>)&hY^h=PM>jvvAt z$Q7Zfbr%lPeu2OFHW3uNyavs`ezAXnB`OuCGx+U1e%!gwF?S3T3XLaG+BzOfiLB-f zLsTI!R2nT{#3)Z+EHpqiKXE$CK-~2S!*Tvgi)l{*o7SZiuHQf&N=jK$gt6|+nF)`Gm z!Txq?dNfctW^}=z-436nDud8w974=Iuf~cqED93ykXqf1w8FZK9fiO>iyHhGH6`Xa zy99CYP)x3@)FSqPdVt-Br1$H%x6;EwpuBzZ?#_D^RUI0KPMzf^_Q2rPhK)0jFB8Xm zlV*;2seylEHqM|s4!E5>k-zx$17R0R2*LcwM(ea^%K>Rf92id$mc6SChy+Lhh?+zh zvO6({dx7GOFjsuW1#TIks9C3Y1NS^K;IL#Bmt5WRAnNcc>QhlO{Vj2vmon)s*asQd z33&IEDekAAXHibwHHW4Kjin6FB;UgbL))#+*%fRgjq!Uy)J$xt^A4P* z=wpGU$DPMXW)DL%DW!nu39E+G5tKB@YM$r#?rOf~PwEaIWOZ?-rZteokPGZsqWYS4;B z|0LjjIbp)2Q9#;HApIi0rAAv&MKYgXU3KhsoOYe|YT)zr{({<}EXL67@nFgE$g8n) zlwsHK7H3m?1l)9j7MVEeKIFU&$Urel=||l_I+%2%vpEWGJ4%Ae=4~9emV-GN((dey zu%{X&7)-JZ@$2L0Yqtni7;-H%fWs%8= z=kT2S6oOA<-_q!hTShh=6tYB`my{cf^+Lx>yzS~3hAy^=8Fn4^M9*a;F$7-pPb`5WTTi>BH<(hQt<2d>L}bEO@qeR~R5CV6M#}U~hOs$t?sI z7o&N-naKA!$TJ z>&^XTo(>zGjv|b*XTI$ut5?7&&KtRH*Xif1`>gBEp7*Joo(B{{&6%EYr?;2euFLC6 zyxINGDCvA&Z9Ke6+p?I9Q!BMcUI`b0h}(?yqWH@VsM zQOR!?^5j*fLK3_B=$34i3+r{u7IgD)M~W2q7y3L-307k;BupXtBuqlRxD3=-rhwa9 z?bS^@iS*Hnd^;p2cOp}nC~VDSN?;3$3z!yI^$)`1W?UAhtCjjqn>M&ph0;8EaiL{z zu|C4KQm1Ko&6~iXk*x&^ph_a+*qDsevtmcT;T0k>1Tvc@2_|YU#phijBjGm~(FAS> zlUlF>J!lV+cX^mbgNt|q+%c)}o#I2L8tL)BII4PpHABevx1oqq4Fk=enLf)lPJppehzt;iO9UQ2qK{ycJZ}25$Em8#QCj@IGeY)Ih;t1C_j5#Indn9> z?q%Mr*&t<`FGYDnXUw!Q9F(&(vc=j2NyA|}`{O%(aBk4&ic|F*CyG^zcJTh7Jbkku znj-MdZ0aPz3?=kXncCW=-<;dP;J9T1y-C;{aJj^)J(P2N6H-0wO?ZvS=U!GHKVCK< z=aWv?u%5>H&8MwXa49`eLmGW<%;nt}*#2=)K*`axE(dLvH|fGa6F34#8tRY?cr_y0 ze3Ys0rp;JgADiP65s|!r+v;Bhhv}`Vm{n>M24Hc%zOJ&UhG2A;(vSJbsM4>fU{u2_ z-6VIhEcV`qxROML_k8tmxBr)-{ z0Nki4Ka!>@`U^UZ)eJ*+dVEKh%hU52puWKbEG44AD>zWsBPQobQCa)OTlz41wS`U5 zA(_e!#MIkQ_D?<^L@2G~TpSiQGc{2i*D?M}9=ed6<%52)rPN_&_Zz}kJyQ*xrss+n z+*}R)Uzw_8MN}8>Nin$jkrHrz;R3n*HT*JD&M9fIRS?wRHq#A#i(f4q5+z;_5Ij)k z55fi>(u^$A=GCiS!o_k6hWVWf;@9>(C^LB-^lw%JYn+7v`}UC04jw=#dbI?>PxGb< z^hYM;a|^$Xv8HwRyEFBlC0EGDeVFD zsI=F15ChE=aHP6tL~Ao9#WHh`H@ZcicgWiJi5Wg12JkaFg6%fLuw^#2^+FGSBYJC) zcLQaBfXhJJeIf<*h>U>kVP9*cRCfKc<$@qO~wd*)<>-)SK6P zJ@I^4#us1Hf$yt#&=?VaIkhDY^^W;!&OFd#L5S3wEK(42b#OVRSI3Yn=DLC>djb3m zOx*FMX7ymI4;B56>=L7Cv?Opmx_j#kUAIX{b-S2c8Z$v=gOMvo?-ij^Qg7+-IsiMdRFM)v7G{O9O zb{zD!lmDA*H)}70ZFQ4xTkLM$F*jknM@CK!9fA;1rEyA1T;kT|rRhl7MQ@3Z8K3<$ zthbXo^c6w1sy3usEhrD|+wtJ{DqW>!SzzMAYG&n5P_48!FI7^!mt^UsJ=Ii%VFz|f zC`{_0n8zVxPB%8P&U9wpG3=awF3lq(pY)ZY+X0iPX>u?nXvOVKqHlZ!kPr!p?==9sB_~DS`Wz) z-C{l?ZU7>v`xhem*b=STWhZXwe7a@WUN>CeYu(sj2^yMe+X__p(O0XKfx z%AXEQxVFsfTzy)ozm#eCQhr*;4iF$jVCn@40VgXeH%1E z29UQ3y$aVZ3TOp-E~*g`Gz^slv`Lf|RO$MFBa@P)tKRuI=cc?XxIqzmXgmw~OWv_3 z79M~sk*g{jtNxD4ShkFGO@d3`N{)-(L`+B$P3o{T)|L%BE`c71nj=koezdtBY4~a%t^5r3-m!3Kj%V`9dB?v%w?BxOI$&~!jUNWa z@o8Q~I6n%f3*aDLLYK<|4FU2X@*``7jnlDRq5+VebLwb4vJVL_1XDYFTUc;$dW3relP0}p?81NZ&{!uRJU{&9)O%uEL4Mkts~ z&T=;)Kjl_c^Tc3YX*8y9Lb`*cpyU^wFHkn{Z--k1SA~|n0bO2_YwyEVv91paW(>>D z5A?fn$`0!!94mEWTUFmE5+yocu&wZDj;aE3+jOFJ95*T%`pKWaqKNiaixt!T^#`@p zHlA$6Fj^5&7!Hb19 zHyE9zQWe<12XmH)8IDIOtwPeM zHRd&LKn-qMRQRtyy5LYzR9#*8JDBD2K-E^^INa=#S{XA+rW5XKtg>7Nn^Of&Vhir! z+P>KycTUF|e~Hw_vAX%ap<+u9o9)jcAVaw~|4zkmS zZa8>nl~i|D8zjQ^%<{;ZR6cbVD>%?nlBzUD&(9h}VOpBkVW!AuVW!MGuz;OfTWE_| z{yi!0mE#74$DH%4$iv357s-5PS(g3aXJUS?=I-+Jz4Y{Czu2{VMepL1!wV0l8b0k) zSH~&|HJ~YYm{WKY&gKO*WNzB=l|JE3C?T`VIh$Fi$wHFx68QWYRy%ziF%z4Zc<{>B zjkGSyv*i{+F*O@tKQ!EDM%7xw!z{Yx)~Woo$kr{Z7+t7ve;X$MoE{R-LVe22TZY;% zOIFYRqSw}4;Mcno^z?O*G8Q`&wbgNV%>E*DX{fnqK*lP#K0dvcU3endLW%GugLOH< z>Y{oG#ECe$UPvO#$t@?@GA5JFE*6oY@?+$jRxnx(BiZ8q{AuRkwymR+;{*D6-bh*) z-5@PC8lo`?K**Ec9*n$U>OJRjK0H$J@vnMoQZa4ti zMegzJ2oft=1Y+aEG$4JE9{t_I{tH*SwKVixk$IyL|hvQq*qu&_4C6X zp>36)v+qAXl|OfXL8koN-RrhNjjA36)N;pjmTkOO>jg}c>35j<2gH)fb7QYv#8VV2-AXJ1-O{Vpi$uIz3lMp3dl`?Wwpp>|6_$}|ROmbQ- z+O3VID2pdMNR%dc(_#%+-P-%bNIb5Irk&d>rOY(_mq8%P;dkWuH0mR4vhl=r?rV5g z%=n2Yz2%@f5#I6!(KxF>D%1-3IyJU|VW-!(l$}cWBQtobb>#9D+>HlD>@kp+qgiCj zU_Y+2nP+9m^gw~vIRygs?R~aXBZ*Vk8cFZj_&b8(pTaY{Y}cTT z*fRuKeL3=89rk16#2TNQ%KL}Ryx)%5M0MHy=A(uL9M*f_;^wBL-FO~J+@|(7I)GQF zGxu8y$fzRDE)xoI0MCR3S^FKd3Mzir$&35HZu)9V$~5*Kk^r{%vt!7ISD#%fswRS1 z7x8ugQ&u(usOPXbN5Z5URhEFc|NLc;g}f4JzVjlUxu&$T#yH-Omy4s=$~b=B<)v}= z;R7RHY}oe#TExRVjM2_)jF*Q3%G{)3ZZqgSTa^}wnjk_InITrx)tW> zN_A5pLZ9CogVv`5^1_9Jm_n4I&Od-1kC6YSPp-Oxyt0!D zIplg&zC_?4NKvoQui_?BUY3EYOP5n0W0#hYf21a%4Fg1xeEs;w-CE2d_X6pd9A`2e zuiIRY)}Lqe0J(eXdpq{`UG}5w@h=I2qwDlnybY&n3-F)3(mWK*z~Y1=sqQ352UCF4 zQlI=T^y5Lp>gG~>1T94`()}Z4=w<|*zIWTL=+#(!PT$k6nPOoI-RVk#s?iWB=$tTc z;v`#9_oLoCy7W1j8Mn^hfr?}kDKcERb3jxH4>hafqve(?N%m6{o48;*Aj`VQb5)Ul zHK-31_Fm*+OH8EXSzh8{$7fljqN=ahTv<75(Rp-SR$Zz#EMGFOcXfT5%J^HHx8x@r zP2)nIWHes~>%OVy%4>O3(0{X?N*ukyQv5>kKb>M|32-D&p%1(V8j7s?3w|Lp63nOV z937ts^a~AioVI92W$?353}~XMK~{A}5JkKH5b=n9Ciq@IDBAB;Z!IUAV+ciiDvH*j zMD^3Dk+a${QM5$azio{#f^OHOx>LnJ+5kbRm4^N`5ii4(4>XD|b?3s1jrWv1Z}MFy zT9v+!?Ds9SiLUpcRnr?JG+C=^SKkC=BwXt~F8Tyir)=)czcAl$Z)2R5pR!H;e=OVl z8*}D=$~ONscK(|=^G~^sSitaqkw<2U?vov$hY7)fa=I8~62|7IuK10w(qZq9BnSjK zt$S9yI^QU{77(-&cteiu27n8-8*tNC&-dMPS#upD2hi$Q=J$O0#Os?xwTN{WtSzZC zp0+5nsTrDO-C3RykP7Y)6z8U{uiQ@973Pg|STBrbPO4R4VU>jA3ZJD%OK)mD`u%Bq zjUA|-$B9L(11X}nY*naJ%@8ESe`WsFWU8vR= z2;2}9@)$?_zbc_riw26%Kg!e8Kd<=z-OEDxpIr0*^LqcyFQ+uzy_6rD_)MF*+Au)L zK+sV!gc8RX!}1A93BeHY86igj>{s@tCS@2Inb@Wg|3Ir$G(TxPHZ`*>y-_zsskEEv zlcqu`YL%;Yn6XuOyEIg6vQ;HLymz>grb&Gw(*q#A5?6USh=@|D2=%(`I*cmsk7f^9^}}P? z?OW5EW$5ivagZURMyiQ!)dSTd0?Cq6Pu{r&OKRfiuu+&nj(M|bhppFk4ze_}sSz1;);PvKNiaE=q^G|5w^Vy2SN zBs0Xts91C^d0dq<=JmXesd8D;1K5UvF9?WTYl6d%lJqXxN`Pj}5LxPgSRE$%)Se9Nn;^;MLmXCiH$)23AiNRlj3 zB5S`@U11=y{xj(rqgS3zSUD^dhUILAwb|IZt>UN#gv=Rm63ig{MK*6HQPQQC{?1ODO*flB7}Q(AO3hFI}(g&O+0tS_v* zssss=fjAF6c7M%h{bJFcbm>-<=R>Xa4X{qGb3|a97zk+R8pO+p(k2^QM<;%(sz0y~ zRB?%#!Lct8vXEtAzqvF2#xo$NsieLB9TCSs^E_?X{@2BD7<@uv#vvJzQhJD^v3!dT zl|$vIA|g+p5nMz|Au5{UAyp|$2kfI)S~hhN0%yOnr(#(o-&bKg$Y+VeF{*sx3Du~N znZWwrE{QHx{GA?2J*uLTQ+AKA)Nbt+N2AXvftlF`pev3SOJ$4`MSDf=HiGkA5i0UO zd~$T7PLbVXMt2^U57wmD5}@X1U>&QO#B&jZ0J18_+exP+Z@5Me9xd0Jbq&L^e7(>X zNNZ(5fx4(0i?cEE=!j+2!b@EfJXIo&j};GwfS*019h#N=Yt|*|0J4`!D5 zN_q7;3^d-)FNmK&7&H^rwGK+yh}q{Hpt?|PFC?Fm#mlG5xknmlrQ>IgB05c3KF~=a zh6K*nAvP~CiOXlXY$wlxYQ8_)WN;>NeiQS5Mb-&Nuox?GER-8$-`li(QhmzUy}Keq zW@+_RPM`C|bx|r{2{VLpv4kQKehI>QOprT%3zknCxVb_F`5u!3W#trOn>06Z6D*XH z=M)M2!jWK4RGLfuttE%E2P@F6hVZljI&jmjn43^ zPJ~{D)br75_H1XB8(ej-Emk3-$#Qk8x9>hEB<9vjxJQ=EG&)&*v=3TD&pvVnxeR-) z?Lb+YlOky39f%jYERz8;%h7@zQH?O%8>!r^nUZ(>IPqq+lbCHA8Ax24#IZ@dwzGe_ zNr{+ocSoD-L2*Xdg%@t^OiJbgq#@1W&4(>T_SLJKpM5HrJSQaRRfbG&uyI9+T~>My zyWR{C12~~%bhg$$vJk%xRx<*^v~v)B^3%hV33i~-tUvA5Sfb|5i=rmc9n>)2!GqKa z^P&<_F>DtK$|77CJ5xuKX-Q%!OtxP3n%EsDQrn82M%6F*?l55XtzSVcMPQG0ZuQjl zmq*Ic&aackwk$S6PqbQ!TT;VJDSX~x&h0RoXfrD8&a{@qUZfVn6$ilU9V(GVzCpk^ zP$Zf;Ui%dnVGK2;ueF6kZ zFhW{mY7j^Tftei%owFtP`AO&4M?tOT( z;Htw$hS6rDA9#f<0l{2DA~U)NOfScqg!^m^q#5Caibizsnh)JfGIIAiSiC=S%J|_X-AWeS|ich7A5v3!>zaS0qG@+}6 zF+61ADkXR}zFbZ1mX?PdOp=@C9DI^|;2Tz^0qedK3>_4z?WYMY85qL(rt=Zq14q`G zmX)L~hGa0K_F1zeK5O`YjYkt&x-#C=rX%}-v%xC}Z95zssU#Mk{YR8Je z@U4Wha=tl!xo6aPg=VsfWT-Uw*s!bATd!Jrcam6JES#?b>09?3j3HtW9zjdZo{@vm z;Qsw!K~TU*LK!uvRJbS;OkNH2Wt%Y^x3I4&v!zodO!!r6#`%hm7yl~tBXG|sE%(t= zztYj^vC$ivB^+7S$l7s@do8-L_omu&g;hi4Q7^#p%DB);DAqKLC_yf{M--fbVCW4Q zpLSAJpyR=Jw|FpZ7!OY9&`o&H;FE5C-006%H7z?V^+c?EUl19l4m+%pxM%W-d$e~- zt(|&Ex@CFK^ihfbnmM|@OUuO+x=YOaa6Up`MZSv=z+ zj&v;Xfs>|(JoZyyf*n#2H&qEvkEBqz1th01TIY?cy1siJEZd%upf04|88q_e^UcqIJI$qO^tX{0Q=;ytn*d0;d>W zpbMg2hvsXQ_P18QOkwPq?4dM+V|(uRBPZ<<$bpw08v0vS$9$VUpbm=Fv(IMqMe~ij zM>0rOq>iZMoC}d%y?jB;97(AMLyv&6Zzi(5LIvB?<#Ywf0)mZ_~Rdangdl z&@8jcCHuwoEo63_;{rqY2HFx=n@YZylX9a} zl&P9Yv{)Lgc|b3Q1o2l|SANshLidoYfmF5?I`bsF`E$9kGP};}K?$qva#L^~CH` z!TFGfb4WF(Bq_ENC#V_OREgx>tR!Qa(Jg2?b%7g;M5AE-&>&(JHfZkcmN2s4eJeN!nCrcl9Way`gTk=o|nGo|BD1pGHLvB0ih$H-WM^@K##RBrgEQ`4$CSNzg z8QjInTy|bpvXE2PqeM9*$mGvZ!Ps7Fn?$@*V_0OIlsGq$7xq#m0A&oC)8WX5OB{I{& z&m4D92ULj=J&5P>4A>lRn(KPS@|aiq-&TfHnOC`uYpkgbZ!za!sgrKX&HmC&DR$Qw znLUwmqe#(ab!;OBsne)NG--Cm>qV#<+25uf(vCyt?AGIMoJse#4t}n3bFn42(girok)X zsLlF0m3f3uPV@^VjN3J zs7vW$dREOUH=t;vnxK-_6qp*ejG&zM*m*>v9wu&xniWe@+eJ-67VZtoVET-b0X5{6 zr(c*Y=7z@KB`=B#zMR8)M_(&sn@t?LtNkyD`lrk0nJapT+`Ued`PVEyOY{v7f2Alh zxP{mY>C3kmqt~@Sx9=weAH3PUD&9e;-4Z?DM%u2JrA~7?nOo3Fg!@?ilHRb~Q9Vh0 zS~k)vttP$Xy9A>{?$-j{oKIM^!~^qOk9nFfO9U;uX<{Z}MGPU&T0}pPw4d7EHF*^c z(1Qo888T#p5hW(|Q-(yg#r6vVzhg0gpd>56bb9oH0wu}%3M)p2fxFLEy>QG4R_-h8 zU+Al?!eBv?3%sHzLA?4>j0E@%7$S|RYf_S$ylY+ z4n%*ot_mG#p83HvVERPUjJRH!Ay-9T%yQe2biJr+b%|?XeE(`??bZyWEqp{h5`F<$ z|26&q>X&o$0crC>TI-zNN~}*w7-kFnefLs z2fQs{{%-wM-9ryBgJ*Iuv&{5yuKy+Eoc^si>??Jju|gyAn_Uf`ajXB1%g`EBtwiQ1 zx^awk%lc*V?-yf2mx&<2oHk?3d{TaxpMu&Sc>d+t2h>+*DNg;iw%P+Pbq56MHt1{8 zuC!j;1YlpBL2hXi-rks7|L=db0Mz7?nWiEF08stMZRP$Sn6!kAqm#as74d%`|J5u1 zZ`hY{-1iNNl z1=2bj@r1^~3~TeQTAAId%fY2ha|!FRU6VMpiAkkk@VViqVwhBxz8SBI0v70InyyD6 z3Bn|Jj3nVomoatTh{xa7jx;yvi_UnW_#l*M<|9E)rOc4j#iVycL>cKHTtp3#k-nKL z+7?|mS#aSINetxl?nE8)%Zyk>!C1k`<{`huyPwZD2`YbK4!99|Okznl56^r1}88nU&cpyn*~f zRP2FGaX0@#FpvKuii!WfqnQ6~#DBA2l_uoxjK6W&?wmdns)%IKg2?m;9KE4d3H+J4 z{P-@21_oU4WQ76zv4`7rf2c8VBqkLlTWX8sn;VP7*r9$|Zvr<123Vyh&st-dNnOt) zxtL4AjW-w3bde9fPrdt&)f0toUJ2&UdD?Duy5Ap7dEF=0V82i93p+Kxkri{*^!QAa z`)V#?MO?Egc}EaN?0rV`N9>*U}noU~6E-WouZiR;Mgh z;i}OVBurvrDpRj7!i%ICbMj)VT&(w5JB7dEWs8$MSfbZaa1D^jw$rlh41JSI!*+g5 zc`HjldKt~dEdKiq-t`OW#SHiFi#h4kU3|pR`S;CF5SvpIp|Cl8#>|qEO zL6o_yj`uN0$wSqXQfj)_qWIKrnS3$j-u8y`GrF8k5xy*m3E_xC>4xG+3@28lsi2dl zG->G?bNPxG)$u+RlKOK*4722EnDvKFTfCP}MVn#i1AP7T_HVVXeMTs4JO zpT_!OPG@)cEQ+es9a7Q~8ZJxuwg`RN6PqI_ZGrR{=g#vc28nWQy+I8dcb5dFR^-u; z&&P%sTVJJ;F`R;9s*$hDbF31St>mkHWdp=P*}5fF!x?lQhPw$TMi}e=#xDm^PWJok zBklIX+F!cN8)z!@No~Er@9ywmEwj?-&7I}xh?Aw0SPtK(3EQ+5LHqwwu+}k1p;#vH zrvh`dw3QgL-4@kIQ!Av--?{@#~s8|+dQ;(;Mo#ndpY6spn{3TJBv8{Ee0%vgX2)N zCCV1=Y(p9TH+hpYR^mG9QF6nF>tHb9wDPpXRlL7F+QvVV*IK(W=+D|wiR-*I;elS7 zY`O=x^{a5b-2CDtug6c%+y!Jb>;Y$1|5k+KbP-$ndnLz+PK~0IJ6_kenCmP!NG!nT z0oX@l4sD#DBU$@kjnc{sh4baeOf!mqY{x0?+@X-P%tFTkGt+fK8Xnl}SW!g#bX7&^ z+2;eo?q}&im*rirs}E*eubvzp8ZZ##(eDL0O^$sfaX!0;rmj^d#vG<0v5$vbadqkM z;c@S>jXq)Rz%lvuo_XtEk0U!0-X%0LG%_Oo&y;sC!y!Vzbv!1e%gjo7+E(!P5CXQg zglw~&%zv|GAITU4^EUXYL*ba5L|+fG{n2f#<$P`;XXQzw!rFG>1xIQtjYXPCx$0Tg z_y1H9*k8*NMu;cG(T9I5k|_z+!6-KvLctWLG?awCF`Wto6>5{_B*kX_J!#TlRfW|Q zTxT2;H#0}=YR;55U1N;$dTp5H%;k}GCmbbyfA00QK5!SnK;wWT_=y7G3YX(F_2ej zekKG-;-FFYlnsInfBS-ue-l(=JyzlnCV;dv+bFa!pd>$1xZyr37BgGGzr|0+^O~0j z15^}t&e-E6dU|#)QNVmuka5beLq1^$=n5hx6Mg@fLV!rjf(f07zjUyE!{MRr^$O81 z9c&-SdtEZ{pn(T}h6ZnUS7wPMBn?d!5HMe!BHRBbb05=@24O?2h_`+1 zSkky=Y6p<;hK&MFs_UV3Pi4-ZFlQ5qOdAaJ4>=1O04Q<~*!bCF?FPS~o{er4?b z@BAktYAQF=_~SF#TF%vAsN~HdgBetV+7Sn}tl<@KS7SOg0f&fC(;da%oL1YWSL+*m zGM#5P_te#*^#`lcd2E#Bzrd<*Ozyihcs6GM{UIN@;iOnS-MRs~qr?3IfIIow<-ibm z1axfeXk3WdOtrvL9~RrkL@RPE27Wm{vO5xg=Y{Si6xRMyB}nHWVL(7VUs(tiyCf+=eFX z^v*e{k1Tj6MkZdZ0LiaYY^zFpCUo+Dxx=bBlNeU*IS#VeeOAzI)Vt^$zh$j^EZMHM z**h+Kz~xZ6N@mz-#ETTbxO`K|Nr-N;@=2jQ#7ZgkFx(W;GWygjB|Jx@jU+qS`t!IrL_@Mh#X_TZx%@ z^4p_*L+-*ol_Bw(5gpCY^}j0qLkVl4eKqJivQEuSwK~_wQU=a?(Pr}B&EB% zySux)K|s1&x?55}O1is2>5>k~O$h(?yyyFj*W>Z~9|mI&_Fz2Mnsd!nbFSyU4NmP* zk_r34gxePNOJ$h6cykvyCw$qW0>}3|r&9U*AFcQWu@^Z90;YM#zVCO^+rx zNH@pXoqevqr|SqP@$wvXr8J@&d_JP>=uXmMSW8G@sN0shx}NXhJ^U;k3^P3*Y9*{X zT_){Q>`WUL%w79gi?=u4Dq=QB^rnC>Qexc!1mCKET58qi_4>ylhJterN@VVP&{9R} zf`VGjgzL=<92XlYXsi4V{!C1%tpasaKFas6LJV)K-=vfm;P_v(pq!FX4Y?&YsVKhO zR%%faHzRDbQ!M3E;64T2WnRzcuczPxKYjJ4E?oK+r6|}!&xa}zY4)CB2A?|sZ9Z0a z|7}5bo3I!eu5axh5J}j*49lzaa_Zc8rw3g>pdb(cSDK@($H8DyJ~4-_*`cwZ$s? ze5h6-?o%Yb`5-tXa|0?FF6Y2tk6?PhbB~VSfa6cTW01)6;9^4dE+jka44m<(+qOx| zS7+%A4{cV1vYAlL_6DE@7TAVxXLfPEJy)0APHnPc=nL6sYxCkc(#=FY#J=VU)@bgA z0_~_L;7&Dz1PtGWxfn&<4}Ma94p>_udw=f*7k4kv58VQ0lC!J^kehlmGtWV4Mi6UiYHz1L*lE`k@;g5_yK$-= zZtu<-NFGqxlm4JpB#T7g%Ex-iNmQO!&y7g$cHfwbO|=&7md}4l4Mn9|n24rEQ^>Ux zYO+gTedMAD(2~_1Q6k*FOpy38A*yn7gLcbXj?+s+U;2tl$BG4xn$@hHmfNzSfuA*V zDR8OI{FbT?yi6r34Q}@hSTAGKo2ggB19-#DmV2x|Zadz2|rHCQV8f=qYq3S-XQKr)V!L{fbjC(JB{i1oZ ziF#JsGKmxT>@0|5a3}*}b2#dWUIr!i`8n>4;r7E*)&qvB!SvEbZkC%_T$i>HF_iTK znSw(apn9nYdcK)KaXd!E__$?es}T}>(H*ztldjGo3~FxJOQHIwDEbA;V7L2u0y+iR zI z`Ta|+1SVzj1fro-ACvhOxw!`lkeVnt+5zUv+2Q>l6W3DEHS!?GkLeUc=jF=*DYi;4 zgAmXvqwtL98S&@oBP*(OL2;6Q!{jJ!x!SIzc(UKP=n25KVnzea3MJKb=3u8Cm>iLlc zo>?@$-95+WQf~)EAZt_5R=Kx&-+eesXf5(h%iWVsgV-k<5sR4Bt?SzA!_Si!Vs17{ z{6tvfF)5Sptk|88Zta~Yi^wNgFB3D>72<4rA$j}O^elvaJgTjo4ShF~YmiNpHeGbr zyKXGp)-!&Ibd!z^zbI+4QbF?)fGbwcwDyLFza9Z}=ghoEC1>_-5DRf*_-4`0`D_3% z-j$9^NUELnMfu|?&hgFGHu3n@;Oi!chfyGFC1tj zysM2L<;pVB&eZILeivP-DG6^E!_0P@Pv$*0)yMcNP8S ztipdgy#t~iDVyOeruzZb?;xzt0NZ53utk9^3ZvN}(iFQco`XI5+!2~Bt*g7s$UI9V zqTk}E=N|5KTZK~u!6+3ngR++0rc2UcL~b2^1ySOpH^5EkBa;19dk^IoLT_D(^eYV? zh)u!~KjQmm97L8GO!T6q$6zM-+4)P@I(QCal||#8B$YWzh+EnD6~{;lGD;KM(2Z~x zbfm^>#(c>3<`9QS(Mb$0_NoT37Om8`p*ft5u4+)-eY&scXqIdG8ph(=r%k3w~PVLOXd zvY%SJgzTUS)}20bSmIE#Ku2ArE#^+hFkz~5s)Jq}y~;DcyBxahE*PlD`+}A(u^rn<&8zczVDn%^A5dk-Vy_mr0qL*uM z+kH(G>dhnCDc>o`r?(AIs+^*rfe)ECTkV3CYD3Q#19fXQhe<>BD4P`WFJ{4fglrGp zMC#o(hLNzR_6BG%EOWFS0kBYlhLR^aX`ly0}L;y&ATq9Kgir+g(JSTR7eC^Kd70rtk@Qwh@u3M8?jc zvgkQ+ER2q@6iY?Es?2yUOPXy52HHmmw09OlCy8i1JSX$cFQ?Kz?WxLaD*;xXXdOZ= zBkjariS2=U=4{ztOD4WdLby%7@-N=%81G7r_onmAC}*~wh&dH`ElcXAaT1YCg!*3c zydPyIQxoLY1}B)t!AYV-sVm|=v@yqXQI~?W4Le?d1`+uZEGOQ|ee*VGf zrT|&74wW?}lFB{`V02N9RseY6=RHwR+vczuOFPU6KW$IutXl`cwNkIGa12qG zrJ%bP3TNk7J?}yS3x6XEWxoN1EKl;n-Jr)OR82@8A-lLcqJ0m!DhivFnJu)P!CIZozRj3Dupfu>UuxP6njtRWN0x(t)#GPjJ(W*QX;@KZebajIc;dm zCW~hL0jRsrD=aVq-P|3Oy{?-lW2lzd!ihrjVFr)oLbOS5oQOiE*S-!;?Lbx&bB@wB zIBCNkoH#5Y8I#5PlHx>EpLUEIfBnTV;pU3R%nfkZ z!YFhE-!>M@7lKEDX})s?nHWmd;*DDNM6GEm7PaY{ePtQ7vU*E6^Yo7t_xmKXg?pIw zLetbL($kGYR?TwDFJ{6?y@??DP->A;k*WI-u5h`r_Fj=a1?c8CaYv_fx+w3Y&sz)# z5l!Eerg8T>?FtY$ym)%@xf}a@V)bx@rCghzp-=;#(K|s@NOO*IZA)NzB23n8Oyp`N z6Y_)!pjq5GpOl;|9mspLVAjuk4Swf>dB>Z+oWGfksTiJHt6LL8{)`TN&}5mlo&S@f zn?k$j;4E88b8ms}U06xznINvR%znonws$*X0nXu~KR;D&0=; zq1MxLBj~1VFmZ3_rpJ&0B|edG0LL4z$TA%JtOE-~IHfCXompV+wy z8-&6rt-RaR;6BG2HZ5IoYkQ!W1K80!*5H1C5|T&@US7!VmLWU9nG%2IR0sf%g(q;p zir%R2#OCiM-FRbfu?u|_l)-Q7I{}F_K#B)nXF9wXSLm-9xO`&}clEL58GaMK6`1Uo zQKob~3zs=o{h-kD;27bhfCkdw{8=X?mD$rB(iIfJLV2z}Inma$btemM>{3VY_dH`c zRmH*W_;0{4Bi*0y!=kq3gCg}!KzsqQv(?<&2%Y|52_E_JZZE7axCF6;pWKz-h9;(1 zFEg|lBDp{TkLtU9pc8X{8!)$h;lT}wYiX`cFvH{sCC$IJ1nrkGsX1R-c54t zLc9jBHVaK(PZqQAK)*w|rQxaCi@4yDsR;BKp_0+QMY4^V@oQdty=y?g5jigp7$EqZ zjDUR~x@7qfAlguTFi<0JZx{E(?05$3ZrE!(`+7JwC(6-O)0zPfL-;9#k~GMZLtGy?nM#)>2+T`kNj ze-Cd%!Vd{3rx0cOIo+1L-plN7F!@)*0?vWum?{xsvwILKF<=UycOWzqNrt^1DAHo{ z&>l4+Ab^}}aY{#leq4;cq6#<-V$Ho7UKVZ81@Wh+CFOY)SxBEZUOMd5^n&4mJBI5y zhiL&%RP$EK=dU%dsx>v_%dKWSAnH{~OU>To6_twC8@+RTFwOV zjN#5sZh{G`WWFrn$+vV8xa_EdxGegTh$iG5fdf8|IkR2eF_u{^F!2%tv7EYty{ytY zfTzxF4)ngPoP_WTG|Fer08u&Q$%>o}_7yWw_VUke{^I-nDIPLL`#{~ep5)0hW*8ez z$=vvIc7ys0bTt^Z4cC$pSAr8jP+)*}S0n5;J4~41b{%cIM*fv_$1_a{7~CzEGF*%a zmo!~DyV(mH=a!>N6aTXY|l>8fd_G+w#(nF|q5jcLBA z13?#dl>PPCA}RNzqD6oVO(@OKym{I-Pa5JmLRwqW$FBiUBnL+P2)@~J(ec|s_sm!R2@$OKicGYN*2GqU(J&T z{Lqn)*=vxuAX1Gv0Dk!C`pCTtlDrGq_gKcHI?^jian>rS^UL?G0{-ilaNK#DTyw56 z{Mo5FbQ?Hew~5Kllovle5o!-n7?EA%~9 z%jQnBip8H@%a9KGo;gZW59-6s%P>_Y62@fk&z9tt_3vec<8wZNl}y-DPVJOG|Iin_ z626Fx(_8z21@R?Y6h3=m$wyZ(m0~u^gGm$C_>_E9bIWd}w}}Fi6`vO0&SEgSdVWB! z70oGSTwI5)%Dq)n3w0Upp_=|g;_;3OZw=}>WJUsdX*M=A4EsAwYD>0ZPrKc^Y`%(P zR4QJgyJNu4aNup&3279U6_ zdbsfLmw#jb+-(ai0SJf=$M4ESh--^XS307Zgwt`pJ8{}aNm%u@LRcdGx zw~H)F7#NIpX{7#kW5V(1H5 zz5AdL#5;!Xs~elu2h{fX{pR6_V=3+&^ruJ{iTx$`s^O_)RYD@?{ol+}(o43PDCFcy z>6@z&ig(9lnQ&Je#^YG*qG0nV5izc-nDi1Oya!vptC5L&xq!LbWas62!Jk9@Hgg$u zcf|NzytpAfC_?Eo)ZG&ywyD+)KyrtAk@F|5=o#Mda4t2W8yW1la)U@5zE9jn2t8L( zX81%5B2%>F4iIQQ*!=|^;t?PSN?@8gFwrSJ@S3$#y8xt&xUbuD-u=7}9#eLWR72-qTT@xu+BTcA6}iClYMq3D|3PS&w~_olnHK zbbUG}X3XIIUV2VpcbYSqR^lWK`E;G4pb|N_JYdhO-P9g;3Pq zx#XGZHE!5Xc?m~}&3$AbIXJZLI=xQV><&VT5CXbQ&*Kz10ue(bo$2A61QOcN*>`p;EOKRNXLPtn*{8w3F-Cleb(>;Dq;Q;C(4 zd?J7xq=(1C&}V+H(IjuWE!QWIPhSF^7YZk!fUfOIo+QzqwU^5k7P>3Y8U%-;?GA!O zHYcntF5ohIP^By2K2uO|W-gA~czK@O*61M(U{K*rXX`j+=FR!L5*bC z8%ZNoC}V;XL!Kpb>sP)JkSj_sf;rwMx2$<+g%bK77T7~8tSw-VD@GV=JA)2g5Hs@& zN(X^2sMAj;J;5fpbBvQ$s%Wr@mKo`t|+60qbQv%_fRc(1N8*2fDS zc~Y)?i3pyo`Y`?2GK=TmHMB1Sk?@)-KhzR}Oj=qWo(Ut-uUx}_lC%xNatZzBfmEBJ zSB2ILfPtS-VxP5RivoeD?|F1}MKFC}S2DXwe+>&i*)@^(pNc<0Ylm@t;ENoizkQkG z#jnpbKyf#qNVcsT*VPwT{GWW9AfDFmg(z^eN2;&JR3~wRYIg?8~`b z6w+Q}ETeZ#j>1Z?z5425VK$AnXI=J;)o?YW1AC@*n=7rc0xy8rmLo~Jcb!bgn3ceG zv1@S2g~rpP*}ia;hD~CRV%Kn2XA_Ux$o_4-22CZ*sM5r!eGy6Peeyw==5WHgAUBr! zfvRYibkq^Pj~pB0`BIi)Xx#xu3H)+%OM`sS+HY@3+2tFUh{#~*CgyA#2A6>lqfn z6S5O{6{Wk3D3`MS+HG^VfwulGBaN;h`#huNIg<4%zjQE;0edb^GBt_26eM9Eg~2<= z%x&8wNd;sz2J(b`T`Vn+b%GZu!pg_&@u44I_b|jc_M^Ast*GX% z~cER`C{E`DzN*%y4r>@ti4A$Le2~6EEK|BE&%nFopIQQ zN!-D9pX<=ija}?3M}Wur)SnR4!Q^=N{TZI>K-5OX+PuZ@ecEdP)O|3 z;Z49IgbEtgSJg(*(Aa^$Aoi=5ZV6^_E4HzP)mn?bbRzqSk-Q@}P! zU^@l7uS{R0FQ1#*uh%#!jP+VDBI7|deK+xz-o;cMwsFQa_N6oU`m|HL^uTLD=QXI? zqFiDND9*>fT!W9Zuh{5;R})jH-(6Au;dQ~kD`bIM)20??E{+DjC_(m7K9a=~L+3%m zmtNX7LSUw(wb78YdD4gQYKDwb0w6BK=Xyc%RRPAvWSvJs>w0h2R385!%w)PxhWr&M01bMie zx>a1ez2u_4;Q$qR#^a%(z`bD;W}PcbW;gZp$;XJ(jj16;20aY3xp5(V_)^EWM`}Gr zK#ADYB0DVWY&9JP_oH)FDL~K(Y0HNT%jo5+7MAC6`q*B*BqP)IfOA zSs1}p4ht#5?g87B?XYTl`HxLvWh($kg4e|Fz2Zvohr;hXR?n)(=s&V%ugp%$J_YTVFooJk<#&j9b704}aM+b!QM* zY2B{6NUDF@2GpzM?B-{6Ghg#rk|qw*Qr=FO%CA^HN`cxwni?*?^I8;o%^2I|#b!@H z!~kFZVrVLm*xR}zG$0!nJB)j{!+gufR3EieNl0$mvb9e%%PXc-huMH^XTw*p?1 zYyBDhW(uaF%N2hMyCTWakzvUi@hY_+R8p{u`b*vcrP^U z_*g|+yWK|d2olI`sQ^ThBwo*25*7;P@yH3tB(f9HU$-isz0RnuWHIEzUyNIb?n@Re zv$Du(b|ul3b3Fq0U>?6%DxBrqHZ@M!(Q9Sr<$XXSD&RZR=lmi8#WaVOpR03FJ!gJX7}xq)vi!L65L~h`COI7w7PQN!xMG^TmKZsOTAK%u z#7EYSymBa>Y&`4@Ffm&lxog|JGhG>BPx$u;Ig zhanra)@5TBV{@8(le)od=MZScTHK2=8cikHIuNW>^0PQLiQ-@U95r?P0sc?spnX8XB-Fwp8ZN9nk*gQNY==j2)0kCP> zDS3wH9LV%ani_3bU2|xy#zAU$rwL<`uAe~6y>{(&G8kQVUiZh>m`rur~bZ0XVL~QQ(q<_ClM)5o8+`+95hA?X0lOj&2f6?i%}xEm~y3R zZA1w3h^*;MJ*GFdRrP9o(a}EeSy$0MRB1H>ND#EI?o(ILX|D1yXsML7Jz;PiQelZ+ zp!i9t0BZQ}Y0c!zH|4A21GdDR7i)Cpg{XY}^=@lm1vWb9>y^p4F^Fj{5|XH~U(`1y zf0U&kUb4c0uQ(#`!MNRwE;%*DP}`saRhM}Q@8)WSInEKkDq_N)ih@A^4cDIuzpTR1 zg1^TRqQx;vVRq~}7XnA(a3&`_p-X}Rp+M!R82&a9yRuU2)qbcH!*(OuBG-ZxL$7^3 zk&b$I^~5I@OdQRRR`nvwa|Z8Ax*#R#RSH|9#$u7?>1oDhG*RHFDlwSr4bi&61QLwz zDLzl|vh{cbR+{+2Riced&uLkYy9`dK_ScE8u`N&ueqg2cUruA%=)P)#35CF58vwV> zIFPBlmMmvWShXzwjAC;X9Q9dnE`&F@@U8Utn=nx1ySEfLX(0((;LiiMhO*{o z332vyIVs;A+_1A?y(oW|?Fl2oUa(^_iON_+oYqiYgd}-iq2eyFl8e*2C7b|Q$7#)w zm1s2=sH^Fdv2u>d+BWU{?4KqFr-5CP>KbEH1xpYDVVij6M-c8AG=ym^@?d!I(P`9u z(W@77VDq{wy0<#R`)C@Tr;x*YPD61$^u=U&KnFrtLk+}c7XYQ}!}&%5t49-o8#I6j z8$BWc@|_PmISg)MZFq}`=(Tu&Y0*gn=!zUT%R6}HnzGC1I3zr#o#GHqMQG@>OzQj7okNAF z(psjhjkl6sE-6TI^GhnVg0K&Qnd~;28l$D{!$=pSZL9m)_hz5f__8{k;McQxsl7yL zoV4+ZL@DetHhsB+u&|Sr*#=j%+t!eitu!F$RMK>tLL_&GeKR_!oe^eQ=FnS3U9fs4 zI?FrCXlH>RT``+eW}G!(+Yec7JR&Y?WJi( zmoa%r*|6?kWI2MyMWFR&UR94W?=gsTJxJ}_*g_YkdUWL!owBrj-lX=Hx;)8+BIbFr zftcCqOWQ7{96mH7cGBrD==xgg7+$j^gyKT_a)O9QZ?{T>TX!jrkd>J#Cm|;2;tO2| z=43{SY5NJhTQKQ*&oeNy$u#WO!de&b$r+usOzH|f+vA&o_9PCcYXVad((7s>b=O!Z zxvTY)LL%1i&SDV@+C7(o`!I)3_ln}{m?q?=Y~@fKh>zj!lY5>N_O3$Ml2U5KPx+(7 zN0LYrf4JaN?NRvbXSVht{+PCc8`(XyfG??_f2D8e;jKH>`WI|T!;WbjqP9zrm*ZR7KW`bM%aMZ4>;lijsSslVlc+pT}&WfxFuQSMv0}uM1%mqJA$7GWa z6pIIode$f6LrBHlm1tMmunGE`=P4W`HIGYvT#t8kYINF0AA{{c=jGrCMA7YO`<&7m znPRW=3T+R(iyAEZD5LAgt+0a^)JQ95Y} zArV<65fxQBr;(Bl?f2HlYs0 ziGdJ%;O|#epl^W;RG_kRG@~>7OHhi=$l8MLJ1b@ZM>7{2pdviba?Qm47dPlXx4gn5 zAS((u#k2^#&-gl#^es}6f5-WyC+g41pS(8g(F7)M06uYiwe0*BfoQ)={+9!*<1+zM zpe4zFKtG#={Y zWh|VWfPQ@cp#n$BpCHi$Fq3A1NJ*f0`j5@bc=iX#zgcbujwXNJ%$8|Sv|Ql8_W^R* zf9Tq6-~sy2ga7Yw^MCDC&}KYbVj#*CIDmc}rk9j|j8g*IG1;2^%l?~tkPAUa! zoPSK-#rj{#|LUpVSk(V~Fn@15{M8crTjX;6d-DGbxPRIH@BK7?9A#=eKOijruWrUa zH|Ben#;-;|-(p?xH>CfwTj$T*@7>LQyk=br|G@pFquD<@LjKJ8-uCLNSK7B=k^Fbg zA3CS~4E^4B>8qpGw|FJ}1N48^U;fBn>u1XM)-XTrI(OM$QvTNt=KtpC^fUK+i;S=aQLge^)V~~G-zzMBoxuDS=O(|*`v;1gKX3c@GJ`*k za60qfF#ev4`Df+EpE=)Gb$=Bt{1(v`f5!Qj&icO6_{Yu)@%|;?4@$*8G>EADkeqE{m7WL`BO#91q`=2-V`_;N1uP(+}zs&l(<<*~)e?RN~b;0jj z5a;|l`5!F*{S5hjw(!SY+EDOI$ls&#chmVlGroU@`a19UEsRQj$M}a?NO>s;-~$;5 R2np~f1o-$>Q}y+){|A@R9n$~+ literal 0 HcmV?d00001 diff --git a/src/kotlin/gradle/wrapper/gradle-wrapper.properties b/src/kotlin/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..df6a6ad --- /dev/null +++ b/src/kotlin/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/src/kotlin/gradlew b/src/kotlin/gradlew new file mode 100755 index 0000000..b9bb139 --- /dev/null +++ b/src/kotlin/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 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. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# 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/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/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 + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# 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 + + + +# 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 + if ! command -v java >/dev/null 2>&1 + then + 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 +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + 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" ) + + 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 + + +# 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"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# 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/src/kotlin/gradlew.bat b/src/kotlin/gradlew.bat new file mode 100644 index 0000000..24c62d5 --- /dev/null +++ b/src/kotlin/gradlew.bat @@ -0,0 +1,82 @@ +@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 +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +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% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% From f7da940acd7094a1208bfa17b3f2059010a0a682 Mon Sep 17 00:00:00 2001 From: Cy Scott <3858469+CyAScott@users.noreply.github.com> Date: Mon, 25 May 2026 13:59:04 -0400 Subject: [PATCH 04/10] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/release-and-publish.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release-and-publish.yml b/.github/workflows/release-and-publish.yml index baf5a7a..0a649d8 100644 --- a/.github/workflows/release-and-publish.yml +++ b/.github/workflows/release-and-publish.yml @@ -130,9 +130,8 @@ jobs: - name: Build Kotlin package working-directory: src/kotlin run: | - chmod +x ./gradlew - ./gradlew clean build - ./gradlew coverage + gradle clean build + gradle coverage # Upload build artifacts - name: Upload TypeScript artifact From 8fb84f3ac68d671b6bb65c5ab9c56765542a19ad Mon Sep 17 00:00:00 2001 From: Cy Scott <3858469+CyAScott@users.noreply.github.com> Date: Mon, 25 May 2026 14:00:38 -0400 Subject: [PATCH 05/10] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../transactions/messages/PolyBusMessageNotFoundError.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/kotlin/src/main/kotlin/polybus/transport/transactions/messages/PolyBusMessageNotFoundError.kt b/src/kotlin/src/main/kotlin/polybus/transport/transactions/messages/PolyBusMessageNotFoundError.kt index f71d7fb..a291f6f 100644 --- a/src/kotlin/src/main/kotlin/polybus/transport/transactions/messages/PolyBusMessageNotFoundError.kt +++ b/src/kotlin/src/main/kotlin/polybus/transport/transactions/messages/PolyBusMessageNotFoundError.kt @@ -4,5 +4,5 @@ import polybus.PolyBusError class PolyBusMessageNotFoundError : PolyBusError( 2, - "The requested type, attribute/decorator, or header was not found." + "The requested type, annotation, or header was not found." ) From 1b4254a0d2de45c5ace91b428b10c19d6edfce47 Mon Sep 17 00:00:00 2001 From: Cy Scott <3858469+CyAScott@users.noreply.github.com> Date: Mon, 25 May 2026 14:00:55 -0400 Subject: [PATCH 06/10] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/kotlin/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/kotlin/README.md b/src/kotlin/README.md index 485f5f5..4b379b7 100644 --- a/src/kotlin/README.md +++ b/src/kotlin/README.md @@ -120,7 +120,7 @@ The project is configured with Gradle Kotlin DSL (`build.gradle.kts`): - **Kotlin Plugin**: `org.jetbrains.kotlin.jvm` 2.1.10 - **Java Toolchain**: JDK 17 - **Test Platform**: JUnit 5 (`useJUnitPlatform()`) -- **Project Name**: `Poly.Bus` +- **Project Name**: `poly-bus` ### Source Layout From e0fb673e7200f4f0a1bd8e0d49abe84cb8c33bac Mon Sep 17 00:00:00 2001 From: Cy Scott Date: Mon, 25 May 2026 14:07:02 -0400 Subject: [PATCH 07/10] fix: deployment --- .github/workflows/release-and-publish.yml | 7 ++--- src/kotlin/build.gradle.kts | 31 +++++++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release-and-publish.yml b/.github/workflows/release-and-publish.yml index 0a649d8..96ff40b 100644 --- a/.github/workflows/release-and-publish.yml +++ b/.github/workflows/release-and-publish.yml @@ -296,9 +296,10 @@ jobs: MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }} MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} run: | - gradle clean build - if gradle tasks --all | grep -qE '(^|[[:space:]])publish([[:space:]]|$)'; then - gradle publish -PmavenRepoUrl="$MAVEN_REPOSITORY_URL" -PmavenUsername="$MAVEN_USERNAME" -PmavenPassword="$MAVEN_PASSWORD" + chmod +x ./gradlew + ./gradlew clean build + if ./gradlew tasks --all | grep -qE '(^|[[:space:]])publish([[:space:]]|$)'; then + ./gradlew publish -PmavenRepoUrl="$MAVEN_REPOSITORY_URL" -PmavenUsername="$MAVEN_USERNAME" -PmavenPassword="$MAVEN_PASSWORD" else echo "Skipping Gradle publish: no 'publish' task is configured in src/kotlin." fi diff --git a/src/kotlin/build.gradle.kts b/src/kotlin/build.gradle.kts index 77ab010..55dde03 100644 --- a/src/kotlin/build.gradle.kts +++ b/src/kotlin/build.gradle.kts @@ -1,6 +1,7 @@ plugins { kotlin("jvm") version "2.1.10" jacoco + `maven-publish` } group = "com.cyascott" @@ -43,3 +44,33 @@ tasks.register("coverage") { kotlin { jvmToolchain(17) } + +publishing { + publications { + create("mavenJava") { + from(components["java"]) + groupId = project.group.toString() + artifactId = "poly-bus" + version = project.version.toString() + } + } + + repositories { + maven { + val repoUrl = (findProperty("mavenRepoUrl") as String?) + ?: System.getenv("MAVEN_REPOSITORY_URL") + ?: "" + + if (repoUrl.isNotBlank()) { + url = uri(repoUrl) + } + + credentials { + username = (findProperty("mavenUsername") as String?) + ?: System.getenv("MAVEN_USERNAME") + password = (findProperty("mavenPassword") as String?) + ?: System.getenv("MAVEN_PASSWORD") + } + } + } +} From db235e8154c73039d98bea46dd9d8ff683a634ee Mon Sep 17 00:00:00 2001 From: Cy Scott Date: Mon, 25 May 2026 14:32:40 -0400 Subject: [PATCH 08/10] fix: test --- .../inmemory/InMemoryTransportTests.kt | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/src/kotlin/src/test/kotlin/polybus/transport/inmemory/InMemoryTransportTests.kt b/src/kotlin/src/test/kotlin/polybus/transport/inmemory/InMemoryTransportTests.kt index cad0fd5..cfb4503 100644 --- a/src/kotlin/src/test/kotlin/polybus/transport/inmemory/InMemoryTransportTests.kt +++ b/src/kotlin/src/test/kotlin/polybus/transport/inmemory/InMemoryTransportTests.kt @@ -6,9 +6,9 @@ import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.fail import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows import polybus.transport.PolyBusNotStartedError import java.time.Instant @@ -37,7 +37,12 @@ class InMemoryTransportTests { transaction.add(AlphaCommand().apply { name = "Test" }) // Assert - should throw an error because the transport is not started - assertThrows { runBlocking { transaction.commit() } } + try { + transaction.commit() + fail("Expected PolyBusNotStartedError to be thrown") + } catch (e: PolyBusNotStartedError) { + // Expected - exception was thrown as expected + } assertFalse(called.isCompleted) } @@ -161,15 +166,21 @@ class InMemoryTransportTests { testEnvironment.beta.onMessageReceived = { called.complete(true) } // Act + Assert - subscribing and sending before starting should throw - assertThrows { - runBlocking { - testEnvironment.beta.transport.subscribe( - testEnvironment.beta.bus.messages.getMessageInfo(AlphaEvent::class.java) - ) - } + try { + testEnvironment.beta.transport.subscribe( + testEnvironment.beta.bus.messages.getMessageInfo(AlphaEvent::class.java) + ) + fail("Expected PolyBusNotStartedError to be thrown") + } catch (e: PolyBusNotStartedError) { + // Expected - exception was thrown as expected } transaction.add(AlphaEvent().apply { name = "Test" }) - assertThrows { runBlocking { transaction.commit() } } + try { + transaction.commit() + fail("Expected PolyBusNotStartedError to be thrown") + } catch (e: PolyBusNotStartedError) { + // Expected - exception was thrown as expected + } // Assert assertFalse(called.isCompleted) From 1a59fb1e5906a6932dd3d1e76cdc5d74a7e8898b Mon Sep 17 00:00:00 2001 From: Cy Scott <3858469+CyAScott@users.noreply.github.com> Date: Mon, 25 May 2026 14:40:12 -0400 Subject: [PATCH 09/10] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/release-and-publish.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-and-publish.yml b/.github/workflows/release-and-publish.yml index 96ff40b..d91c415 100644 --- a/.github/workflows/release-and-publish.yml +++ b/.github/workflows/release-and-publish.yml @@ -130,8 +130,8 @@ jobs: - name: Build Kotlin package working-directory: src/kotlin run: | - gradle clean build - gradle coverage + ./gradlew clean build + ./gradlew coverage # Upload build artifacts - name: Upload TypeScript artifact From fb2cd039087dd76ba38ae276df970f8d099d4f9d Mon Sep 17 00:00:00 2001 From: Cy Scott Date: Mon, 25 May 2026 15:09:08 -0400 Subject: [PATCH 10/10] fix: cicd --- .github/workflows/release-and-publish.yml | 25 +++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release-and-publish.yml b/.github/workflows/release-and-publish.yml index d91c415..f9cde58 100644 --- a/.github/workflows/release-and-publish.yml +++ b/.github/workflows/release-and-publish.yml @@ -7,6 +7,10 @@ on: paths: - 'src/**' - '.github/workflows/release-and-publish.yml' + pull_request: + paths: + - 'src/**' + - '.github/workflows/release-and-publish.yml' permissions: contents: write @@ -14,9 +18,18 @@ permissions: id-token: write jobs: + pr-status: + name: PR Status + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - name: Skip release workflow on pull requests + run: echo "Release and publish only runs on pushes to main." + # Determine version using GitVersion determine-version: name: Determine Version + if: github.event_name == 'push' runs-on: ubuntu-latest outputs: version: ${{ steps.gitversion.outputs.semVer }} @@ -67,7 +80,7 @@ jobs: name: Build and Test All Packages runs-on: ubuntu-latest needs: determine-version - if: needs.determine-version.outputs.should_release == 'true' + if: github.event_name == 'push' && needs.determine-version.outputs.should_release == 'true' steps: - name: Checkout code uses: actions/checkout@v4 @@ -163,7 +176,7 @@ jobs: name: Publish to npm runs-on: ubuntu-latest needs: [determine-version, build-and-test] - if: needs.determine-version.outputs.should_release == 'true' + if: github.event_name == 'push' && needs.determine-version.outputs.should_release == 'true' permissions: id-token: write contents: read @@ -201,7 +214,7 @@ jobs: name: Publish to PyPI runs-on: ubuntu-latest needs: [determine-version, build-and-test] - if: needs.determine-version.outputs.should_release == 'true' + if: github.event_name == 'push' && needs.determine-version.outputs.should_release == 'true' permissions: id-token: write steps: @@ -237,7 +250,7 @@ jobs: name: Publish to NuGet runs-on: ubuntu-latest needs: [determine-version, build-and-test] - if: needs.determine-version.outputs.should_release == 'true' + if: github.event_name == 'push' && needs.determine-version.outputs.should_release == 'true' steps: - name: Checkout code uses: actions/checkout@v4 @@ -270,7 +283,7 @@ jobs: name: Publish Gradle Package runs-on: ubuntu-latest needs: [determine-version, build-and-test] - if: needs.determine-version.outputs.should_release == 'true' + if: github.event_name == 'push' && needs.determine-version.outputs.should_release == 'true' steps: - name: Checkout code uses: actions/checkout@v4 @@ -309,7 +322,7 @@ jobs: name: Publish Summary runs-on: ubuntu-latest needs: [determine-version, publish-npm, publish-pypi, publish-nuget, publish-gradle] - if: always() && needs.determine-version.outputs.should_release == 'true' + if: github.event_name == 'push' && always() && needs.determine-version.outputs.should_release == 'true' steps: - name: Summary run: |