commit d7bdb4f37887bdf44e75abe7a0654666c2a1565b Author: wenzuhuai Date: Mon Jan 12 18:29:52 2026 +0800 init diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..25fb0d9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.build +.git* +.swiftpm +Dockerfile +LICENSE \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/defect.yml b/.github/ISSUE_TEMPLATE/defect.yml new file mode 100644 index 0000000..4816b81 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/defect.yml @@ -0,0 +1,47 @@ +--- +name: Defect +description: Report a defect, such as a bug or regression. +labels: + - defect +body: + - type: textarea + id: observed + attributes: + label: Observed behavior + description: Describe the unexpected behavior or performance regression you are observing. + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + description: Describe the expected behavior or performance characteristics. + validations: + required: true + - type: textarea + id: versions + attributes: + label: Server and client version + description: |- + Provide the versions you were using when the detect was observed. + For the server, use `nats-server --version`, check the startup log output, or the image tag pulled from Docker. + For the CLI client, use `nats --version`. + For language-specific clients, check the version downloaded by the language dependency manager. + validations: + required: true + - type: textarea + id: environment + attributes: + label: Host environment + description: |- + Specify any relevant details about the host environment the server and/or client was running in, + such as operating system, CPU architecture, container runtime, etc. + validations: + required: false + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: Provide as many concrete steps to reproduce the defect. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/proposal.yml b/.github/ISSUE_TEMPLATE/proposal.yml new file mode 100644 index 0000000..7158383 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/proposal.yml @@ -0,0 +1,28 @@ +--- +name: Proposal +description: Propose an enhancement or new feature. +labels: + - proposal +body: + - type: textarea + id: change + attributes: + label: Proposed change + description: This could be a behavior change, enhanced API, or a new feature. + validations: + required: true + - type: textarea + id: usecase + attributes: + label: Use case + description: What is the use case or general motivation for this proposal? + validations: + required: true + - type: textarea + id: contribute + attributes: + label: Contribution + description: |- + Are you intending or interested in contributing code for this proposal if accepted? + validations: + required: false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1c4d163 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,40 @@ +name: ci + +on: + push: + branches: [ main, release/*, feature/* ] + pull_request: + branches: [ main, release/* ] + +jobs: + macos: + runs-on: macos-13 + steps: + - name: Checkout the code + uses: actions/checkout@v4 + - name: Install nats-server + run: curl --fail https://binaries.nats.dev/nats-io/nats-server/v2@latest | PREFIX='/usr/local/bin' sh + - name: Check nats-server version + run: nats-server -v + - name: List schemes + run: xcodebuild -list + - name: Build + run: xcodebuild build -scheme Nats -destination 'platform=macOS,arch=x86_64' + - name: Test + run: swift test + ios: + runs-on: macos-13 + steps: + - name: Checkout the code + uses: actions/checkout@v4 + - name: Build + run: xcodebuild build -scheme Nats -destination generic/platform=ios + check-linter: + runs-on: macos-13 + steps: + - name: Checkout the code + uses: actions/checkout@v4 + - name: Install swift-format + run: brew install swift-format + - name: Run check + run: swift-format lint --configuration .swift-format -r --strict Sources Tests diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e26797e --- /dev/null +++ b/.gitignore @@ -0,0 +1,72 @@ +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore +Nats.xcodeproj + +## Build generated +build/ +DerivedData/ + +## Various settings +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata/ + +## Other +*.moved-aside +*.xccheckout +*.xcscmblueprint + +## Obj-C/Swift specific +*.hmap +*.ipa +*.dSYM.zip +*.dSYM + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +# Package.pins +Package.resolved +.build/ + +# CocoaPods +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# +# Pods/ + +# Carthage +# +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the +# screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +/.swiftpm +.DS_Store +.vscode diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000..544e5db --- /dev/null +++ b/.swift-format @@ -0,0 +1,61 @@ +{ + "version": 1, + "fileScopedDeclarationPrivacy": { + "accessLevel": "private" + }, + "indentation": { + "spaces": 4 + }, + "lineLength": 100, + "maximumBlankLines": 1, + "noAssignmentInExpressions": { + "allowedFunctions": [ + "XCTAssertNoThrow" + ] + }, + "prioritizeKeepingFunctionOutputTogether": true, + "respectsExistingLineBreaks": true, + "rules": { + "AllPublicDeclarationsHaveDocumentation": false, + "AlwaysUseLiteralForEmptyCollectionInit": false, + "AlwaysUseLowerCamelCase": true, + "AmbiguousTrailingClosureOverload": true, + "BeginDocumentationCommentWithOneLineSummary": false, + "DoNotUseSemicolons": true, + "DontRepeatTypeInStaticProperties": true, + "FileScopedDeclarationPrivacy": true, + "FullyIndirectEnum": true, + "GroupNumericLiterals": true, + "IdentifiersMustBeASCII": true, + "NeverForceUnwrap": false, + "NeverUseForceTry": false, + "NeverUseImplicitlyUnwrappedOptionals": false, + "NoAccessLevelOnExtensionDeclaration": true, + "NoAssignmentInExpressions": true, + "NoBlockComments": true, + "NoCasesWithOnlyFallthrough": true, + "NoEmptyTrailingClosureParentheses": true, + "NoLabelsInCasePatterns": true, + "NoLeadingUnderscores": false, + "NoParensAroundConditions": true, + "NoPlaygroundLiterals": true, + "NoVoidReturnOnFunctionSignature": true, + "OmitExplicitReturns": false, + "OneCasePerLine": true, + "OneVariableDeclarationPerLine": true, + "OnlyOneTrailingClosureArgument": true, + "OrderedImports": true, + "ReplaceForEachWithForLoop": true, + "ReturnVoidInsteadOfEmptyTuple": true, + "TypeNamesShouldBeCapitalized": true, + "UseEarlyExits": false, + "UseLetInEveryBoundCaseVariable": true, + "UseShorthandTypeNames": true, + "UseSingleLinePropertyGetter": true, + "UseSynthesizedInitializer": true, + "UseTripleSlashForDocumentationComments": true, + "UseWhereClausesInForLoops": false, + "ValidateDocumentationComments": false + }, + "spacesAroundRangeFormationOperators": false +} \ No newline at end of file diff --git a/CODE-OF-CONDUCT.md b/CODE-OF-CONDUCT.md new file mode 100644 index 0000000..5828082 --- /dev/null +++ b/CODE-OF-CONDUCT.md @@ -0,0 +1,3 @@ +## Community Code of Conduct + +The NATS Swift follows the [CNCF Code of Conduct](https://github.com/cncf/foundation/blob/master/code-of-conduct.md). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..56eaffd --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,38 @@ +# Contributing + +Thanks for your interest in contributing! This document contains `nats-io/nats.swift` specific contributing details. If you are a first-time contributor, please refer to the general [NATS Contributor Guide](https://nats.io/contributing/) to get a comprehensive overview of contributing to the NATS project. + +## Getting started + +There are three general ways you can contribute to this repo: + +- Proposing an enhancement or new feature +- Reporting a bug or regression +- Contributing changes to the source code + +For the first two, refer to the [GitHub Issues](https://github.com/nats-io/nats.swift/issues/new/choose) which guides you through the available options along with the needed information to collect. + +## Contributing Changes + +_Prior to opening a pull request, it is recommended to open an issue first to ensure the maintainers can review intended changes. Exceptions to this rule include fixing non-functional source such as code comments, documentation or other supporting files._ + +Proposing source code changes is done through GitHub's standard pull request workflow. + +If your branch is a work-in-progress then please start by creating your pull requests as draft, by clicking the down-arrow next to the `Create pull request` button and instead selecting `Create draft pull request`. + +This will defer the automatic process of requesting a review from the NATS Swift team and significantly reduces noise until you are ready. Once you are happy with your PR, you can click the `Ready for review` button. + +### Guidelines + +A good pull request includes: + +- A high-level description of the changes, including links to any issues that are related by adding comments like `Resolves #NNN` to your description. See [Linking a Pull Request to an Issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue) for more information. +- An up-to-date parent commit. Please make sure you are pulling in the latest `main` branch and rebasing your work on top of it, i.e. `git rebase main`. +- Unit tests where appropriate. Bug fixes will benefit from the addition of regression tests. New features will not be accepted without suitable test coverage! +- No more commits than necessary. Sometimes having multiple commits is useful for telling a story or isolating changes from one another, but please squash down any unnecessary commits that may just be for clean-up, comments or small changes. +- No additional external dependencies that aren't absolutely essential. Please do everything you can to avoid pulling in additional libraries/dependencies as we will be very critical of these. + +## Get Help + +If you have questions about the contribution process, please start a [GitHub discussion](https://github.com/nats-io/nats.swift/discussions), join the [NATS Slack](https://slack.nats.io/), or send your question to the [NATS Google Group](https://groups.google.com/forum/#!forum/natsio). + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c700811 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2019 The NATS 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 + + http://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. diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..1f253cb --- /dev/null +++ b/Package.swift @@ -0,0 +1,68 @@ +// swift-tools-version:5.7 + +import PackageDescription + +let package = Package( + name: "nats-swift", + platforms: [ + .macOS(.v13), + .iOS(.v13), + ], + products: [ + .library(name: "Nats", targets: ["Nats"]), + .library(name: "JetStream", targets: ["JetStream"]), + .library(name: "NatsServer", targets: ["NatsServer"]) + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-nio.git", from: "2.0.0"), + .package(url: "https://github.com/apple/swift-log.git", from: "1.4.2"), + .package(url: "https://github.com/nats-io/nkeys.swift.git", from: "0.1.2"), + .package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.0.0"), + .package(url: "https://github.com/Jarema/swift-nuid.git", from: "0.2.0"), + ], + targets: [ + .target( + name: "Nats", + dependencies: [ + .product(name: "NIO", package: "swift-nio"), + .product(name: "NIOSSL", package: "swift-nio-ssl"), + .product(name: "Logging", package: "swift-log"), + .product(name: "NIOFoundationCompat", package: "swift-nio"), + .product(name: "NIOHTTP1", package: "swift-nio"), + .product(name: "NIOWebSocket", package: "swift-nio"), + .product(name: "NKeys", package: "nkeys.swift"), + .product(name: "Nuid", package: "swift-nuid"), + ]), + .target( + name: "JetStream", + dependencies: [ + "Nats", + .product(name: "Logging", package: "swift-log"), + ]), + .target( + name: "NatsServer", + dependencies: [ + .product(name: "Logging", package: "swift-log"), + ]), + + .testTarget( + name: "NatsTests", + dependencies: ["Nats", "NatsServer"], + resources: [ + .process("Integration/Resources") + ] + ), + .testTarget( + name: "JetStreamTests", + dependencies: ["Nats", "JetStream", "NatsServer"], + resources: [ + .process("Integration/Resources") + ] + ), + .executableTarget(name: "bench", dependencies: ["Nats"]), + .executableTarget(name: "Benchmark", dependencies: ["Nats"]), + .executableTarget(name: "BenchmarkPubSub", dependencies: ["Nats"]), + .executableTarget(name: "BenchmarkSub", dependencies: ["Nats"]), + .executableTarget(name: "Example", dependencies: ["Nats"]), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..f265aeb --- /dev/null +++ b/README.md @@ -0,0 +1,198 @@ +![NATS Swift Client](./Resources/Logo@256.png) + +[![License Apache 2](https://img.shields.io/badge/License-Apache2-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0) +[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fnats-io%2Fnats.swift%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/nats-io/nats.swift) +[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fnats-io%2Fnats.swift%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/nats-io/nats.swift) + + + + +# NATS Swift Client + +Welcome to the [Swift](https://www.swift.org) Client for [NATS](https://nats.io), +your gateway to asynchronous messaging in Swift applications. This client library +is designed to provide Swift developers with a seamless interface to NATS +messaging, enabling swift and efficient communication across distributed systems. + +## Features + +Currently, the client supports **Core NATS** with auth, TLS, lame duck mode and more. + +JetStream, KV, Object Store, Service API are on the roadmap. + +## Support + +Join the [#swift](https://natsio.slack.com/channels/swift) channel on nats.io Slack. +We'll do our best to help quickly. You can also just drop by and say hello. We're looking forward to developing the community. + +## Installation via Swift Package Manager + +Include this package as a dependency in your project's `Package.swift` file and add the package name to your target as shown in the following example: + +```swift +// swift-tools-version:5.7 + +import PackageDescription + +let package = Package( + name: "YourApp", + products: [ + .executable(name: "YourApp", targets: ["YourApp"]), + ], + dependencies: [ + .package(name: "Nats", url: "https://github.com/nats-io/nats.swift.git", from: "0.1") + ], + targets: [ + .target(name: "YourApp", dependencies: ["Nats"]), + ] +) + +``` + +### Xcode Package Dependencies + +Open the project inspector in Xcode and select your project. It is important to select the **project** and not a target! +Click on the third tab `Package Dependencies` and add the git url `https://github.com/nats-io/nats.swift.git` by selecting the little `+`-sign at the end of the package list. + +## Basic Usage + +Here is a quick start example to see everything at a glance: + +```swift +import Nats + +// create the client +let nats = NatsClientOptions().url(URL(string: "nats://localhost:4222")!).build() + +// connect to the server +try await nats.connect() + +// subscribe to a subject +let subscription = try await nats.subscribe(subject: "events.>") + +// publish a message +try await nats.publish("my event".data(using: .utf8)!, subject: "events.example") + +// receive published messages +for await msg in subscriptions { + print( "Received: \(String(data:msg.payload!, encoding: .utf8)!)") +} + ``` + +### Connecting to a NATS Server + +The first step is establishing a connection to a NATS server. +This example demonstrates how to connect to a NATS server using the default settings, which assume the server is +running locally on the default port (4222). You can also customize your connection by specifying additional options: + +```swift +let nats = NatsClientOptions() + .url(URL(string: "nats://localhost:4222")!) + .build() + +try await nats.connect() +``` + +### Publishing Messages + +Once you've established a connection to a NATS server, the next step is to publish messages. +Publishing messages to a subject allows any subscribed clients to receive these messages +asynchronously. This example shows how to publish a simple text message to a specific subject. + +```swift +let data = "message text".data(using: .utf8)! +try await nats.publish(data, subject: "foo.msg") +``` + +In more complex scenarios, you might want to include additional metadata with your messages in +the form of headers. Headers allow you to pass key-value pairs along with your message, providing +extra context or instructions for the subscriber. This example shows how to publish a +message with headers: + +```swift +let data = "message text".data(using: .utf8)! + +var headers = NatsHeaderMap() +headers.append(try! NatsHeaderName("X-Example"), NatsHeaderValue("example value")) + +try await nats.publish(data, subject: "foo.msg.1", headers: headers) +``` + +### Subscribing to Subjects + +After establishing a connection and publishing messages to a NATS server, the next crucial step is +subscribing to subjects. Subscriptions enable your client to listen for messages published to +specific subjects, facilitating asynchronous communication patterns. This example +will guide you through creating a subscription to a subject, allowing your application to process +incoming messages as they are received. + +```swift +let subscription = try await nats.subscribe(subject: "foo.>") + +for try await msg in subscription { + + if msg.subject == "foo.done" { + break + } + + if let payload = msg.payload { + print("received \(msg.subject): \(String(data: payload, encoding: .utf8) ?? "")") + } + + if let headers = msg.headers { + if let headerValue = headers.get(try! NatsHeaderName("X-Example")) { + print(" header: X-Example: \(headerValue.description)") + } + } +} +``` + +Notice that the subject `foo.>` uses a special wildcard syntax, allowing for subscription +to a hierarchy of subjects. For more detailed information, please refer to the [NATS documentation +on _Subject-Based Messaging_](https://docs.nats.io/nats-concepts/subjects). + +### Setting Log Levels + +The default log level is `.info`. You can set it to see more or less verbose messages. Possible values are `.debug`, `.info`, `.error` or `.critical`. + +```swift +// TODO +``` + +### Events + + You can also monitor when your app connects, disconnects, or encounters an error using events: + +```swift +let nats = NatsClientOptions() + .url(URL(string: "nats://localhost:4222")!) + .build() + +nats.on(.connected) { event in + print("event: connected") +} +``` + +### AppDelegate or SceneDelegate Integration + +In order to make sure the connection is managed properly in your +AppDelegate.swift or SceneDelegate.swift, integrate the NatsClient connection +management as follows: + +```swift +func sceneDidBecomeActive(_ scene: UIScene) { + Task { + try await self.natsClient.resume() + } +} + +func sceneWillResignActive(_ scene: UIScene) { + Task { + try await self.natsClient.suspend() + } +} +``` + +## Attribution + +This library is based on excellent work in https://github.com/aus-der-Technik/SwiftyNats diff --git a/Resources/Logo.afdesign b/Resources/Logo.afdesign new file mode 100644 index 0000000..62e5f51 Binary files /dev/null and b/Resources/Logo.afdesign differ diff --git a/Resources/Logo@256.png b/Resources/Logo@256.png new file mode 100644 index 0000000..7aad82f Binary files /dev/null and b/Resources/Logo@256.png differ diff --git a/Sources/Benchmark/main.swift b/Sources/Benchmark/main.swift new file mode 100644 index 0000000..1c657af --- /dev/null +++ b/Sources/Benchmark/main.swift @@ -0,0 +1,40 @@ +// Copyright 2024 The NATS 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 +// +// http://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. + +import Foundation +import Nats + +let nats = NatsClientOptions() + .url(URL(string: "nats://localhost:4222")!) + .build() +print("Connecting...") +try! await nats.connect() +print("Connected!") + +let data = "foo".data(using: .utf8)! +// Warmup +print("Warming up...") +for _ in 0..<10_000 { + try! await nats.publish(data, subject: "foo") +} +print("Starting benchmark...") +let now = DispatchTime.now() +let numMsgs = 1_000_000 +for _ in 0..") + +let loop = Task { + print("starting message loop...") + + for try await msg in sub { + + if msg.subject == "foo.done" { + break + } + + if let payload = msg.payload { + print("received \(msg.subject): \(String(data: payload, encoding: .utf8) ?? "")") + } + + if let headers = msg.headers { + if let headerValue = headers.get(try! NatsHeaderName("X-Example")) { + print(" header: X-Example: \(headerValue.description)") + } + } + } + + print("message loop done...") +} + +print("publishing data...") +for i in 1...3 { + var headers = NatsHeaderMap() + headers.append(try! NatsHeaderName("X-Example"), NatsHeaderValue("example value")) + + if let data = "data\(i)".data(using: .utf8) { + try await nats.publish(data, subject: "foo.\(i)", headers: headers) + } +} + +print("signalling done...") +try await nats.publish(Data(), subject: "foo.done") + +try await loop.value + +print("closing...") +try await nats.close() + +print("bye") diff --git a/Sources/JetStream/Consumer+Pull.swift b/Sources/JetStream/Consumer+Pull.swift new file mode 100644 index 0000000..a01c4c2 --- /dev/null +++ b/Sources/JetStream/Consumer+Pull.swift @@ -0,0 +1,220 @@ +// Copyright 2024 The NATS 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 +// +// http://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. + +import Foundation +import Nats +import Nuid + +/// Extension to ``Consumer`` adding pull consumer capabilities. +extension Consumer { + + /// Retrieves up to a provided number of messages from a stream. + /// This method will send a single request and deliver requested messages unless time out is met earlier. + /// + /// - Parameters: + /// - batch: maximum number of messages to be retrieved + /// - expires: timeout of a pull request + /// - idleHeartbeat: interval in which server should send heartbeat messages (if no user messages are available). + /// + /// - Returns: ``FetchResult`` which implements ``AsyncSequence`` allowing iteration over messages. + /// + /// - Throws: + /// - ``JetStreamError/FetchError`` if there was an error while fetching messages + public func fetch( + batch: Int, expires: TimeInterval = 30, idleHeartbeat: TimeInterval? = nil + ) async throws -> FetchResult { + var request: PullRequest + if let idleHeartbeat { + request = PullRequest( + batch: batch, expires: NanoTimeInterval(expires), + heartbeat: NanoTimeInterval(idleHeartbeat)) + } else { + request = PullRequest(batch: batch, expires: NanoTimeInterval(expires)) + } + + let subject = ctx.apiSubject("CONSUMER.MSG.NEXT.\(info.stream).\(info.name)") + let inbox = ctx.client.newInbox() + let sub = try await ctx.client.subscribe(subject: inbox) + try await self.ctx.client.publish( + JSONEncoder().encode(request), subject: subject, reply: inbox) + return FetchResult(ctx: ctx, sub: sub, idleHeartbeat: idleHeartbeat, batch: batch) + } +} + +/// Used to iterate over results of ``Consumer/fetch(batch:expires:idleHeartbeat:)`` +public class FetchResult: AsyncSequence { + public typealias Element = JetStreamMessage + public typealias AsyncIterator = FetchIterator + + private let ctx: JetStreamContext + private let sub: NatsSubscription + private let idleHeartbeat: TimeInterval? + private let batch: Int + + init(ctx: JetStreamContext, sub: NatsSubscription, idleHeartbeat: TimeInterval?, batch: Int) { + self.ctx = ctx + self.sub = sub + self.idleHeartbeat = idleHeartbeat + self.batch = batch + } + + public func makeAsyncIterator() -> FetchIterator { + return FetchIterator( + ctx: ctx, + sub: self.sub, idleHeartbeat: self.idleHeartbeat, remainingMessages: self.batch) + } + + public struct FetchIterator: AsyncIteratorProtocol { + private let ctx: JetStreamContext + private let sub: NatsSubscription + private let idleHeartbeat: TimeInterval? + private var remainingMessages: Int + private var subIterator: NatsSubscription.AsyncIterator + + init( + ctx: JetStreamContext, sub: NatsSubscription, idleHeartbeat: TimeInterval?, + remainingMessages: Int + ) { + self.ctx = ctx + self.sub = sub + self.idleHeartbeat = idleHeartbeat + self.remainingMessages = remainingMessages + self.subIterator = sub.makeAsyncIterator() + } + + public mutating func next() async throws -> JetStreamMessage? { + if remainingMessages <= 0 { + try await sub.unsubscribe() + return nil + } + + while true { + let message: NatsMessage? + + if let idleHeartbeat = idleHeartbeat { + let timeout = idleHeartbeat * 2 + message = try await nextWithTimeout(timeout, subIterator) + } else { + message = try await subIterator.next() + } + + guard let message else { + // the subscription has ended + try await sub.unsubscribe() + return nil + } + + let status = message.status ?? .ok + + switch status { + case .timeout: + try await sub.unsubscribe() + return nil + case .idleHeartbeat: + // in case of idle heartbeat error, we want to + // wait for next message on subscription + continue + case .notFound: + try await sub.unsubscribe() + return nil + case .ok: + remainingMessages -= 1 + return JetStreamMessage(message: message, client: ctx.client) + case .badRequest: + try await sub.unsubscribe() + throw JetStreamError.FetchError.badRequest + case .noResponders: + try await sub.unsubscribe() + throw JetStreamError.FetchError.noResponders + case .requestTerminated: + try await sub.unsubscribe() + guard let description = message.description else { + throw JetStreamError.FetchError.invalidResponse + } + + let descLower = description.lowercased() + if descLower.contains("message size exceeds maxbytes") { + return nil + } else if descLower.contains("leadership changed") { + throw JetStreamError.FetchError.leadershipChanged + } else if descLower.contains("consumer deleted") { + throw JetStreamError.FetchError.consumerDeleted + } else if descLower.contains("consumer is push based") { + throw JetStreamError.FetchError.consumerIsPush + } + default: + throw JetStreamError.FetchError.unknownStatus(status, message.description) + } + + if remainingMessages == 0 { + try await sub.unsubscribe() + } + + } + } + + func nextWithTimeout( + _ timeout: TimeInterval, _ subIterator: NatsSubscription.AsyncIterator + ) async throws -> NatsMessage? { + try await withThrowingTaskGroup(of: NatsMessage?.self) { group in + group.addTask { + return try await subIterator.next() + } + group.addTask { + try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + try await sub.unsubscribe() + return nil + } + defer { + group.cancelAll() + } + for try await result in group { + if let msg = result { + return msg + } else { + throw JetStreamError.FetchError.noHeartbeatReceived + } + } + // this should not be reachable + throw JetStreamError.FetchError.noHeartbeatReceived + } + } + } +} + +internal struct PullRequest: Codable { + let batch: Int + let expires: NanoTimeInterval + let maxBytes: Int? + let noWait: Bool? + let heartbeat: NanoTimeInterval? + + internal init( + batch: Int, expires: NanoTimeInterval, maxBytes: Int? = nil, noWait: Bool? = nil, + heartbeat: NanoTimeInterval? = nil + ) { + self.batch = batch + self.expires = expires + self.maxBytes = maxBytes + self.noWait = noWait + self.heartbeat = heartbeat + } + + enum CodingKeys: String, CodingKey { + case batch + case expires + case maxBytes = "max_bytes" + case noWait = "no_wait" + case heartbeat = "idle_heartbeat" + } +} diff --git a/Sources/JetStream/Consumer.swift b/Sources/JetStream/Consumer.swift new file mode 100644 index 0000000..3e13bc5 --- /dev/null +++ b/Sources/JetStream/Consumer.swift @@ -0,0 +1,379 @@ +// Copyright 2024 The NATS 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 +// +// http://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. + +import CryptoKit +import Foundation +import Nuid + +public class Consumer { + + private static var rdigits: [UInt8] = Array( + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz".utf8) + + /// Contains information about the consumer. + /// Note that this may be out of date and reading it does not query the server. + /// For up-to-date stream info use ``Consumer/info()`` + public internal(set) var info: ConsumerInfo + internal let ctx: JetStreamContext + + init(ctx: JetStreamContext, info: ConsumerInfo) { + self.ctx = ctx + self.info = info + } + + /// Retrieves information about the consumer + /// This also refreshes ``Consumer/info``. + /// + /// - Returns ``ConsumerInfo`` from the server. + /// + /// > **Throws:** + /// > - ``JetStreamRequestError`` if the request was unsuccessful. + /// > - ``JetStreamError`` if the server responded with an API error. + public func info() async throws -> ConsumerInfo { + let subj = "CONSUMER.INFO.\(info.stream).\(info.name)" + let info: Response = try await ctx.request(subj) + switch info { + case .success(let info): + self.info = info + return info + case .error(let apiResponse): + throw apiResponse.error + } + } + + internal static func validate(name: String) throws { + guard !name.isEmpty else { + throw JetStreamError.StreamError.nameRequired + } + + let invalidChars = CharacterSet(charactersIn: ">*. /\\") + if name.rangeOfCharacter(from: invalidChars) != nil { + throw JetStreamError.StreamError.invalidStreamName(name) + } + } + + internal static func generateConsumerName() -> String { + let name = nextNuid() + + let hash = SHA256.hash(data: Data(name.utf8)) + let hashData = Data(hash) + + // Convert the first 8 bytes of the hash to the required format. + let base: UInt8 = 36 + + var result = [UInt8]() + for i in 0..<8 { + let index = Int(hashData[i] % base) + result.append(Consumer.rdigits[index]) + } + + // Convert the result array to a string and return it. + return String(bytes: result, encoding: .utf8)! + } +} + +/// `ConsumerInfo` is the detailed information about a JetStream consumer. +public struct ConsumerInfo: Codable { + /// The name of the stream that the consumer is bound to. + public let stream: String + + /// The unique identifier for the consumer. + public let name: String + + /// The timestamp when the consumer was created. + public let created: String + + /// The configuration settings of the consumer, set when creating or updating the consumer. + public let config: ConsumerConfig + + /// Information about the most recently delivered message, including its sequence numbers and timestamp. + public let delivered: SequenceInfo + + /// Indicates the message before the first unacknowledged message. + public let ackFloor: SequenceInfo + + /// The number of messages that have been delivered but not yet acknowledged. + public let numAckPending: Int + + /// The number of messages that have been redelivered and not yet acknowledged. + public let numRedelivered: Int + + /// The count of active pull requests (relevant for pull-based consumers). + public let numWaiting: Int + + /// The number of messages that match the consumer's filter but have not been delivered yet. + public let numPending: UInt64 + + /// Information about the cluster to which this consumer belongs (if applicable). + public let cluster: ClusterInfo? + + /// Indicates whether at least one subscription exists for the delivery subject of this consumer (only for push-based consumers). + public let pushBound: Bool? + + /// The timestamp indicating when this information was gathered by the server. + public let timeStamp: String + + enum CodingKeys: String, CodingKey { + case stream = "stream_name" + case name + case created + case config + case delivered + case ackFloor = "ack_floor" + case numAckPending = "num_ack_pending" + case numRedelivered = "num_redelivered" + case numWaiting = "num_waiting" + case numPending = "num_pending" + case cluster + case pushBound = "push_bound" + case timeStamp = "ts" + } +} + +/// `ConsumerConfig` is the configuration of a JetStream consumer. +public struct ConsumerConfig: Codable, Equatable { + /// Optional name for the consumer. + public var name: String? + + /// Optional durable name for the consumer. + public var durable: String? + + /// Optional description of the consumer. + public var description: String? + + /// Defines from which point to start delivering messages from the stream. + public var deliverPolicy: DeliverPolicy + + /// Optional sequence number from which to start message delivery. + public var optStartSeq: UInt64? + + /// Optional time from which to start message delivery. + public var optStartTime: String? + + /// Defines the acknowledgment policy for the consumer. + public var ackPolicy: AckPolicy + + /// Defines how long the server will wait for an acknowledgment before resending a message. + public var ackWait: NanoTimeInterval? + + /// Defines the maximum number of delivery attempts for a message. + public var maxDeliver: Int? + + /// Specifies the optional back-off intervals for retrying message delivery after a failed acknowledgment. + public var backOff: [NanoTimeInterval]? + + /// Can be used to filter messages delivered from the stream by given subject. + /// It is exclusive with ``ConsumerConfig/filterSubjects`` + public var filterSubject: String? + + /// Can be used to filter messages delivered from the stream by given subjects. + /// It is exclusive with ``ConsumerConfig/filterSubject`` + public var filterSubjects: [String]? + + /// Defines the rate at which messages are sent to the consumer. + public var replayPolicy: ReplayPolicy + + /// Specifies an optional maximum rate of message delivery in bits per second. + public var rateLimit: UInt64? + + /// Optional frequency for sampling acknowledgments for observability. + public var sampleFrequency: String? + + /// Maximum number of pull requests waiting to be fulfilled. + public var maxWaiting: Int? + + /// Maximum number of outstanding unacknowledged messages. + public var maxAckPending: Int? + + /// Indicates whether only headers of messages should be sent (and no payload). + public var headersOnly: Bool? + + /// Optional maximum batch size a single pull request can make. + public var maxRequestBatch: Int? + + /// Maximum duration a single pull request will wait for messages to be available to pull. + public var maxRequestExpires: NanoTimeInterval? + + /// Optional maximum total bytes that can be requested in a given batch. + public var maxRequestMaxBytes: Int? + + /// Duration which instructs the server to clean up the consumer if it has been inactive for the specified duration. + public var inactiveThreshold: NanoTimeInterval? + + /// Number of replicas for the consumer's state. + public var replicas: Int + + /// Flag to force the consumer to use memory storage rather than inherit the storage type from the stream. + public var memoryStorage: Bool? + + /// A set of application-defined key-value pairs for associating metadata on the consumer. + public var metadata: [String: String]? + + public init( + name: String? = nil, + durable: String? = nil, + description: String? = nil, + deliverPolicy: DeliverPolicy = .all, + optStartSeq: UInt64? = nil, + optStartTime: String? = nil, + ackPolicy: AckPolicy = .explicit, + ackWait: NanoTimeInterval? = nil, + maxDeliver: Int? = nil, + backOff: [NanoTimeInterval]? = nil, + filterSubject: String? = nil, + filterSubjects: [String]? = nil, + replayPolicy: ReplayPolicy = .instant, + rateLimit: UInt64? = nil, + sampleFrequency: String? = nil, + maxWaiting: Int? = nil, + maxAckPending: Int? = nil, + headersOnly: Bool? = nil, + maxRequestBatch: Int? = nil, + maxRequestExpires: NanoTimeInterval? = nil, + maxRequestMaxBytes: Int? = nil, + inactiveThreshold: NanoTimeInterval? = nil, + replicas: Int = 1, + memoryStorage: Bool? = nil, + metadata: [String: String]? = nil + ) { + self.name = name + self.durable = durable + self.description = description + self.deliverPolicy = deliverPolicy + self.optStartSeq = optStartSeq + self.optStartTime = optStartTime + self.ackPolicy = ackPolicy + self.ackWait = ackWait + self.maxDeliver = maxDeliver + self.backOff = backOff + self.filterSubject = filterSubject + self.replayPolicy = replayPolicy + self.rateLimit = rateLimit + self.sampleFrequency = sampleFrequency + self.maxWaiting = maxWaiting + self.maxAckPending = maxAckPending + self.headersOnly = headersOnly + self.maxRequestBatch = maxRequestBatch + self.maxRequestExpires = maxRequestExpires + self.maxRequestMaxBytes = maxRequestMaxBytes + self.inactiveThreshold = inactiveThreshold + self.replicas = replicas + self.memoryStorage = memoryStorage + self.filterSubjects = filterSubjects + self.metadata = metadata + } + + enum CodingKeys: String, CodingKey { + case name + case durable = "durable_name" + case description + case deliverPolicy = "deliver_policy" + case optStartSeq = "opt_start_seq" + case optStartTime = "opt_start_time" + case ackPolicy = "ack_policy" + case ackWait = "ack_wait" + case maxDeliver = "max_deliver" + case backOff = "backoff" + case filterSubject = "filter_subject" + case replayPolicy = "replay_policy" + case rateLimit = "rate_limit_bps" + case sampleFrequency = "sample_freq" + case maxWaiting = "max_waiting" + case maxAckPending = "max_ack_pending" + case headersOnly = "headers_only" + case maxRequestBatch = "max_batch" + case maxRequestExpires = "max_expires" + case maxRequestMaxBytes = "max_bytes" + case inactiveThreshold = "inactive_threshold" + case replicas = "num_replicas" + case memoryStorage = "mem_storage" + case filterSubjects = "filter_subjects" + case metadata + } +} + +/// `SequenceInfo` has both the consumer and the stream sequence and last activity. +public struct SequenceInfo: Codable, Equatable { + /// Consumer sequence number. + public let consumer: UInt64 + + /// Stream sequence number. + public let stream: UInt64 + + /// Last activity timestamp. + public let last: String? + + enum CodingKeys: String, CodingKey { + case consumer = "consumer_seq" + case stream = "stream_seq" + case last = "last_active" + } +} + +/// `DeliverPolicy` determines from which point to start delivering messages. +public enum DeliverPolicy: String, Codable { + /// DeliverAllPolicy starts delivering messages from the very beginning of stream. This is the default. + case all + + /// DeliverLastPolicy will start the consumer with the last received. + case last + + /// DeliverNewPolicy will only deliver new messages that are sent after consumer is created. + case new + + /// DeliverByStartSequencePolicy will deliver messages starting from a sequence configured with OptStartSeq in ConsumerConfig. + case byStartSequence = "by_start_sequence" + + /// DeliverByStartTimePolicy will deliver messages starting from a given configured with OptStartTime in ConsumerConfig. + case byStartTime = "by_start_time" + + /// DeliverLastPerSubjectPolicy will start the consumer with the last for all subjects received. + case lastPerSubject = "last_per_subject" +} + +/// `AckPolicy` determines how the consumer should acknowledge delivered messages. +public enum AckPolicy: String, Codable { + /// AckNonePolicy requires no acks for delivered messages./ + case none + + /// AckAllPolicy when acking a sequence number, this implicitly acks sequences below this one as well. + case all + + /// AckExplicitPolicy requires ack or nack for all messages. + case explicit +} + +/// `ReplayPolicy` determines how the consumer should replay messages it already has queued in the stream. +public enum ReplayPolicy: String, Codable { + /// ReplayInstantPolicy will replay messages as fast as possible./ + case instant + + /// ReplayOriginalPolicy will maintain the same timing as the messages received. + case original +} + +internal struct CreateConsumerRequest: Codable { + internal let stream: String + internal let config: ConsumerConfig + internal let action: String? + + enum CodingKeys: String, CodingKey { + case stream = "stream_name" + case config + case action + } +} + +struct ConsumerDeleteResponse: Codable { + let success: Bool +} diff --git a/Sources/JetStream/JetStreamContext+Consumer.swift b/Sources/JetStream/JetStreamContext+Consumer.swift new file mode 100644 index 0000000..ebad426 --- /dev/null +++ b/Sources/JetStream/JetStreamContext+Consumer.swift @@ -0,0 +1,322 @@ +// Copyright 2024 The NATS 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 +// +// http://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. + +import Foundation + +extension JetStreamContext { + + /// Creates a consumer with the specified configuration. + /// + /// - Parameters: + /// - stream: name of the stream the consumer will be created on + /// - cfg: consumer config + /// + /// - Returns: ``Consumer`` object containing ``ConsumerConfig`` and exposing operations on the consumer + /// + /// > **Throws:** + /// > - ``JetStreamError/ConsumerError``: if there was am error creating the consumer. There are several errors which may occur, most common being: + /// > - ``JetStreamError/ConsumerError/invalidConfig(_:)``: if the provided configuration is not valid. + /// > - ``JetStreamError/ConsumerError/consumerNameExist(_:)``: if attempting to overwrite an existing consumer (with different configuration) + /// > - ``JetStreamError/ConsumerError/maximumConsumersLimit(_:)``: if a max number of consumers (specified on stream/account level) has been reached. + /// > - ``JetStreamError/RequestError``: if the request fails if e.g. JetStream is not enabled. + /// > - ``JetStreamError/APIError``: if there was a different API error returned from JetStream. + public func createConsumer(stream: String, cfg: ConsumerConfig) async throws -> Consumer { + try Stream.validate(name: stream) + return try await upsertConsumer(stream: stream, cfg: cfg, action: "create") + } + + /// Updates an existing consumer using specified config. + /// + /// - Parameters: + /// - stream: name of the stream the consumer will be updated on + /// - cfg: consumer config + /// + /// - Returns: ``Consumer`` object containing ``ConsumerConfig`` and exposing operations on the consumer + /// + /// > **Throws:** + /// > - ``JetStreamError/ConsumerError``: if there was am error updating the consumer. There are several errors which may occur, most common being: + /// > - ``JetStreamError/ConsumerError/invalidConfig(_:)``: if the provided configuration is not valid or atteppting to update an illegal property + /// > - ``JetStreamError/ConsumerError/consumerDoesNotExist(_:)``: if attempting to update a non-existing consumer + /// > - ``JetStreamError/RequestError``: if the request fails if e.g. JetStream is not enabled. + /// > - ``JetStreamError/APIError``: if there was a different API error returned from JetStream. + public func updateConsumer(stream: String, cfg: ConsumerConfig) async throws -> Consumer { + try Stream.validate(name: stream) + return try await upsertConsumer(stream: stream, cfg: cfg, action: "update") + } + + /// Creates a consumer with the specified configuration or updates an existing consumer. + /// + /// - Parameters: + /// - stream: name of the stream the consumer will be created on + /// - cfg: consumer config + /// + /// - Returns: ``Consumer`` object containing ``ConsumerConfig`` and exposing operations on the consumer + /// + /// > **Throws:** + /// > - ``JetStreamError/ConsumerError``: if there was am error creating or updatig the consumer. There are several errors which may occur, most common being: + /// > - ``JetStreamError/ConsumerError/invalidConfig(_:)``: if the provided configuration is not valid or atteppting to update an illegal property + /// > - ``JetStreamError/ConsumerError/maximumConsumersLimit(_:)``: if a max number of consumers (specified on stream/account level) has been reached. + /// > - ``JetStreamError/RequestError``: if the request fails if e.g. JetStream is not enabled. + /// > - ``JetStreamError/APIError``: if there was a different API error returned from JetStream. + public func createOrUpdateConsumer(stream: String, cfg: ConsumerConfig) async throws -> Consumer + { + try Stream.validate(name: stream) + return try await upsertConsumer(stream: stream, cfg: cfg) + } + + /// Retrieves a consumer with given name from a stream. + /// + /// - Parameters: + /// - stream: name of the stream the consumer is retrieved from + /// - name: name of the stream + /// + /// - Returns a ``Stream`` object containing ``StreamInfo`` and exposing operations on the stream or nil if stream with given name does not exist. + /// + /// > **Throws:** + /// > - ``JetStreamError/ConsumerError/consumerNotFound(_:)``: if the consumer with given name does not exist on a given stream. + /// > - ``JetStreamError/RequestError``: if the request fails if e.g. JetStream is not enabled. + /// > - ``JetStreamError/APIError``: if there was a different API error returned from JetStream. + public func getConsumer(stream: String, name: String) async throws -> Consumer? { + try Stream.validate(name: stream) + try Consumer.validate(name: name) + + let subj = "CONSUMER.INFO.\(stream).\(name)" + let info: Response = try await request(subj) + switch info { + case .success(let info): + return Consumer(ctx: self, info: info) + case .error(let apiResponse): + if apiResponse.error.errorCode == .consumerNotFound { + return nil + } + if let consumerError = JetStreamError.ConsumerError(from: apiResponse.error) { + throw consumerError + } + throw apiResponse.error + } + } + + /// Deletes a consumer from a stream. + /// + /// - Parameters: + /// - stream: name of the stream the consumer will be created on + /// - name: consumer name + /// + /// > **Throws:** + /// > - ``JetStreamError/ConsumerError/consumerNotFound(_:)``: if the consumer with given name does not exist on a given stream. + /// > - ``JetStreamError/RequestError``: if the request fails if e.g. JetStream is not enabled. + /// > - ``JetStreamError/APIError``: if there was a different API error returned from JetStream. + public func deleteConsumer(stream: String, name: String) async throws { + try Stream.validate(name: stream) + try Consumer.validate(name: name) + + let subject = "CONSUMER.DELETE.\(stream).\(name)" + let resp: Response = try await request(subject) + + switch resp { + case .success(_): + return + case .error(let apiResponse): + if let streamErr = JetStreamError.ConsumerError(from: apiResponse.error) { + throw streamErr + } + throw apiResponse.error + } + } + + internal func upsertConsumer( + stream: String, cfg: ConsumerConfig, action: String? = nil + ) async throws -> Consumer { + let consumerName = cfg.name ?? cfg.durable ?? Consumer.generateConsumerName() + + try Consumer.validate(name: consumerName) + + let createReq = CreateConsumerRequest(stream: stream, config: cfg, action: action) + let req = try! JSONEncoder().encode(createReq) + + var subject: String + if let filterSubject = cfg.filterSubject { + subject = "CONSUMER.CREATE.\(stream).\(consumerName).\(filterSubject)" + } else { + subject = "CONSUMER.CREATE.\(stream).\(consumerName)" + } + + let info: Response = try await request(subject, message: req) + + switch info { + case .success(let info): + return Consumer(ctx: self, info: info) + case .error(let apiResponse): + if let consumerError = JetStreamError.ConsumerError(from: apiResponse.error) { + throw consumerError + } + throw apiResponse.error + } + } + + /// Used to list consumer names. + /// + /// - Parameters: + /// - stream: the name of the strem to list the consumers from. + /// + /// - Returns ``Consumers`` which implements AsyncSequence allowing iteration over stream infos. + public func consumers(stream: String) async -> Consumers { + return Consumers(ctx: self, stream: stream) + } + + /// Used to list consumer names. + /// + /// - Parameters: + /// - stream: the name of the strem to list the consumers from. + /// + /// - Returns ``ConsumerNames`` which implements AsyncSequence allowing iteration over consumer names. + public func consumerNames(stream: String) async -> ConsumerNames { + return ConsumerNames(ctx: self, stream: stream) + } +} + +internal struct ConsumersPagedRequest: Codable { + let offset: Int +} + +/// Used to iterate over consumer names when using ``JetStreamContext/consumerNames(stream:)`` +public struct ConsumerNames: AsyncSequence { + public typealias Element = String + public typealias AsyncIterator = ConsumerNamesIterator + + private let ctx: JetStreamContext + private let stream: String + private var buffer: [String] + private var offset: Int + private var total: Int? + + private struct ConsumerNamesPage: Codable { + let total: Int + let consumers: [String]? + } + + init(ctx: JetStreamContext, stream: String) { + self.stream = stream + self.ctx = ctx + self.buffer = [] + self.offset = 0 + } + + public func makeAsyncIterator() -> ConsumerNamesIterator { + return ConsumerNamesIterator(seq: self) + } + + public mutating func next() async throws -> Element? { + if let consumer = buffer.first { + buffer.removeFirst() + return consumer + } + + if let total = self.total, self.offset >= total { + return nil + } + + // poll consumers + let request = ConsumersPagedRequest(offset: offset) + + let res: Response = try await ctx.request( + "CONSUMER.NAMES.\(self.stream)", message: JSONEncoder().encode(request)) + switch res { + case .success(let names): + guard let consumers = names.consumers else { + return nil + } + self.offset += consumers.count + self.total = names.total + buffer.append(contentsOf: consumers) + return try await self.next() + case .error(let err): + throw err.error + } + + } + + public struct ConsumerNamesIterator: AsyncIteratorProtocol { + var seq: ConsumerNames + + public mutating func next() async throws -> Element? { + try await seq.next() + } + } +} + +/// Used to iterate over consumers when listing consumer infos using ``JetStreamContext/consumers(stream:)`` +public struct Consumers: AsyncSequence { + public typealias Element = ConsumerInfo + public typealias AsyncIterator = ConsumersIterator + + private let ctx: JetStreamContext + private let stream: String + private var buffer: [ConsumerInfo] + private var offset: Int + private var total: Int? + + private struct ConsumersPage: Codable { + let total: Int + let consumers: [ConsumerInfo]? + } + + init(ctx: JetStreamContext, stream: String) { + self.stream = stream + self.ctx = ctx + self.buffer = [] + self.offset = 0 + } + + public func makeAsyncIterator() -> ConsumersIterator { + return ConsumersIterator(seq: self) + } + + public mutating func next() async throws -> Element? { + if let consumer = buffer.first { + buffer.removeFirst() + return consumer + } + + if let total = self.total, self.offset >= total { + return nil + } + + // poll consumers + let request = ConsumersPagedRequest(offset: offset) + + let res: Response = try await ctx.request( + "CONSUMER.LIST.\(self.stream)", message: JSONEncoder().encode(request)) + switch res { + case .success(let infos): + guard let consumers = infos.consumers else { + return nil + } + self.offset += consumers.count + self.total = infos.total + buffer.append(contentsOf: consumers) + return try await self.next() + case .error(let err): + throw err.error + } + + } + + public struct ConsumersIterator: AsyncIteratorProtocol { + var seq: Consumers + + public mutating func next() async throws -> Element? { + try await seq.next() + } + } +} diff --git a/Sources/JetStream/JetStreamContext+Stream.swift b/Sources/JetStream/JetStreamContext+Stream.swift new file mode 100644 index 0000000..377cd47 --- /dev/null +++ b/Sources/JetStream/JetStreamContext+Stream.swift @@ -0,0 +1,291 @@ +// Copyright 2024 The NATS 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 +// +// http://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. + +import Foundation + +/// Extension to `JetStreamContext` adding stream management functionalities. +extension JetStreamContext { + + /// Creates a stream with the specified configuration. + /// + /// - Parameter cfg: stream config + /// + /// - Returns: ``Stream`` object containing ``StreamInfo`` and exposing operations on the stream + /// + /// > **Throws:** + /// > - ``JetStreamError/StreamError``: if there was am error creating the stream. There are several errors which may occur, most common being: + /// > - ``JetStreamError/StreamError/nameRequired``: if the provided stream name is empty. + /// > - ``JetStreamError/StreamError/invalidStreamName(_:)``: if the provided stream name is not valid. + /// > - ``JetStreamError/StreamError/streamNameExist(_:)``: if a stream with provided name exists and has different configuration. + /// > - ``JetStreamError/StreamError/invalidConfig(_:)``: if the stream config is not valid. + /// > - ``JetStreamError/StreamError/maximumStreamsLimit(_:)``: if the maximum number of streams has been reached. + /// > - ``JetStreamError/RequestError``: if the request fails if e.g. JetStream is not enabled. + /// > - ``JetStreamError/APIError``: if there was a different API error returned from JetStream. + public func createStream(cfg: StreamConfig) async throws -> Stream { + try Stream.validate(name: cfg.name) + let req = try! JSONEncoder().encode(cfg) + let subj = "STREAM.CREATE.\(cfg.name)" + let info: Response = try await request(subj, message: req) + switch info { + case .success(let info): + return Stream(ctx: self, info: info) + case .error(let apiResponse): + if let streamErr = JetStreamError.StreamError(from: apiResponse.error) { + throw streamErr + } + throw apiResponse.error + } + } + + /// Retrieves a stream by its name. + /// + /// - Parameter name: name of the stream + /// + /// - Returns a ``Stream`` object containing ``StreamInfo`` and exposing operations on the stream or nil if stream with given name does not exist. + /// + /// > **Throws:** + /// > - ``JetStreamError/StreamError/nameRequired`` if the provided stream name is empty. + /// > - ``JetStreamError/StreamError/invalidStreamName(_:)`` if the provided stream name is not valid. + /// > - ``JetStreamError/RequestError`` if the request fails if e.g. JetStream is not enabled. + /// > - ``JetStreamError/APIError`` if there was a different JetStreamError returned from JetStream. + public func getStream(name: String) async throws -> Stream? { + try Stream.validate(name: name) + let subj = "STREAM.INFO.\(name)" + let info: Response = try await request(subj) + switch info { + case .success(let info): + return Stream(ctx: self, info: info) + case .error(let apiResponse): + if apiResponse.error.errorCode == .streamNotFound { + return nil + } + if let streamErr = JetStreamError.StreamError(from: apiResponse.error) { + throw streamErr + } + throw apiResponse.error + } + } + + /// Updates an existing stream with new configuration. + /// + /// - Parameter: cfg: stream config + /// + /// - Returns: ``Stream`` object containing ``StreamInfo`` and exposing operations on the stream + /// + /// > **Throws:** + /// > - ``JetStreamError/StreamError`` if there was am error updating the stream. + /// > There are several errors which may occur, most common being: + /// > - ``JetStreamError/StreamError/nameRequired`` if the provided stream name is empty. + /// > - ``JetStreamError/StreamError/invalidStreamName(_:)`` if the provided stream name is not valid. + /// > - ``JetStreamError/StreamError/streamNotFound(_:)`` if a stream with provided name exists and has different configuration. + /// > - ``JetStreamError/StreamError/invalidConfig(_:)`` if the stream config is not valid or user attempts to update non-updatable properties. + /// > - ``JetStreamError/RequestError`` if the request fails if e.g. JetStream is not enabled. + /// > - ``JetStreamError/APIError`` if there was a different API error returned from JetStream. + public func updateStream(cfg: StreamConfig) async throws -> Stream { + try Stream.validate(name: cfg.name) + let req = try! JSONEncoder().encode(cfg) + let subj = "STREAM.UPDATE.\(cfg.name)" + let info: Response = try await request(subj, message: req) + switch info { + case .success(let info): + return Stream(ctx: self, info: info) + case .error(let apiResponse): + if let streamErr = JetStreamError.StreamError(from: apiResponse.error) { + throw streamErr + } + throw apiResponse.error + } + } + + /// Deletes a stream by its name. + /// + /// - Parameter name: name of the stream to be deleted. + /// + /// > **Throws:** + /// > - ``JetStreamError/StreamError/nameRequired`` if the provided stream name is empty. + /// > - ``JetStreamError/StreamError/invalidStreamName(_:)`` if the provided stream name is not valid. + /// > - ``JetStreamError/RequestError`` if the request fails if e.g. JetStream is not enabled. + /// > - ``JetStreamError/APIError`` if there was a different JetStreamError returned from JetStream. + public func deleteStream(name: String) async throws { + try Stream.validate(name: name) + let subj = "STREAM.DELETE.\(name)" + let info: Response = try await request(subj) + switch info { + case .success(_): + return + case .error(let apiResponse): + if let streamErr = JetStreamError.StreamError(from: apiResponse.error) { + throw streamErr + } + throw apiResponse.error + } + } + + struct StreamDeleteResponse: Codable { + let success: Bool + } + + /// Used to list stream infos. + /// + /// - Returns ``Streams`` which implements AsyncSequence allowing iteration over streams. + /// + /// - Parameter subject: if provided will be used to filter out returned streams + public func streams(subject: String? = nil) async -> Streams { + return Streams(ctx: self, subject: subject) + } + + /// Used to list stream names. + /// + /// - Returns ``StreamNames`` which implements AsyncSequence allowing iteration over stream names. + /// + /// - Parameter subject: if provided will be used to filter out returned stream names + public func streamNames(subject: String? = nil) async -> StreamNames { + return StreamNames(ctx: self, subject: subject) + } +} + +internal struct StreamsPagedRequest: Codable { + let offset: Int + let subject: String? +} + +/// Used to iterate over streams when listing stream infos using ``JetStreamContext/streams(subject:)`` +public struct Streams: AsyncSequence { + public typealias Element = StreamInfo + public typealias AsyncIterator = StreamsIterator + + private let ctx: JetStreamContext + private let subject: String? + private var buffer: [StreamInfo] + private var offset: Int + private var total: Int? + + private struct StreamsInfoPage: Codable { + let total: Int + let streams: [StreamInfo]? + } + + init(ctx: JetStreamContext, subject: String?) { + self.ctx = ctx + self.subject = subject + self.buffer = [] + self.offset = 0 + } + + public func makeAsyncIterator() -> StreamsIterator { + return StreamsIterator(seq: self) + } + + public mutating func next() async throws -> Element? { + if let stream = buffer.first { + buffer.removeFirst() + return stream + } + + if let total = self.total, self.offset >= total { + return nil + } + + // poll streams + let request = StreamsPagedRequest(offset: offset, subject: subject) + + let res: Response = try await ctx.request( + "STREAM.LIST", message: JSONEncoder().encode(request)) + switch res { + case .success(let infos): + guard let streams = infos.streams else { + return nil + } + self.offset += streams.count + self.total = infos.total + buffer.append(contentsOf: streams) + return try await self.next() + case .error(let err): + throw err.error + } + + } + + public struct StreamsIterator: AsyncIteratorProtocol { + var seq: Streams + + public mutating func next() async throws -> Element? { + try await seq.next() + } + } +} + +public struct StreamNames: AsyncSequence { + public typealias Element = String + public typealias AsyncIterator = StreamNamesIterator + + private let ctx: JetStreamContext + private let subject: String? + private var buffer: [String] + private var offset: Int + private var total: Int? + + private struct StreamNamesPage: Codable { + let total: Int + let streams: [String]? + } + + init(ctx: JetStreamContext, subject: String?) { + self.ctx = ctx + self.subject = subject + self.buffer = [] + self.offset = 0 + } + + public func makeAsyncIterator() -> StreamNamesIterator { + return StreamNamesIterator(seq: self) + } + + public mutating func next() async throws -> Element? { + if let stream = buffer.first { + buffer.removeFirst() + return stream + } + + if let total = self.total, self.offset >= total { + return nil + } + + // poll streams + let request = StreamsPagedRequest(offset: offset, subject: subject) + + let res: Response = try await ctx.request( + "STREAM.NAMES", message: JSONEncoder().encode(request)) + switch res { + case .success(let names): + guard let streams = names.streams else { + return nil + } + self.offset += streams.count + self.total = names.total + buffer.append(contentsOf: streams) + return try await self.next() + case .error(let err): + throw err.error + } + + } + + public struct StreamNamesIterator: AsyncIteratorProtocol { + var seq: StreamNames + + public mutating func next() async throws -> Element? { + try await seq.next() + } + } +} diff --git a/Sources/JetStream/JetStreamContext.swift b/Sources/JetStream/JetStreamContext.swift new file mode 100644 index 0000000..fd3ba78 --- /dev/null +++ b/Sources/JetStream/JetStreamContext.swift @@ -0,0 +1,234 @@ +// Copyright 2024 The NATS 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 +// +// http://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. + +import Combine +import Foundation +import Nats +import Nuid + +/// A context which can perform jetstream scoped requests. +public class JetStreamContext { + internal var client: NatsClient + private var prefix: String = "$JS.API" + private var timeout: TimeInterval = 5.0 + + /// Creates a JetStreamContext from ``NatsClient`` with optional custom prefix and timeout. + /// + /// - Parameters: + /// - client: NATS client connection. + /// - prefix: Used to comfigure a prefix for JetStream API requests. + /// - timeout: Used to configure a timeout for JetStream API operations. + public init(client: NatsClient, prefix: String = "$JS.API", timeout: TimeInterval = 5.0) { + self.client = client + self.prefix = prefix + self.timeout = timeout + } + + /// Creates a JetStreamContext from ``NatsClient`` with custom domain and timeout. + /// + /// - Parameters: + /// - client: NATS client connection. + /// - domain: Used to comfigure a domain for JetStream API requests. + /// - timeout: Used to configure a timeout for JetStream API operations. + public init(client: NatsClient, domain: String, timeout: TimeInterval = 5.0) { + self.client = client + self.prefix = "$JS.\(domain).API" + self.timeout = timeout + } + + /// Creates a JetStreamContext from ``NatsClient`` + /// + /// - Parameters: + /// - client: NATS client connection. + public init(client: NatsClient) { + self.client = client + } + + /// Sets a custom timeout for JetStream API requests. + public func setTimeout(_ timeout: TimeInterval) { + self.timeout = timeout + } +} + +extension JetStreamContext { + + /// Publishes a message on a stream subjec without waiting for acknowledgment from the server that the message has been successfully delivered. + /// + /// - Parameters: + /// - subject: Subject on which the message will be published. + /// - message: NATS message payload. + /// - headers:Optional set of message headers. + /// + /// - Returns: ``AckFuture`` allowing to await for the ack from the server. + public func publish( + _ subject: String, message: Data, headers: NatsHeaderMap? = nil + ) async throws -> AckFuture { + // TODO(pp): add stream header options (expected seq etc) + let inbox = client.newInbox() + let sub = try await self.client.subscribe(subject: inbox) + try await self.client.publish(message, subject: subject, reply: inbox, headers: headers) + return AckFuture(sub: sub, timeout: self.timeout) + } + + internal func request( + _ subject: String, message: Data? = nil + ) async throws -> Response { + let data = message ?? Data() + do { + let response = try await self.client.request( + data, subject: apiSubject(subject), timeout: self.timeout) + let decoder = JSONDecoder() + guard let payload = response.payload else { + throw JetStreamError.RequestError.emptyResponsePayload + } + return try decoder.decode(Response.self, from: payload) + } catch let err as NatsError.RequestError { + switch err { + case .noResponders: + throw JetStreamError.RequestError.noResponders + case .timeout: + throw JetStreamError.RequestError.timeout + case .permissionDenied: + throw JetStreamError.RequestError.permissionDenied(subject) + } + } + } + + internal func request(_ subject: String, message: Data? = nil) async throws -> NatsMessage { + let data = message ?? Data() + do { + return try await self.client.request( + data, subject: apiSubject(subject), timeout: self.timeout) + } catch let err as NatsError.RequestError { + switch err { + case .noResponders: + throw JetStreamError.RequestError.noResponders + case .timeout: + throw JetStreamError.RequestError.timeout + case .permissionDenied: + throw JetStreamError.RequestError.permissionDenied(subject) + } + } + } + + internal func apiSubject(_ subject: String) -> String { + return "\(self.prefix).\(subject)" + } +} + +public struct JetStreamAPIResponse: Codable { + public let type: String + public let error: JetStreamError.APIError +} + +/// Used to await for response from ``JetStreamContext/publish(_:message:headers:)`` +public struct AckFuture { + let sub: NatsSubscription + let timeout: TimeInterval + + /// Waits for an ACK from JetStream server. + /// + /// - Returns: Acknowledgement object returned by the server. + /// + /// > **Throws:** + /// > - ``JetStreamError/RequestError`` if the request timed out (client did not receive the ack in time) or + public func wait() async throws -> Ack { + let response = try await withThrowingTaskGroup( + of: NatsMessage?.self, + body: { group in + group.addTask { + return try await sub.makeAsyncIterator().next() + } + + // task for the timeout + group.addTask { + try await Task.sleep(nanoseconds: UInt64(self.timeout * 1_000_000_000)) + return nil + } + + for try await result in group { + // if the result is not empty, return it (or throw status error) + if let msg = result { + group.cancelAll() + return msg + } else { + group.cancelAll() + try await sub.unsubscribe() + // if result is empty, time out + throw JetStreamError.RequestError.timeout + } + } + + // this should not be reachable + throw NatsError.ClientError.internalError("error waiting for response") + }) + if response.status == StatusCode.noResponders { + throw JetStreamError.PublishError.streamNotFound + } + + let decoder = JSONDecoder() + guard let payload = response.payload else { + throw JetStreamError.RequestError.emptyResponsePayload + } + + let ack = try decoder.decode(Response.self, from: payload) + switch ack { + case .success(let ack): + return ack + case .error(let err): + if let publishErr = JetStreamError.PublishError(from: err.error) { + throw publishErr + } else { + throw err.error + } + } + + } +} + +public struct Ack: Codable { + public let stream: String + public let seq: UInt64 + public let domain: String? + public let duplicate: Bool + + // Custom CodingKeys to map JSON keys to Swift property names + enum CodingKeys: String, CodingKey { + case stream + case seq + case domain + case duplicate + } + + // Custom initializer from Decoder + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + // Decode `stream` and `seq` as they are required + stream = try container.decode(String.self, forKey: .stream) + seq = try container.decode(UInt64.self, forKey: .seq) + + // Decode `domain` as optional since it may not be present + domain = try container.decodeIfPresent(String.self, forKey: .domain) + + // Decode `duplicate` and provide a default value of `false` if not present + duplicate = try container.decodeIfPresent(Bool.self, forKey: .duplicate) ?? false + } +} + +/// contains info about the `JetStream` usage from the current account. +public struct AccountInfo: Codable { + public let memory: Int64 + public let storage: Int64 + public let streams: Int64 + public let consumers: Int64 +} diff --git a/Sources/JetStream/JetStreamError.swift b/Sources/JetStream/JetStreamError.swift new file mode 100644 index 0000000..00182a5 --- /dev/null +++ b/Sources/JetStream/JetStreamError.swift @@ -0,0 +1,863 @@ +// Copyright 2024 The NATS 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 +// +// http://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. + +import Foundation +import Nats + +public protocol JetStreamErrorProtocol: Error, CustomStringConvertible {} + +public enum JetStreamError { + public struct APIError: Codable, Error { + public var code: UInt + public var errorCode: ErrorCode + public var description: String + + enum CodingKeys: String, CodingKey { + case code = "code" + case errorCode = "err_code" + case description = "description" + } + } + + public enum RequestError: JetStreamErrorProtocol { + case noResponders + case timeout + case emptyResponsePayload + case permissionDenied(String) + + public var description: String { + switch self { + case .noResponders: + return "nats: no responders available for request" + case .timeout: + return "nats: request timed out" + case .emptyResponsePayload: + return "nats: empty response payload" + case .permissionDenied(let subject): + return "nats: permission denied on subject \(subject)" + } + } + } + + public enum MessageMetadataError: JetStreamErrorProtocol { + case noReplyInMessage + case invalidPrefix + case invalidTokenNum + case invalidTokenValue + + public var description: String { + switch self { + case .noReplyInMessage: + return "nats: did not fund reply subject in message" + case .invalidPrefix: + return "nats: invalid reply subject prefix" + case .invalidTokenNum: + return "nats: invalid token count" + case .invalidTokenValue: + return "nats: invalid token value" + } + } + } + + public enum FetchError: JetStreamErrorProtocol { + case noHeartbeatReceived + case consumerDeleted + case badRequest + case noResponders + case consumerIsPush + case invalidResponse + case leadershipChanged + case unknownStatus(StatusCode, String?) + + public var description: String { + switch self { + case .noHeartbeatReceived: + return "nats: no heartbeat received" + case .consumerDeleted: + return "nats: consumer deleted" + case .badRequest: + return "nats: bad request" + case .noResponders: + return "nats: no responders" + case .consumerIsPush: + return "nats: consumer is push based" + case .invalidResponse: + return "nats: no description in status response" + case .leadershipChanged: + return "nats: leadership changed" + case .unknownStatus(let status, let description): + if let description { + return "nats: unknown response status: \(status): \(description)" + } else { + return "nats: unknown response status: \(status)" + } + } + } + + } + + public enum AckError: JetStreamErrorProtocol { + case noReplyInMessage + + public var description: String { + switch self { + case .noReplyInMessage: + return "nats: did not fund reply subject in message" + } + } + } + + public enum StreamError: JetStreamErrorProtocol { + case nameRequired + case invalidStreamName(String) + case streamNotFound(APIError) + case streamNameExist(APIError) + case streamMessageExceedsMaximum(APIError) + case streamDelete(APIError) + case streamUpdate(APIError) + case streamInvalidExternalDeliverySubject(APIError) + case streamMirrorNotUpdatable(APIError) + case streamLimitsExceeded(APIError) + case invalidConfig(APIError) + case maximumStreamsLimit(APIError) + case streamSealed(APIError) + + public var description: String { + switch self { + case .nameRequired: + return "nats: stream name is required" + case .invalidStreamName(let name): + return "nats: invalid stream name: \(name)" + case .streamNotFound(let err), + .streamNameExist(let err), + .streamMessageExceedsMaximum(let err), + .streamDelete(let err), + .streamUpdate(let err), + .streamInvalidExternalDeliverySubject(let err), + .streamMirrorNotUpdatable(let err), + .streamLimitsExceeded(let err), + .invalidConfig(let err), + .maximumStreamsLimit(let err), + .streamSealed(let err): + return "nats: \(err.description)" + } + } + + internal init?(from err: APIError) { + switch err.errorCode { + case ErrorCode.streamNotFound: + self = .streamNotFound(err) + case ErrorCode.streamNameExist: + self = .streamNameExist(err) + case ErrorCode.streamMessageExceedsMaximum: + self = .streamMessageExceedsMaximum(err) + case ErrorCode.streamDelete: + self = .streamDelete(err) + case ErrorCode.streamUpdate: + self = .streamUpdate(err) + case ErrorCode.streamInvalidExternalDeliverySubject: + self = .streamInvalidExternalDeliverySubject(err) + case ErrorCode.streamMirrorNotUpdatable: + self = .streamMirrorNotUpdatable(err) + case ErrorCode.streamLimits: + self = .streamLimitsExceeded(err) + case ErrorCode.mirrorWithSources, + ErrorCode.streamSubjectOverlap, + ErrorCode.streamExternalDeletePrefixOverlaps, + ErrorCode.mirrorMaxMessageSizeTooBig, + ErrorCode.sourceMaxMessageSizeTooBig, + ErrorCode.streamInvalidConfig, + ErrorCode.mirrorWithSubjects, + ErrorCode.streamExternalApiOverlap, + ErrorCode.mirrorWithStartSequenceAndTime, + ErrorCode.mirrorWithSubjectFilters, + ErrorCode.streamReplicasNotSupported, + ErrorCode.streamReplicasNotUpdatable, + ErrorCode.streamMaxBytesRequired, + ErrorCode.streamMaxStreamBytesExceeded, + ErrorCode.streamNameContainsPathSeparators, + ErrorCode.replicasCountCannotBeNegative, + ErrorCode.sourceDuplicateDetected, + ErrorCode.sourceInvalidStreamName, + ErrorCode.mirrorInvalidStreamName, + ErrorCode.sourceMultipleFiltersNotAllowed, + ErrorCode.sourceInvalidSubjectFilter, + ErrorCode.sourceInvalidTransformDestination, + ErrorCode.sourceOverlappingSubjectFilters, + ErrorCode.streamExternalDeletePrefixOverlaps: + self = .invalidConfig(err) + case ErrorCode.maximumStreamsLimit: + self = .maximumStreamsLimit(err) + case ErrorCode.streamSealed: + self = .streamSealed(err) + default: + return nil + } + } + } + + public enum PublishError: JetStreamErrorProtocol { + case streamWrongLastSequence(APIError) + case streamWrongLastMessageId(APIError) + case streamNotMatch(APIError) + case streamNotFound + + public var description: String { + switch self { + case .streamWrongLastSequence(let err), + .streamWrongLastMessageId(let err), + .streamNotMatch(let err): + return "nats: \(err.description)" + case .streamNotFound: + return "nats: stream not found" + } + } + + internal init?(from err: APIError) { + switch err.errorCode { + case ErrorCode.streamWrongLastSequence: + self = .streamWrongLastSequence(err) + case ErrorCode.streamWrongLastMessageId: + self = .streamWrongLastMessageId(err) + case ErrorCode.streamNotMatch: + self = .streamNotMatch(err) + default: + return nil + } + } + } + + public enum ConsumerError: JetStreamErrorProtocol { + case consumerNotFound(APIError) + case maximumConsumersLimit(APIError) + case consumerNameExist(APIError) + case consumerDoesNotExist(APIError) + case invalidConfig(APIError) + + public var description: String { + switch self { + case .consumerNotFound(let err), + .maximumConsumersLimit(let err), + .consumerNameExist(let err), + .consumerDoesNotExist(let err), + .invalidConfig(let err): + return "nats: \(err.description)" + } + } + + internal init?(from err: APIError) { + switch err.errorCode { + case ErrorCode.consumerNotFound: + self = .consumerNotFound(err) + case ErrorCode.maximumConsumersLimit: + self = .maximumConsumersLimit(err) + case ErrorCode.consumerNameExist, + ErrorCode.consumerExistingActive, + ErrorCode.consumerAlreadyExists: + self = .consumerNameExist(err) + case ErrorCode.consumerDoesNotExist: + self = .consumerDoesNotExist(err) + case ErrorCode.consumerDeliverToWildcards, + ErrorCode.consumerPushMaxWaiting, + ErrorCode.consumerDeliverCycle, + ErrorCode.consumerMaxPendingAckPolicyRequired, + ErrorCode.consumerSmallHeartbeat, + ErrorCode.consumerPullRequiresAck, + ErrorCode.consumerPullNotDurable, + ErrorCode.consumerPullWithRateLimit, + ErrorCode.consumerPullNotDurable, + ErrorCode.consumerMaxWaitingNegative, + ErrorCode.consumerHeartbeatRequiresPush, + ErrorCode.consumerFlowControlRequiresPush, + ErrorCode.consumerDirectRequiresPush, + ErrorCode.consumerDirectRequiresEphemeral, + ErrorCode.consumerOnMapped, + ErrorCode.consumerFilterNotSubset, + ErrorCode.consumerInvalidPolicy, + ErrorCode.consumerInvalidSampling, + ErrorCode.consumerWithFlowControlNeedsHeartbeats, + ErrorCode.consumerWqRequiresExplicitAck, + ErrorCode.consumerWqMultipleUnfiltered, + ErrorCode.consumerWqConsumerNotUnique, + ErrorCode.consumerWqConsumerNotDeliverAll, + ErrorCode.consumerNameTooLong, + ErrorCode.consumerBadDurableName, + ErrorCode.consumerDescriptionTooLong, + ErrorCode.consumerInvalidDeliverSubject, + ErrorCode.consumerMaxRequestBatchNegative, + ErrorCode.consumerMaxRequestExpiresToSmall, + ErrorCode.consumerMaxDeliverBackoff, + ErrorCode.consumerMaxPendingAckExcess, + ErrorCode.consumerMaxRequestBatchExceeded, + ErrorCode.consumerReplicasExceedsStream, + ErrorCode.consumerNameContainsPathSeparators, + ErrorCode.consumerCreateFilterSubjectMismatch, + ErrorCode.consumerCreateDurableAndNameMismatch, + ErrorCode.replicasCountCannotBeNegative, + ErrorCode.consumerReplicasShouldMatchStream, + ErrorCode.consumerMetadataLength, + ErrorCode.consumerDuplicateFilterSubjects, + ErrorCode.consumerMultipleFiltersNotAllowed, + ErrorCode.consumerOverlappingSubjectFilters, + ErrorCode.consumerEmptyFilter, + ErrorCode.mirrorMultipleFiltersNotAllowed, + ErrorCode.mirrorInvalidSubjectFilter, + ErrorCode.mirrorOverlappingSubjectFilters, + ErrorCode.consumerInactiveThresholdExcess: + self = .invalidConfig(err) + default: + return nil + } + } + } + + public enum StreamMessageError: JetStreamErrorProtocol { + case deleteSequenceNotFound(APIError) + + public var description: String { + switch self { + case .deleteSequenceNotFound(let err): + return "nats: \(err.description)" + } + } + + internal init?(from err: APIError) { + switch err.errorCode { + case ErrorCode.sequenceNotFound: + self = .deleteSequenceNotFound(err) + default: + return nil + } + } + } + + public enum DirectGetError: JetStreamErrorProtocol { + case invalidResponse(String) + case errorResponse(StatusCode, String?) + + public var description: String { + switch self { + case .invalidResponse(let cause): + return "invalid response: \(cause)" + case .errorResponse(let code, let description): + if let description { + return "unable to get message: \(code) \(description)" + } else { + return "unable to get message: \(code)" + } + } + } + } +} + +public struct ErrorCode: Codable, Equatable { + public let rawValue: UInt64 + /// Peer not a member + public static let clusterPeerNotMember = ErrorCode(rawValue: 10040) + + /// Consumer expected to be ephemeral but detected a durable name set in subject + public static let consumerEphemeralWithDurable = ErrorCode(rawValue: 10019) + + /// Stream external delivery prefix overlaps with stream subject + public static let streamExternalDeletePrefixOverlaps = ErrorCode(rawValue: 10022) + + /// Resource limits exceeded for account + public static let accountResourcesExceeded = ErrorCode(rawValue: 10002) + + /// Jetstream system temporarily unavailable + public static let clusterNotAvailable = ErrorCode(rawValue: 10008) + + /// Subjects overlap with an existing stream + public static let streamSubjectOverlap = ErrorCode(rawValue: 10065) + + /// Wrong last sequence + public static let streamWrongLastSequence = ErrorCode(rawValue: 10071) + + /// Template name in subject does not match request + public static let nameNotMatchSubject = ErrorCode(rawValue: 10073) + + /// No suitable peers for placement + public static let clusterNoPeers = ErrorCode(rawValue: 10005) + + /// Consumer expected to be ephemeral but a durable name was set in request + public static let consumerEphemeralWithDurableName = ErrorCode(rawValue: 10020) + + /// Insufficient resources + public static let insufficientResources = ErrorCode(rawValue: 10023) + + /// Stream mirror must have max message size >= source + public static let mirrorMaxMessageSizeTooBig = ErrorCode(rawValue: 10030) + + /// Generic error from stream deletion operation + public static let streamTemplateDeleteFailed = ErrorCode(rawValue: 10067) + + /// Bad request + public static let badRequest = ErrorCode(rawValue: 10003) + + /// Not currently supported in clustered mode + public static let notSupportedInClusterMode = ErrorCode(rawValue: 10036) + + /// Consumer not found + public static let consumerNotFound = ErrorCode(rawValue: 10014) + + /// Stream source must have max message size >= target + public static let sourceMaxMessageSizeTooBig = ErrorCode(rawValue: 10046) + + /// Generic error when stream operation fails. + public static let streamAssignment = ErrorCode(rawValue: 10048) + + /// Message size exceeds maximum allowed + public static let streamMessageExceedsMaximum = ErrorCode(rawValue: 10054) + + /// Generic error for stream creation error with a string + public static let streamCreateTemplate = ErrorCode(rawValue: 10066) + + /// Invalid JSON + public static let invalidJson = ErrorCode(rawValue: 10025) + + /// Stream external delivery prefix must not contain wildcards + public static let streamInvalidExternalDeliverySubject = ErrorCode(rawValue: 10024) + + /// Restore failed + public static let streamRestore = ErrorCode(rawValue: 10062) + + /// Incomplete results + public static let clusterIncomplete = ErrorCode(rawValue: 10004) + + /// Account not found + public static let noAccount = ErrorCode(rawValue: 10035) + + /// General RAFT error + public static let raftGeneral = ErrorCode(rawValue: 10041) + + /// Jetstream unable to subscribe to restore snapshot + public static let restoreSubscribeFailed = ErrorCode(rawValue: 10042) + + /// Stream deletion failed + public static let streamDelete = ErrorCode(rawValue: 10050) + + /// Stream external api prefix must not overlap + public static let streamExternalApiOverlap = ErrorCode(rawValue: 10021) + + /// Stream mirrors can not contain subjects + public static let mirrorWithSubjects = ErrorCode(rawValue: 10034) + + /// Jetstream not enabled + public static let jetstreamNotEnabled = ErrorCode(rawValue: 10076) + + /// Jetstream not enabled for account + public static let jetstreamNotEnabledForAccount = ErrorCode(rawValue: 10039) + + /// Sequence not found + public static let sequenceNotFound = ErrorCode(rawValue: 10043) + + /// Stream mirror configuration can not be updated + public static let streamMirrorNotUpdatable = ErrorCode(rawValue: 10055) + + /// Expected stream sequence does not match + public static let streamSequenceNotMatch = ErrorCode(rawValue: 10063) + + /// Wrong last msg id + public static let streamWrongLastMessageId = ErrorCode(rawValue: 10070) + + /// Jetstream unable to open temp storage for restore + public static let tempStorageFailed = ErrorCode(rawValue: 10072) + + /// Insufficient storage resources available + public static let storageResourcesExceeded = ErrorCode(rawValue: 10047) + + /// Stream name in subject does not match request + public static let streamMismatch = ErrorCode(rawValue: 10056) + + /// Expected stream does not match + public static let streamNotMatch = ErrorCode(rawValue: 10060) + + /// Setting up consumer mirror failed + public static let mirrorConsumerSetupFailed = ErrorCode(rawValue: 10029) + + /// Expected an empty request payload + public static let notEmptyRequest = ErrorCode(rawValue: 10038) + + /// Stream name already in use with a different configuration + public static let streamNameExist = ErrorCode(rawValue: 10058) + + /// Tags placement not supported for operation + public static let clusterTags = ErrorCode(rawValue: 10011) + + /// Maximum consumers limit reached + public static let maximumConsumersLimit = ErrorCode(rawValue: 10026) + + /// General source consumer setup failure + public static let sourceConsumerSetupFailed = ErrorCode(rawValue: 10045) + + /// Consumer creation failed + public static let consumerCreate = ErrorCode(rawValue: 10012) + + /// Consumer expected to be durable but no durable name set in subject + public static let consumerDurableNameNotInSubject = ErrorCode(rawValue: 10016) + + /// Stream limits error + public static let streamLimits = ErrorCode(rawValue: 10053) + + /// Replicas configuration can not be updated + public static let streamReplicasNotUpdatable = ErrorCode(rawValue: 10061) + + /// Template not found + public static let streamTemplateNotFound = ErrorCode(rawValue: 10068) + + /// Jetstream cluster not assigned to this server + public static let clusterNotAssigned = ErrorCode(rawValue: 10007) + + /// Jetstream cluster can't handle request + public static let clusterNotLeader = ErrorCode(rawValue: 10009) + + /// Consumer name already in use + public static let consumerNameExist = ErrorCode(rawValue: 10013) + + /// Stream mirrors can't also contain other sources + public static let mirrorWithSources = ErrorCode(rawValue: 10031) + + /// Stream not found + public static let streamNotFound = ErrorCode(rawValue: 10059) + + /// Jetstream clustering support required + public static let clusterRequired = ErrorCode(rawValue: 10010) + + /// Consumer expected to be durable but a durable name was not set + public static let consumerDurableNameNotSet = ErrorCode(rawValue: 10018) + + /// Maximum number of streams reached + public static let maximumStreamsLimit = ErrorCode(rawValue: 10027) + + /// Stream mirrors can not have both start seq and start time configured + public static let mirrorWithStartSequenceAndTime = ErrorCode(rawValue: 10032) + + /// Stream snapshot failed + public static let streamSnapshot = ErrorCode(rawValue: 10064) + + /// Stream update failed + public static let streamUpdate = ErrorCode(rawValue: 10069) + + /// Jetstream not in clustered mode + public static let clusterNotActive = ErrorCode(rawValue: 10006) + + /// Consumer name in subject does not match durable name in request + public static let consumerDurableNameNotMatchSubject = ErrorCode(rawValue: 10017) + + /// Insufficient memory resources available + public static let memoryResourcesExceeded = ErrorCode(rawValue: 10028) + + /// Stream mirrors can not contain filtered subjects + public static let mirrorWithSubjectFilters = ErrorCode(rawValue: 10033) + + /// Stream create failed with a string + public static let streamCreate = ErrorCode(rawValue: 10049) + + /// Server is not a member of the cluster + public static let clusterServerNotMember = ErrorCode(rawValue: 10044) + + /// No message found + public static let noMessageFound = ErrorCode(rawValue: 10037) + + /// Deliver subject not valid + public static let snapshotDeliverSubjectInvalid = ErrorCode(rawValue: 10015) + + /// General stream failure + public static let streamGeneralError = ErrorCode(rawValue: 10051) + + /// Invalid stream config + public static let streamInvalidConfig = ErrorCode(rawValue: 10052) + + /// Replicas > 1 not supported in non-clustered mode + public static let streamReplicasNotSupported = ErrorCode(rawValue: 10074) + + /// Stream message delete failed + public static let streamMessageDeleteFailed = ErrorCode(rawValue: 10057) + + /// Peer remap failed + public static let peerRemap = ErrorCode(rawValue: 10075) + + /// Stream store failed + public static let streamStoreFailed = ErrorCode(rawValue: 10077) + + /// Consumer config required + public static let consumerConfigRequired = ErrorCode(rawValue: 10078) + + /// Consumer deliver subject has wildcards + public static let consumerDeliverToWildcards = ErrorCode(rawValue: 10079) + + /// Consumer in push mode can not set max waiting + public static let consumerPushMaxWaiting = ErrorCode(rawValue: 10080) + + /// Consumer deliver subject forms a cycle + public static let consumerDeliverCycle = ErrorCode(rawValue: 10081) + + /// Consumer requires ack policy for max ack pending + public static let consumerMaxPendingAckPolicyRequired = ErrorCode(rawValue: 10082) + + /// Consumer idle heartbeat needs to be >= 100ms + public static let consumerSmallHeartbeat = ErrorCode(rawValue: 10083) + + /// Consumer in pull mode requires ack policy + public static let consumerPullRequiresAck = ErrorCode(rawValue: 10084) + + /// Consumer in pull mode requires a durable name + public static let consumerPullNotDurable = ErrorCode(rawValue: 10085) + + /// Consumer in pull mode can not have rate limit set + public static let consumerPullWithRateLimit = ErrorCode(rawValue: 10086) + + /// Consumer max waiting needs to be positive + public static let consumerMaxWaitingNegative = ErrorCode(rawValue: 10087) + + /// Consumer idle heartbeat requires a push based consumer + public static let consumerHeartbeatRequiresPush = ErrorCode(rawValue: 10088) + + /// Consumer flow control requires a push based consumer + public static let consumerFlowControlRequiresPush = ErrorCode(rawValue: 10089) + + /// Consumer direct requires a push based consumer + public static let consumerDirectRequiresPush = ErrorCode(rawValue: 10090) + + /// Consumer direct requires an ephemeral consumer + public static let consumerDirectRequiresEphemeral = ErrorCode(rawValue: 10091) + + /// Consumer direct on a mapped consumer + public static let consumerOnMapped = ErrorCode(rawValue: 10092) + + /// Consumer filter subject is not a valid subset of the interest subjects + public static let consumerFilterNotSubset = ErrorCode(rawValue: 10093) + + /// Invalid consumer policy + public static let consumerInvalidPolicy = ErrorCode(rawValue: 10094) + + /// Failed to parse consumer sampling configuration + public static let consumerInvalidSampling = ErrorCode(rawValue: 10095) + + /// Stream not valid + public static let streamInvalid = ErrorCode(rawValue: 10096) + + /// Workqueue stream requires explicit ack + public static let consumerWqRequiresExplicitAck = ErrorCode(rawValue: 10098) + + /// Multiple non-filtered consumers not allowed on workqueue stream + public static let consumerWqMultipleUnfiltered = ErrorCode(rawValue: 10099) + + /// Filtered consumer not unique on workqueue stream + public static let consumerWqConsumerNotUnique = ErrorCode(rawValue: 10100) + + /// Consumer must be deliver all on workqueue stream + public static let consumerWqConsumerNotDeliverAll = ErrorCode(rawValue: 10101) + + /// Consumer name is too long + public static let consumerNameTooLong = ErrorCode(rawValue: 10102) + + /// Durable name can not contain token separators and wildcards + public static let consumerBadDurableName = ErrorCode(rawValue: 10103) + + /// Error creating store for consumer + public static let consumerStoreFailed = ErrorCode(rawValue: 10104) + + /// Consumer already exists and is still active + public static let consumerExistingActive = ErrorCode(rawValue: 10105) + + /// Consumer replacement durable config not the same + public static let consumerReplacementWithDifferentName = ErrorCode(rawValue: 10106) + + /// Consumer description is too long + public static let consumerDescriptionTooLong = ErrorCode(rawValue: 10107) + + /// Header size exceeds maximum allowed of 64k + public static let streamHeaderExceedsMaximum = ErrorCode(rawValue: 10097) + + /// Consumer with flow control also needs heartbeats + public static let consumerWithFlowControlNeedsHeartbeats = ErrorCode(rawValue: 10108) + + /// Invalid operation on sealed stream + public static let streamSealed = ErrorCode(rawValue: 10109) + + /// Stream purge failed + public static let streamPurgeFailed = ErrorCode(rawValue: 10110) + + /// Stream rollup failed + public static let streamRollupFailed = ErrorCode(rawValue: 10111) + + /// Invalid push consumer deliver subject + public static let consumerInvalidDeliverSubject = ErrorCode(rawValue: 10112) + + /// Account requires a stream config to have max bytes set + public static let streamMaxBytesRequired = ErrorCode(rawValue: 10113) + + /// Consumer max request batch needs to be > 0 + public static let consumerMaxRequestBatchNegative = ErrorCode(rawValue: 10114) + + /// Consumer max request expires needs to be >= 1ms + public static let consumerMaxRequestExpiresToSmall = ErrorCode(rawValue: 10115) + + /// Max deliver is required to be > length of backoff values + public static let consumerMaxDeliverBackoff = ErrorCode(rawValue: 10116) + + /// Subject details would exceed maximum allowed + public static let streamInfoMaxSubjects = ErrorCode(rawValue: 10117) + + /// Stream is offline + public static let streamOffline = ErrorCode(rawValue: 10118) + + /// Consumer is offline + public static let consumerOffline = ErrorCode(rawValue: 10119) + + /// No jetstream default or applicable tiered limit present + public static let noLimits = ErrorCode(rawValue: 10120) + + /// Consumer max ack pending exceeds system limit + public static let consumerMaxPendingAckExcess = ErrorCode(rawValue: 10121) + + /// Stream max bytes exceeds account limit max stream bytes + public static let streamMaxStreamBytesExceeded = ErrorCode(rawValue: 10122) + + /// Can not move and scale a stream in a single update + public static let streamMoveAndScale = ErrorCode(rawValue: 10123) + + /// Stream move already in progress + public static let streamMoveInProgress = ErrorCode(rawValue: 10124) + + /// Consumer max request batch exceeds server limit + public static let consumerMaxRequestBatchExceeded = ErrorCode(rawValue: 10125) + + /// Consumer config replica count exceeds parent stream + public static let consumerReplicasExceedsStream = ErrorCode(rawValue: 10126) + + /// Consumer name can not contain path separators + public static let consumerNameContainsPathSeparators = ErrorCode(rawValue: 10127) + + /// Stream name can not contain path separators + public static let streamNameContainsPathSeparators = ErrorCode(rawValue: 10128) + + /// Stream move not in progress + public static let streamMoveNotInProgress = ErrorCode(rawValue: 10129) + + /// Stream name already in use, cannot restore + public static let streamNameExistRestoreFailed = ErrorCode(rawValue: 10130) + + /// Consumer create request did not match filtered subject from create subject + public static let consumerCreateFilterSubjectMismatch = ErrorCode(rawValue: 10131) + + /// Consumer durable and name have to be equal if both are provided + public static let consumerCreateDurableAndNameMismatch = ErrorCode(rawValue: 10132) + + /// Replicas count cannot be negative + public static let replicasCountCannotBeNegative = ErrorCode(rawValue: 10133) + + /// Consumer config replicas must match interest retention stream's replicas + public static let consumerReplicasShouldMatchStream = ErrorCode(rawValue: 10134) + + /// Consumer metadata exceeds maximum size + public static let consumerMetadataLength = ErrorCode(rawValue: 10135) + + /// Consumer cannot have both filter_subject and filter_subjects specified + public static let consumerDuplicateFilterSubjects = ErrorCode(rawValue: 10136) + + /// Consumer with multiple subject filters cannot use subject based api + public static let consumerMultipleFiltersNotAllowed = ErrorCode(rawValue: 10137) + + /// Consumer subject filters cannot overlap + public static let consumerOverlappingSubjectFilters = ErrorCode(rawValue: 10138) + + /// Consumer filter in filter_subjects cannot be empty + public static let consumerEmptyFilter = ErrorCode(rawValue: 10139) + + /// Duplicate source configuration detected + public static let sourceDuplicateDetected = ErrorCode(rawValue: 10140) + + /// Sourced stream name is invalid + public static let sourceInvalidStreamName = ErrorCode(rawValue: 10141) + + /// Mirrored stream name is invalid + public static let mirrorInvalidStreamName = ErrorCode(rawValue: 10142) + + /// Source with multiple subject transforms cannot also have a single subject filter + public static let sourceMultipleFiltersNotAllowed = ErrorCode(rawValue: 10144) + + /// Source subject filter is invalid + public static let sourceInvalidSubjectFilter = ErrorCode(rawValue: 10145) + + /// Source transform destination is invalid + public static let sourceInvalidTransformDestination = ErrorCode(rawValue: 10146) + + /// Source filters cannot overlap + public static let sourceOverlappingSubjectFilters = ErrorCode(rawValue: 10147) + + /// Consumer already exists + public static let consumerAlreadyExists = ErrorCode(rawValue: 10148) + + /// Consumer does not exist + public static let consumerDoesNotExist = ErrorCode(rawValue: 10149) + + /// Mirror with multiple subject transforms cannot also have a single subject filter + public static let mirrorMultipleFiltersNotAllowed = ErrorCode(rawValue: 10150) + + /// Mirror subject filter is invalid + public static let mirrorInvalidSubjectFilter = ErrorCode(rawValue: 10151) + + /// Mirror subject filters cannot overlap + public static let mirrorOverlappingSubjectFilters = ErrorCode(rawValue: 10152) + + /// Consumer inactive threshold exceeds system limit + public static let consumerInactiveThresholdExcess = ErrorCode(rawValue: 10153) + +} + +extension ErrorCode { + // Encoding + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(rawValue) + } + + // Decoding + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let decodedValue = try container.decode(UInt64.self) + self = ErrorCode(rawValue: decodedValue) + } +} + +public enum Response: Codable { + case success(T) + case error(JetStreamAPIResponse) + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + // Try to decode the expected success type T first + if let successResponse = try? container.decode(T.self) { + self = .success(successResponse) + return + } + + // If that fails, try to decode ErrorResponse + let errorResponse = try container.decode(JetStreamAPIResponse.self) + self = .error(errorResponse) + return + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .success(let successData): + try container.encode(successData) + case .error(let errorData): + try container.encode(errorData) + } + } +} diff --git a/Sources/JetStream/JetStreamMessage.swift b/Sources/JetStream/JetStreamMessage.swift new file mode 100644 index 0000000..4673e63 --- /dev/null +++ b/Sources/JetStream/JetStreamMessage.swift @@ -0,0 +1,193 @@ +// Copyright 2024 The NATS 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 +// +// http://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. + +import Foundation +import Nats + +/// Representation of NATS message in the context of JetStream. +/// It exposes message properties (payload, headers etc.) and various methods for acknowledging delivery. +/// It also allows for checking message metadata. +public struct JetStreamMessage { + private let message: NatsMessage + + /// Message payload. + public var payload: Data? { message.payload } + + /// Message headers. + public var headers: NatsHeaderMap? { message.headers } + + /// The subject the message was published on. + public var subject: String { message.subject } + + /// Reply subject used for acking a message. + public var reply: String? { message.replySubject } + + internal let client: NatsClient + + private let emptyPayload = "".data(using: .utf8)! + + internal init(message: NatsMessage, client: NatsClient) { + self.message = message + self.client = client + } + + /// Sends an acknowledgement of given kind to the server. + /// + /// - Parameter ackType: the type of acknowledgement being sent (defaults to ``AckKind/ack``. For details, see ``AckKind``. + /// - Throws: + /// - ``JetStreamError/AckError`` if there was an error sending the acknowledgement. + public func ack(ackType: AckKind = .ack) async throws { + guard let subject = message.replySubject else { + throw JetStreamError.AckError.noReplyInMessage + } + try await client.publish(ackType.payload(), subject: subject) + } + + /// Parses the reply subject of the message, exposing JetStream message metadata. + /// + /// - Returns ``MessageMetadata`` + /// + /// - Throws: + /// - ``JetStreamError/MessageMetadataError`` when there is an error parsing metadata. + public func metadata() throws -> MessageMetadata { + let prefix = "$JS.ACK." + guard let subject = message.replySubject else { + throw JetStreamError.MessageMetadataError.noReplyInMessage + } + if !subject.starts(with: prefix) { + throw JetStreamError.MessageMetadataError.invalidPrefix + } + + let startIndex = subject.index(subject.startIndex, offsetBy: prefix.count) + let parts = subject[startIndex...].split(separator: ".") + + return try MessageMetadata(tokens: parts) + } +} + +/// Represents various types of JetStream message acknowledgement. +public enum AckKind { + /// Normal acknowledgemnt + case ack + /// Negative ack, message will be redelivered (immediately or after given delay) + case nak(delay: TimeInterval? = nil) + /// Marks the message as being processed, resets ack wait timer delaying evential redelivery. + case inProgress + /// Marks the message as terminated, it will never be redelivered. + case term(reason: String? = nil) + + func payload() -> Data { + switch self { + case .ack: + return "+ACK".data(using: .utf8)! + case .nak(let delay): + if let delay { + let delayStr = String(Int64(delay * 1_000_000_000)) + return "-NAK {\"delay\":\(delayStr)}".data(using: .utf8)! + } else { + return "-NAK".data(using: .utf8)! + } + case .inProgress: + return "+WPI".data(using: .utf8)! + case .term(let reason): + if let reason { + return "+TERM \(reason)".data(using: .utf8)! + } else { + return "+TERM".data(using: .utf8)! + } + } + } +} + +/// Metadata of a JetStream message. +public struct MessageMetadata { + /// The domain this message was received on. + public let domain: String? + + /// Optional account hash, present in servers post-ADR-15. + public let accountHash: String? + + /// Name of the stream the message is delivered from. + public let stream: String + + /// Name of the consumer the mesasge is delivered from. + public let consumer: String + + /// Number of delivery attempts of this message. + public let delivered: UInt64 + + /// Stream sequence associated with this message. + public let streamSequence: UInt64 + + /// Consumer sequence associated with this message. + public let consumerSequence: UInt64 + + /// The time this message was received by the server from the publisher. + public let timestamp: String + + /// The number of messages known by the server to be pending to this consumer. + public let pending: UInt64 + + private let v1TokenCount = 7 + private let v2TokenCount = 9 + + init(tokens: [Substring]) throws { + if tokens.count >= v2TokenCount { + self.domain = String(tokens[0]) + self.accountHash = String(tokens[1]) + self.stream = String(tokens[2]) + self.consumer = String(tokens[3]) + guard let delivered = UInt64(tokens[4]) else { + throw JetStreamError.MessageMetadataError.invalidTokenValue + } + self.delivered = delivered + guard let sseq = UInt64(tokens[5]) else { + throw JetStreamError.MessageMetadataError.invalidTokenValue + } + self.streamSequence = sseq + guard let cseq = UInt64(tokens[6]) else { + throw JetStreamError.MessageMetadataError.invalidTokenValue + } + self.consumerSequence = cseq + self.timestamp = String(tokens[7]) + guard let pending = UInt64(tokens[8]) else { + throw JetStreamError.MessageMetadataError.invalidTokenValue + } + self.pending = pending + } else if tokens.count == v1TokenCount { + self.domain = nil + self.accountHash = nil + self.stream = String(tokens[0]) + self.consumer = String(tokens[1]) + guard let delivered = UInt64(tokens[2]) else { + throw JetStreamError.MessageMetadataError.invalidTokenValue + } + self.delivered = delivered + guard let sseq = UInt64(tokens[3]) else { + throw JetStreamError.MessageMetadataError.invalidTokenValue + } + self.streamSequence = sseq + guard let cseq = UInt64(tokens[4]) else { + throw JetStreamError.MessageMetadataError.invalidTokenValue + } + self.consumerSequence = cseq + self.timestamp = String(tokens[5]) + guard let pending = UInt64(tokens[6]) else { + throw JetStreamError.MessageMetadataError.invalidTokenValue + } + self.pending = pending + } else { + throw JetStreamError.MessageMetadataError.invalidTokenNum + } + } +} diff --git a/Sources/JetStream/NanoTimeInterval.swift b/Sources/JetStream/NanoTimeInterval.swift new file mode 100644 index 0000000..fcb6126 --- /dev/null +++ b/Sources/JetStream/NanoTimeInterval.swift @@ -0,0 +1,39 @@ +// Copyright 2024 The NATS 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 +// +// http://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. + +import Foundation + +/// `NanoTimeInterval` represents a time interval in nanoseconds, facilitating high precision time measurements. +public struct NanoTimeInterval: Codable, Equatable { + /// The value of the time interval in seconds. + var value: TimeInterval + + public init(_ timeInterval: TimeInterval) { + self.value = timeInterval + } + + /// Initializes a `NanoTimeInterval` from a decoder, assuming the encoded value is in nanoseconds. + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let nanoseconds = try container.decode(Double.self) + self.value = nanoseconds / 1_000_000_000.0 + } + + /// Encodes this `NanoTimeInterval` into a given encoder, converting the time interval from seconds to nanoseconds. + /// This method allows `NanoTimeInterval` to be serialized directly into a format that stores time in nanoseconds. + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + let nanoseconds = self.value * 1_000_000_000.0 + try container.encode(nanoseconds) + } +} diff --git a/Sources/JetStream/Stream+Consumer.swift b/Sources/JetStream/Stream+Consumer.swift new file mode 100644 index 0000000..bc5f91f --- /dev/null +++ b/Sources/JetStream/Stream+Consumer.swift @@ -0,0 +1,118 @@ +// Copyright 2024 The NATS 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 +// +// http://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. + +import Foundation + +extension Stream { + + /// Creates a consumer with the specified configuration. + /// + /// - Parameters: + /// - cfg: consumer config + /// + /// - Returns: ``Consumer`` object containing ``ConsumerConfig`` and exposing operations on the consumer + /// + /// > **Throws:** + /// > - ``JetStreamError/ConsumerError``: if there was am error creating the stream. There are several errors which may occur, most common being: + /// > - ``JetStreamError/ConsumerError/invalidConfig(_:)``: if the provided configuration is not valid. + /// > - ``JetStreamError/ConsumerError/consumerNameExist(_:)``: if attempting to overwrite an existing consumer (with different configuration) + /// > - ``JetStreamError/ConsumerError/maximumConsumersLimit(_:)``: if a max number of consumers (specified on stream/account level) has been reached. + /// > - ``JetStreamError/RequestError``: if the request fails if e.g. JetStream is not enabled. + /// > - ``JetStreamError/APIError``: if there was a different API error returned from JetStream. + public func createConsumer(cfg: ConsumerConfig) async throws -> Consumer { + return try await ctx.upsertConsumer(stream: info.config.name, cfg: cfg, action: "create") + } + + /// Updates an existing consumer using specified config. + /// + /// - Parameters: + /// - cfg: consumer config + /// + /// - Returns: ``Consumer`` object containing ``ConsumerConfig`` and exposing operations on the consumer + /// + /// > **Throws:** + /// > - ``JetStreamError/ConsumerError``: if there was am error creating the stream. There are several errors which may occur, most common being: + /// > - ``JetStreamError/ConsumerError/invalidConfig(_:)``: if the provided configuration is not valid or atteppting to update an illegal property + /// > - ``JetStreamError/ConsumerError/consumerDoesNotExist(_:)``: if attempting to update a non-existing consumer + /// > - ``JetStreamError/RequestError``: if the request fails if e.g. JetStream is not enabled. + /// > - ``JetStreamError/APIError``: if there was a different API error returned from JetStream. + public func updateConsumer(cfg: ConsumerConfig) async throws -> Consumer { + return try await ctx.upsertConsumer(stream: info.config.name, cfg: cfg, action: "update") + } + + /// Creates a consumer with the specified configuration or updates an existing consumer. + /// + /// - Parameters: + /// - stream: name of the stream the consumer will be created on + /// - cfg: consumer config + /// + /// - Returns: ``Consumer`` object containing ``ConsumerConfig`` and exposing operations on the consumer + /// + /// > **Throws:** + /// > - ``JetStreamError/ConsumerError``: if there was am error creating the stream. There are several errors which may occur, most common being: + /// > - ``JetStreamError/ConsumerError/invalidConfig(_:)``: if the provided configuration is not valid or atteppting to update an illegal property + /// > - ``JetStreamError/ConsumerError/maximumConsumersLimit(_:)``: if a max number of consumers (specified on stream/account level) has been reached. + /// > - ``JetStreamError/RequestError``: if the request fails if e.g. JetStream is not enabled. + /// > - ``JetStreamError/APIError``: if there was a different API error returned from JetStream. + public func createOrUpdateConsumer(cfg: ConsumerConfig) async throws -> Consumer { + return try await ctx.upsertConsumer(stream: info.config.name, cfg: cfg) + } + + /// Retrieves a consumer with given name from a stream. + /// + /// - Parameters: + /// - name: name of the stream + /// + /// - Returns a ``Stream`` object containing ``StreamInfo`` and exposing operations on the stream or nil if stream with given name does not exist. + /// + /// > **Throws:** + /// > - ``JetStreamError/ConsumerError/consumerNotFound(_:)``: if the consumer with given name does not exist on a given stream. + /// > - ``JetStreamError/RequestError``: if the request fails if e.g. JetStream is not enabled. + /// > - ``JetStreamError/APIError``: if there was a different API error returned from JetStream. + public func getConsumer(name: String) async throws -> Consumer? { + return try await ctx.getConsumer(stream: info.config.name, name: name) + } + + /// Deletes a consumer from a stream. + /// + /// - Parameters: + /// - name: consumer name + /// + /// > **Throws:** + /// > - ``JetStreamError/ConsumerError/consumerNotFound(_:)``: if the consumer with given name does not exist on a given stream. + /// > - ``JetStreamError/RequestError``: if the request fails if e.g. JetStream is not enabled. + /// > - ``JetStreamError/APIError``: if there was a different API error returned from JetStream. + public func deleteConsumer(name: String) async throws { + try await ctx.deleteConsumer(stream: info.config.name, name: name) + } + + /// Used to list consumer names. + /// + /// - Parameters: + /// - stream: the name of the strem to list the consumers from. + /// + /// - Returns ``Consumers`` which implements AsyncSequence allowing iteration over stream infos. + public func consumers() async -> Consumers { + return Consumers(ctx: ctx, stream: info.config.name) + } + + /// Used to list consumer names. + /// + /// - Parameters: + /// - stream: the name of the strem to list the consumers from. + /// + /// - Returns ``ConsumerNames`` which implements AsyncSequence allowing iteration over consumer names. + public func consumerNames() async -> ConsumerNames { + return ConsumerNames(ctx: ctx, stream: info.config.name) + } +} diff --git a/Sources/JetStream/Stream.swift b/Sources/JetStream/Stream.swift new file mode 100644 index 0000000..ce6ced2 --- /dev/null +++ b/Sources/JetStream/Stream.swift @@ -0,0 +1,1031 @@ +// Copyright 2024 The NATS 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 +// +// http://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. + +import Foundation +import Nats + +/// Representation of a JetStream Stream, exposing ``StreamInfo`` and operations on a stream. +/// +/// Available operations: +/// - fetching stream info +/// - fetching individual messages from the stream +/// - deleting messages from a stream +/// - purging a stream +/// - operating on Consumers +public class Stream { + + /// Contains information about the stream. + /// Note that this may be out of date and reading it does not query the server. + /// For up-to-date stream info use ``Stream/info()`` + public internal(set) var info: StreamInfo + internal let ctx: JetStreamContext + + init(ctx: JetStreamContext, info: StreamInfo) { + self.ctx = ctx + self.info = info + } + + /// Retrieves information about the stream + /// This also refreshes ``Stream/info``. + /// + /// - Returns ``StreamInfo`` from the server. + /// + /// > **Throws:** + /// > - ``JetStreamRequestError`` if the request was unsuccessful. + /// > - ``JetStreamError`` if the server responded with an API error. + public func info() async throws -> StreamInfo { + let subj = "STREAM.INFO.\(info.config.name)" + let info: Response = try await ctx.request(subj) + switch info { + case .success(let info): + self.info = info + return info + case .error(let apiResponse): + throw apiResponse.error + } + } + + /// Retrieves a message from stream. + /// + /// - Parameters: + /// - sequence: The sequence of the message in the stream. + /// - subject: The stream subject the message should be retrieved from. + /// When combined with `seq` will return the first msg with seq >= of the specified sequence. + /// + /// - Returns ``StreamMessage`` containing message payload, headers and metadata or nil if the message was not found. + /// + /// > **Throws:** + /// > - ``JetStreamRequestError`` if the request was unsuccessful. + /// > - ``JetStreamError`` if the server responded with an API error. + public func getMessage(sequence: UInt64, subject: String? = nil) async throws -> StreamMessage? + { + let request = GetMessageRequest(seq: sequence, next: subject) + return try await getMessage(request: request) + } + + /// Retrieves the first message on the stream for a given subject. + /// + /// - Parameter firstForSubject: The subject from which the first message should be retrieved. + /// + /// - Returns ``StreamMessage`` containing message payload, headers and metadata or nil if the message was not found. + /// + /// > **Throws:** + /// > - ``JetStreamRequestError`` if the request was unsuccessful. + /// > - ``JetStreamError`` if the server responded with an API error. + public func getMessage(firstForSubject: String) async throws -> StreamMessage? { + let request = GetMessageRequest(next: firstForSubject) + return try await getMessage(request: request) + } + + /// Retrieves last message on a stream for a given subject + /// + /// - Parameter lastForSubject: The stream subject for which the last available message should be retrieved. + /// + /// - Returns ``StreamMessage`` containing message payload, headers and metadata or nil if the message was not found. + /// + /// > **Throws:** + /// > - ``JetStreamRequestError`` if the request was unsuccessful. + /// > - ``JetStreamError`` if the server responded with an API error. + public func getMessage(lastForSubject: String) async throws -> StreamMessage? { + let request = GetMessageRequest(last: lastForSubject) + return try await getMessage(request: request) + } + + /// Retrieves a message from stream. + /// + /// Requires a ``Stream`` with ``StreamConfig/allowDirect`` set to `true`. + /// This is different from ``Stream/getMsg(sequence:subject:)``, as it can fetch ``StreamMessage`` + /// from any replica member. This means read after write is possible, + /// as that given replica might not yet catch up with the leader. + /// + /// - Parameters: + /// - sequence: The sequence of the message in the stream. + /// - subject: The stream subject the message should be retrieved from. + /// When combined with `seq` will return the first msg with seq >= of the specified sequence. + /// + /// - Returns ``StreamMessage`` containing message payload, headers and metadata or nil if the message was not found. + /// + /// > **Throws:** + /// > - ``JetStreamRequestError`` if the request was unsuccessful. + /// > - ``JetStreamError/DirectGetError`` if the server responded with an error or the response is invalid. + public func getMessageDirect( + sequence: UInt64, subject: String? = nil + ) async throws -> StreamMessage? { + let request = GetMessageRequest(seq: sequence, next: subject) + return try await getMessageDirect(request: request) + } + + /// Retrieves the first message on the stream for a given subject. + /// + /// Requires a ``Stream`` with ``StreamConfig/allowDirect`` set to `true`. + /// This is different from ``Stream/getMsg(firstForSubject:)``, as it can fetch ``StreamMessage`` + /// from any replica member. This means read after write is possible, + /// as that given replica might not yet catch up with the leader. + /// + /// - Parameter firstForSubject: The subject from which the first message should be retrieved. + /// + /// - Returns ``StreamMessage`` containing message payload, headers and metadata or nil if the message was not found. + /// + /// > **Throws:** + /// > - ``JetStreamRequestError`` if the request was unsuccessful. + /// > - ``JetStreamError/DirectGetError`` if the server responded with an error or the response is invalid. + public func getMessageDirect(firstForSubject: String) async throws -> StreamMessage? { + let request = GetMessageRequest(next: firstForSubject) + return try await getMessageDirect(request: request) + } + + /// Retrieves last message on a stream for a given subject + /// + /// Requires a ``Stream`` with ``StreamConfig/allowDirect`` set to `true`. + /// This is different from ``Stream/getMsg(lastForSubject:)``, as it can fetch ``StreamMessage`` + /// from any replica member. This means read after write is possible, + /// as that given replica might not yet catch up with the leader. + /// + /// - Parameter lastForSubject: The stream subject for which the last available message should be retrieved. + /// + /// - Returns ``StreamMessage`` containing message payload, headers and metadata. + /// + /// > **Throws:** + /// > - ``JetStreamRequestError`` if the request was unsuccessful. + /// > - ``JetStreamError/DirectGetError`` if the server responded with an error or the response is invalid. + public func getMessageDirect(lastForSubject: String) async throws -> StreamMessage? { + let request = GetMessageRequest(last: lastForSubject) + return try await getMessageDirect(request: request) + } + + /// Removes a message with provided sequence from the stream. + /// Requires ``StreamConfig/denyDelete`` to be false. + /// + /// - Parameters: + /// - sequence: The sequence of the message in the stream. + /// - secure: If set to true, the message will be permanently removed from the stream (overwritten with random data). + /// Otherwise, it will be marked as deleted. + /// + /// > **Throws:** + /// > - ``JetStreamError/StreamMessageError/deleteSequenceNotFound(_:)`` if a message with provided sequence does not exist. + /// > - ``JetStreamError/RequestError`` if the request fails if e.g. JetStream is not enabled. + /// > - ``JetStreamError/APIError`` if the server responded with an API error. + public func deleteMessage(sequence: UInt64, secure: Bool = false) async throws { + var request: DeleteMessageRequest + if secure { + request = DeleteMessageRequest(seq: sequence) + } else { + request = DeleteMessageRequest(seq: sequence, noErase: true) + } + let subject = "STREAM.MSG.DELETE.\(info.config.name)" + let requestData = try JSONEncoder().encode(request) + + let resp: Response = try await ctx.request( + subject, message: requestData) + + switch resp { + case .success(_): + return + case .error(let apiResponse): + if let deleteMsgError = JetStreamError.StreamMessageError(from: apiResponse.error) { + throw deleteMsgError + } + throw apiResponse.error + } + } + + /// Purges messages from the stream. If `subject` is not provided, all messages on a stream will be permanently removed. + /// Requires ``StreamConfig/denyPurge`` to be false. + /// + /// - Parameter subject:when set, filters the subject from which the messages will be removed (may contain wildcards). + /// + /// - Returns the number of messages purged. + /// + /// > **Throws:** + /// > - ``JetStreamRequestError`` if the request was unsuccessful. + /// > - ``JetStreamError`` if the server responded with an API error. + public func purge(subject: String? = nil) async throws -> UInt64 { + let request = PurgeRequest(filter: subject) + + return try await purge(request: request) + } + + /// Purges messages from the stream up to the given stream sequence (non-inclusive). + /// Requires ``StreamConfig/denyPurge`` to be false. + /// + /// - Parameters: + /// - sequence: the upper bound sequence for messages to be deleted (non-inclusive). + /// - subject: when set, filters the subject from which the messages will be removed (may contain wildcards). + /// + /// - Returns the number of messages purged. + /// + /// > **Throws:** + /// > - ``JetStreamRequestError`` if the request was unsuccessful. + /// > - ``JetStreamError`` if the server responded with an API error. + public func purge(sequence: UInt64, subject: String? = nil) async throws -> UInt64 { + let request = PurgeRequest(seq: sequence, filter: subject) + + return try await purge(request: request) + } + + /// Purges messages from the stream, retaining the provided number of messages). + /// Requires ``StreamConfig/denyPurge`` to be false. + /// + /// - Parameters: + /// - keep: the number of messages to be retained. If there are less matching messages on than this number, no messages will be purged. + /// - subject: when set, filters the subject from which the messages will be removed (may contain wildcards). + /// + /// - Returns the number of messages purged. + /// + /// > **Throws:** + /// > - ``JetStreamRequestError`` if the request was unsuccessful. + /// > - ``JetStreamError`` if the server responded with an API error. + public func purge(keep: UInt64, subject: String? = nil) async throws -> UInt64 { + let request = PurgeRequest(keep: keep, filter: subject) + + return try await purge(request: request) + } + + private func getMessage(request: GetMessageRequest) async throws -> StreamMessage? { + let subject = "STREAM.MSG.GET.\(info.config.name)" + let requestData = try JSONEncoder().encode(request) + + let resp: Response = try await ctx.request(subject, message: requestData) + + switch resp { + case .success(let msg): + return try StreamMessage(from: msg.message) + case .error(let err): + if err.error.errorCode == .noMessageFound { + return nil + } + throw err.error + } + } + + private func getMessageDirect(request: GetMessageRequest) async throws -> StreamMessage? { + let subject = "DIRECT.GET.\(info.config.name)" + let requestData = try JSONEncoder().encode(request) + + let resp = try await ctx.request(subject, message: requestData) + + if let status = resp.status { + if status == StatusCode.notFound { + return nil + } + throw JetStreamError.DirectGetError.errorResponse(status, resp.description) + } + + guard let headers = resp.headers else { + throw JetStreamError.DirectGetError.invalidResponse("response should contain headers") + } + + guard headers[.natsStream] != nil else { + throw JetStreamError.DirectGetError.invalidResponse("missing Nats-Stream header") + } + + guard let seqHdr = headers[.natsSequence] else { + throw JetStreamError.DirectGetError.invalidResponse("missing Nats-Sequence header") + } + + let seq = UInt64(seqHdr.description) + if seq == nil { + throw JetStreamError.DirectGetError.invalidResponse( + "invalid Nats-Sequence header: \(seqHdr)") + } + + guard let timeStamp = headers[.natsTimestamp] else { + throw JetStreamError.DirectGetError.invalidResponse("missing Nats-Timestamp header") + } + + guard let subject = headers[.natsSubject] else { + throw JetStreamError.DirectGetError.invalidResponse("missing Nats-Subject header") + } + + let payload = resp.payload ?? Data() + + return StreamMessage( + subject: subject.description, sequence: seq!, payload: payload, headers: resp.headers, + time: timeStamp.description) + } + + private func purge(request: PurgeRequest) async throws -> UInt64 { + let subject = "STREAM.PURGE.\(info.config.name)" + let requestData = try JSONEncoder().encode(request) + + let resp: Response = try await ctx.request(subject, message: requestData) + + switch resp { + case .success(let result): + return result.purged + case .error(let err): + throw err.error + } + } + + internal struct GetMessageRequest: Codable { + internal let seq: UInt64? + internal let nextBySubject: String? + internal let lastBySubject: String? + + internal init(seq: UInt64, next: String?) { + self.seq = seq + self.nextBySubject = next + self.lastBySubject = nil + } + + internal init(next: String) { + self.seq = nil + self.nextBySubject = next + self.lastBySubject = nil + } + + internal init(last: String) { + self.seq = nil + self.nextBySubject = nil + self.lastBySubject = last + } + + enum CodingKeys: String, CodingKey { + case seq + case nextBySubject = "next_by_subj" + case lastBySubject = "last_by_subj" + } + } + + private struct DeleteMessageRequest: Codable { + internal let seq: UInt64 + internal let noErase: Bool? + + init(seq: UInt64, noErase: Bool? = nil) { + self.seq = seq + self.noErase = noErase + } + + enum CodingKeys: String, CodingKey { + case seq + case noErase = "no_erase" + } + } + + internal struct DeleteMessageResponse: Codable { + internal let success: Bool + } + + private struct PurgeRequest: Codable { + internal let seq: UInt64? + internal let keep: UInt64? + internal let filter: String? + + init(seq: UInt64? = nil, keep: UInt64? = nil, filter: String? = nil) { + self.seq = seq + self.keep = keep + self.filter = filter + } + } + + internal struct PurgeResponse: Codable { + internal let success: Bool + internal let purged: UInt64 + } + + static func validate(name: String) throws { + guard !name.isEmpty else { + throw JetStreamError.StreamError.nameRequired + } + + let invalidChars = CharacterSet(charactersIn: ">*. /\\") + if name.rangeOfCharacter(from: invalidChars) != nil { + throw JetStreamError.StreamError.invalidStreamName(name) + } + } +} + +/// `StreamInfo` contains details about the configuration and state of a stream within JetStream. +public struct StreamInfo: Codable { + /// The configuration settings of the stream, set upon creation or update. + public let config: StreamConfig + + /// The timestamp indicating when the stream was created. + public let created: String + + /// Provides the current state of the stream including metrics such as message count and total bytes. + public let state: StreamState + + /// Information about the cluster to which this stream belongs, if applicable. + public let cluster: ClusterInfo? + + /// Information about another stream that this one is mirroring, if applicable. + public let mirror: StreamSourceInfo? + + /// A list of source streams from which this stream collects data. + public let sources: [StreamSourceInfo]? + + /// The timestamp indicating when this information was gathered by the server. + public let timeStamp: String + + enum CodingKeys: String, CodingKey { + case config, created, state, cluster, mirror, sources + case timeStamp = "ts" + } +} + +/// `StreamConfig` defines the configuration for a JetStream stream. +public struct StreamConfig: Codable, Equatable { + /// The name of the stream, required and must be unique across the JetStream account. + public let name: String + + /// An optional description of the stream. + public var description: String? + + /// A list of subjects that the stream is listening on, cannot be set if the stream is a mirror. + public var subjects: [String]? + + /// The message retention policy for the stream, defaults to `LimitsPolicy`. + public var retention: RetentionPolicy + + /// The maximum number of consumers allowed for the stream. + public var maxConsumers: Int + + /// The maximum number of messages the stream will store. + public var maxMsgs: Int64 + + /// The maximum total size of messages the stream will store. + public var maxBytes: Int64 + + /// Defines the policy for handling messages when the stream's limits are reached. + public var discard: DiscardPolicy + + /// A flag to enable discarding new messages per subject when limits are reached. + public var discardNewPerSubject: Bool? + + /// The maximum age of messages that the stream will retain. + public var maxAge: NanoTimeInterval + + /// The maximum number of messages per subject that the stream will retain. + public var maxMsgsPerSubject: Int64 + + /// The maximum size of any single message in the stream. + public var maxMsgSize: Int32 + + /// Specifies the type of storage backend used for the stream (file or memory). + public var storage: StorageType + + /// The number of stream replicas in clustered JetStream. + public var replicas: Int + + /// A flag to disable acknowledging messages received by this stream. + public var noAck: Bool? + + /// The window within which to track duplicate messages. + public var duplicates: NanoTimeInterval? + + /// Used to declare where the stream should be placed via tags or an explicit cluster name. + public var placement: Placement? + + /// Configuration for mirroring another stream. + public var mirror: StreamSource? + + /// A list of other streams this stream sources messages from. + public var sources: [StreamSource]? + + /// Whether the stream does not allow messages to be published or deleted. + public var sealed: Bool? + + /// Restricts the ability to delete messages from a stream via the API. + public var denyDelete: Bool? + + /// Restricts the ability to purge messages from a stream via the API. + public var denyPurge: Bool? + + /// Allows the use of the Nats-Rollup header to replace all contents of a stream or subject in a stream with a single new message. + public var allowRollup: Bool? + + /// Specifies the message storage compression algorithm. + public var compression: StoreCompression + + /// The initial sequence number of the first message in the stream. + public var firstSeq: UInt64? + + /// Allows applying a transformation to matching messages' subjects. + public var subjectTransform: SubjectTransformConfig? + + /// Allows immediate republishing a message to the configured subject after it's stored. + public var rePublish: RePublish? + + /// Enables direct access to individual messages using direct get API. + public var allowDirect: Bool + + /// Enables direct access to individual messages from the origin stream using direct get API. + public var mirrorDirect: Bool + + /// Defines limits of certain values that consumers can set. + public var consumerLimits: StreamConsumerLimits? + + /// A set of application-defined key-value pairs for associating metadata on the stream. + public var metadata: [String: String]? + + public init( + name: String, + description: String? = nil, + subjects: [String]? = nil, + retention: RetentionPolicy = .limits, + maxConsumers: Int = -1, + maxMsgs: Int64 = -1, + maxBytes: Int64 = -1, + discard: DiscardPolicy = .old, + discardNewPerSubject: Bool? = nil, + maxAge: NanoTimeInterval = NanoTimeInterval(0), + maxMsgsPerSubject: Int64 = -1, + maxMsgSize: Int32 = -1, + storage: StorageType = .file, + replicas: Int = 1, + noAck: Bool? = nil, + duplicates: NanoTimeInterval? = nil, + placement: Placement? = nil, + mirror: StreamSource? = nil, + sources: [StreamSource]? = nil, + sealed: Bool? = nil, + denyDelete: Bool? = nil, + denyPurge: Bool? = nil, + allowRollup: Bool? = nil, + compression: StoreCompression = .none, + firstSeq: UInt64? = nil, + subjectTransform: SubjectTransformConfig? = nil, + rePublish: RePublish? = nil, + allowDirect: Bool = false, + mirrorDirect: Bool = false, + consumerLimits: StreamConsumerLimits? = nil, + metadata: [String: String]? = nil + ) { + self.name = name + self.description = description + self.subjects = subjects + self.retention = retention + self.maxConsumers = maxConsumers + self.maxMsgs = maxMsgs + self.maxBytes = maxBytes + self.discard = discard + self.discardNewPerSubject = discardNewPerSubject + self.maxAge = maxAge + self.maxMsgsPerSubject = maxMsgsPerSubject + self.maxMsgSize = maxMsgSize + self.storage = storage + self.replicas = replicas + self.noAck = noAck + self.duplicates = duplicates + self.placement = placement + self.mirror = mirror + self.sources = sources + self.sealed = sealed + self.denyDelete = denyDelete + self.denyPurge = denyPurge + self.allowRollup = allowRollup + self.compression = compression + self.firstSeq = firstSeq + self.subjectTransform = subjectTransform + self.rePublish = rePublish + self.allowDirect = allowDirect + self.mirrorDirect = mirrorDirect + self.consumerLimits = consumerLimits + self.metadata = metadata + } + + enum CodingKeys: String, CodingKey { + case name + case description + case subjects + case retention + case maxConsumers = "max_consumers" + case maxMsgs = "max_msgs" + case maxBytes = "max_bytes" + case discard + case discardNewPerSubject = "discard_new_per_subject" + case maxAge = "max_age" + case maxMsgsPerSubject = "max_msgs_per_subject" + case maxMsgSize = "max_msg_size" + case storage + case replicas = "num_replicas" + case noAck = "no_ack" + case duplicates = "duplicate_window" + case placement + case mirror + case sources + case sealed + case denyDelete = "deny_delete" + case denyPurge = "deny_purge" + case allowRollup = "allow_rollup_hdrs" + case compression + case firstSeq = "first_seq" + case subjectTransform = "subject_transform" + case rePublish = "republish" + case allowDirect = "allow_direct" + case mirrorDirect = "mirror_direct" + case consumerLimits = "consumer_limits" + case metadata + } + + // use custom encoder to omit certain fields if they are assigned default values + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(name, forKey: .name) // Always encode the name + + try description.map { try container.encode($0, forKey: .description) } + if let subjects = subjects, !subjects.isEmpty { + try container.encode(subjects, forKey: .subjects) + } + try retention != .limits ? container.encode(retention, forKey: .retention) : nil + try maxConsumers != -1 ? container.encode(maxConsumers, forKey: .maxConsumers) : nil + try maxMsgs != -1 ? container.encode(maxMsgs, forKey: .maxMsgs) : nil + try maxBytes != -1 ? container.encode(maxBytes, forKey: .maxBytes) : nil + try discard != .old ? container.encode(discard, forKey: .discard) : nil + try discardNewPerSubject.map { try container.encode($0, forKey: .discardNewPerSubject) } + try maxAge.value != 0 ? container.encode(maxAge, forKey: .maxAge) : nil + try maxMsgsPerSubject != -1 + ? container.encode(maxMsgsPerSubject, forKey: .maxMsgsPerSubject) : nil + try maxMsgSize != -1 ? container.encode(maxMsgSize, forKey: .maxMsgSize) : nil + try storage != .file ? container.encode(storage, forKey: .storage) : nil + try replicas != 1 ? container.encode(replicas, forKey: .replicas) : nil + try noAck.map { try container.encode($0, forKey: .noAck) } + try duplicates.map { try container.encode($0, forKey: .duplicates) } + try placement.map { try container.encode($0, forKey: .placement) } + try mirror.map { try container.encode($0, forKey: .mirror) } + if let sources = sources, !sources.isEmpty { + try container.encode(sources, forKey: .sources) + } + try sealed.map { try container.encode($0, forKey: .sealed) } + try denyDelete.map { try container.encode($0, forKey: .denyDelete) } + try denyPurge.map { try container.encode($0, forKey: .denyPurge) } + try allowRollup.map { try container.encode($0, forKey: .allowRollup) } + try compression != .none ? container.encode(compression, forKey: .compression) : nil + try firstSeq.map { try container.encode($0, forKey: .firstSeq) } + try subjectTransform.map { try container.encode($0, forKey: .subjectTransform) } + try rePublish.map { try container.encode($0, forKey: .rePublish) } + try allowDirect ? container.encode(allowDirect, forKey: .allowDirect) : nil + try mirrorDirect ? container.encode(mirrorDirect, forKey: .mirrorDirect) : nil + try consumerLimits.map { try container.encode($0, forKey: .consumerLimits) } + if let metadata = metadata, !metadata.isEmpty { + try container.encode(metadata, forKey: .metadata) + } + } +} + +/// `RetentionPolicy` determines how messages in a stream are retained. +public enum RetentionPolicy: String, Codable { + /// Messages are retained until any given limit is reached (MaxMsgs, MaxBytes or MaxAge). + case limits + + /// Messages are removed when all known observables have acknowledged a message. + case interest + + /// Messages are removed when the first subscriber acknowledges the message. + case workqueue +} + +/// `DiscardPolicy` determines how to proceed when limits of messages or bytes are reached. +public enum DiscardPolicy: String, Codable { + /// Remove older messages to return to the limits. + case old + + /// Fail to store new messages once the limits are reached. + case new +} + +/// `StorageType` determines how messages are stored for retention. +public enum StorageType: String, Codable { + /// Messages are stored on disk. + case file + + /// Messages are stored in memory. + case memory +} + +/// `Placement` guides the placement of streams in clustered JetStream. +public struct Placement: Codable, Equatable { + /// Tags used to match streams to servers in the cluster. + public var tags: [String]? + + /// Name of the cluster to which the stream should be assigned. + public var cluster: String? + + public init(tags: [String]? = nil, cluster: String? = nil) { + self.tags = tags + self.cluster = cluster + } +} + +/// `StreamSource` defines how streams can source from other streams. +public struct StreamSource: Codable, Equatable { + /// Name of the stream to source from. + public let name: String + + /// Sequence number to start sourcing from. + public let optStartSeq: UInt64? + + // Timestamp of messages to start sourcing from. + public let optStartTime: Date? + + /// Subject filter to replicate only matching messages. + public let filterSubject: String? + + /// Transforms applied to subjects. + public let subjectTransforms: [SubjectTransformConfig]? + + /// Configuration for external stream sources. + public let external: ExternalStream? + + public init( + name: String, optStartSeq: UInt64? = nil, optStartTime: Date? = nil, + filterSubject: String? = nil, subjectTransforms: [SubjectTransformConfig]? = nil, + external: ExternalStream? = nil + ) { + self.name = name + self.optStartSeq = optStartSeq + self.optStartTime = optStartTime + self.filterSubject = filterSubject + self.subjectTransforms = subjectTransforms + self.external = external + } + + enum CodingKeys: String, CodingKey { + case name + case optStartSeq = "opt_start_seq" + case optStartTime = "opt_start_time" + case filterSubject = "filter_subject" + case subjectTransforms = "subject_transforms" + case external + } +} + +/// `ExternalStream` qualifies access to a stream source in another account or JetStream domain. +public struct ExternalStream: Codable, Equatable { + + /// Subject prefix for importing API subjects. + public let apiPrefix: String + + /// Delivery subject for push consumers. + public let deliverPrefix: String + + public init(apiPrefix: String, deliverPrefix: String) { + self.apiPrefix = apiPrefix + self.deliverPrefix = deliverPrefix + } + + enum CodingKeys: String, CodingKey { + case apiPrefix = "api" + case deliverPrefix = "deliver" + } +} + +/// `StoreCompression` specifies the message storage compression algorithm. +public enum StoreCompression: String, Codable { + /// No compression is applied. + case none + + /// Uses the S2 compression algorithm. + case s2 +} + +/// `SubjectTransformConfig` configures subject transformations for incoming messages. +public struct SubjectTransformConfig: Codable, Equatable { + /// Subject pattern to match incoming messages. + public let source: String + + /// Subject pattern to remap the subject to. + public let destination: String + + public init(source: String, destination: String) { + self.source = source + self.destination = destination + } + + enum CodingKeys: String, CodingKey { + case source = "src" + case destination = "dest" + } +} + +/// `RePublish` configures republishing of messages once they are committed to a stream. +public struct RePublish: Codable, Equatable { + /// Subject pattern to match incoming messages. + public let source: String? + + /// Subject pattern to republish the subject to. + public let destination: String + + /// Flag to indicate if only headers should be republished. + public let headersOnly: Bool? + + public init(destination: String, source: String? = nil, headersOnly: Bool? = nil) { + self.destination = destination + self.source = source + self.headersOnly = headersOnly + } + + enum CodingKeys: String, CodingKey { + case source = "src" + case destination = "dest" + case headersOnly = "headers_only" + } +} + +/// `StreamConsumerLimits` defines the limits for a consumer on a stream. +public struct StreamConsumerLimits: Codable, Equatable { + /// Duration to clean up the consumer if inactive. + public var inactiveThreshold: NanoTimeInterval? + + /// Maximum number of outstanding unacknowledged messages. + public var maxAckPending: Int? + + public init(inactiveThreshold: NanoTimeInterval? = nil, maxAckPending: Int? = nil) { + self.inactiveThreshold = inactiveThreshold + self.maxAckPending = maxAckPending + } + + enum CodingKeys: String, CodingKey { + case inactiveThreshold = "inactive_threshold" + case maxAckPending = "max_ack_pending" + } +} + +/// `StreamState` represents the state of a JetStream stream at the time of the request. +public struct StreamState: Codable { + /// Number of messages stored in the stream. + public let messages: UInt64 + + /// Number of bytes stored in the stream. + public let bytes: UInt64 + + /// Sequence number of the first message. + public let firstSeq: UInt64 + + /// Timestamp of the first message. + public let firstTime: String + + /// Sequence number of the last message. + public let lastSeq: UInt64 + + /// Timestamp of the last message. + public let lastTime: String + + /// Number of consumers on the stream. + public let consumers: Int + + /// Sequence numbers of deleted messages. + public let deleted: [UInt64]? + + /// Number of messages deleted causing gaps in sequence numbers. + public let numDeleted: Int? + + /// Number of unique subjects received messages. + public let numSubjects: UInt64? + + /// Message count per subject. + public let subjects: [String: UInt64]? + + enum CodingKeys: String, CodingKey { + case messages + case bytes + case firstSeq = "first_seq" + case firstTime = "first_ts" + case lastSeq = "last_seq" + case lastTime = "last_ts" + case consumers = "consumer_count" + case deleted + case numDeleted = "num_deleted" + case numSubjects = "num_subjects" + case subjects + } +} + +/// `ClusterInfo` contains details about the cluster to which a stream belongs. +public struct ClusterInfo: Codable { + /// The name of the cluster. + public let name: String? + + /// The server name of the RAFT leader within the cluster. + public let leader: String? + + /// A list of peers that are part of the cluster. + public let replicas: [PeerInfo]? +} + +/// `StreamSourceInfo` provides information about an upstream stream source or mirror. +public struct StreamSourceInfo: Codable { + /// The name of the stream that is being replicated or mirrored. + public let name: String + + /// The lag in messages between this stream and the stream it mirrors or sources from. + public let lag: UInt64 + + /// The time since the last activity was detected for this stream. + public let active: NanoTimeInterval + + /// The subject filter used to replicate messages with matching subjects. + public let filterSubject: String? + + /// A list of subject transformations applied to messages as they are sourced. + public let subjectTransforms: [SubjectTransformConfig]? + + enum CodingKeys: String, CodingKey { + case name + case lag + case active + case filterSubject = "filter_subject" + case subjectTransforms = "subject_transforms" + } +} + +/// `PeerInfo` provides details about the peers in a cluster that support the stream or consumer. +public struct PeerInfo: Codable { + /// The server name of the peer within the cluster. + public let name: String + + /// Indicates if the peer is currently synchronized and up-to-date with the leader. + public let current: Bool + + /// Indicates if the peer is considered offline by the cluster. + public let offline: Bool? + + /// The time duration since this peer was last active. + public let active: NanoTimeInterval + + /// The number of uncommitted operations this peer is lagging behind the leader. + public let lag: UInt64? + + enum CodingKeys: String, CodingKey { + case name + case current + case offline + case active + case lag + } +} + +internal struct GetMessageResp: Codable { + internal struct StoredMessage: Codable { + public let subject: String + public let sequence: UInt64 + public let payload: Data + public let headers: Data? + public let time: String + + enum CodingKeys: String, CodingKey { + case subject + case sequence = "seq" + case payload = "data" + case headers = "hdrs" + case time + } + } + + internal let message: StoredMessage +} + +/// Represents a message persisted on a stream. +public struct StreamMessage { + + /// Subject of the message. + public let subject: String + + /// Sequence of the message. + public let sequence: UInt64 + + /// Raw payload of the message as a base64 encoded string. + public let payload: Data + + /// Message headers, if any. + public let headers: NatsHeaderMap? + + /// The time the message was published. + public let time: String + + internal init( + subject: String, sequence: UInt64, payload: Data, headers: NatsHeaderMap?, time: String + ) { + self.subject = subject + self.sequence = sequence + self.payload = payload + self.headers = headers + self.time = time + } + + internal init(from storedMsg: GetMessageResp.StoredMessage) throws { + self.subject = storedMsg.subject + self.sequence = storedMsg.sequence + self.payload = storedMsg.payload + if let headers = storedMsg.headers, let headersStr = String(data: headers, encoding: .utf8) + { + self.headers = try NatsHeaderMap(from: headersStr) + } else { + self.headers = nil + } + self.time = storedMsg.time + } +} diff --git a/Sources/Nats/BatchBuffer.swift b/Sources/Nats/BatchBuffer.swift new file mode 100644 index 0000000..448a5b1 --- /dev/null +++ b/Sources/Nats/BatchBuffer.swift @@ -0,0 +1,128 @@ +// Copyright 2024 The NATS 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 +// +// http://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. + +import Foundation +import NIO +import NIOConcurrencyHelpers + +extension BatchBuffer { + struct State { + private var buffer: ByteBuffer + private var allocator: ByteBufferAllocator + var waitingPromises: [(ClientOp, UnsafeContinuation)] = [] + var isWriteInProgress: Bool = false + + internal init(allocator: ByteBufferAllocator, batchSize: Int = 16 * 1024) { + self.allocator = allocator + self.buffer = allocator.buffer(capacity: batchSize) + } + + var readableBytes: Int { + return self.buffer.readableBytes + } + + mutating func clear() { + buffer.clear() + } + + mutating func getWriteBuffer() -> ByteBuffer { + var writeBuffer = allocator.buffer(capacity: buffer.readableBytes) + writeBuffer.writeBytes(buffer.readableBytesView) + buffer.clear() + + return writeBuffer + } + + mutating func writeMessage(_ message: ClientOp) { + self.buffer.writeClientOp(message) + } + } +} + +internal class BatchBuffer { + private let batchSize: Int + private let channel: Channel + private let state: NIOLockedValueBox + + init(channel: Channel, batchSize: Int = 16 * 1024) { + self.batchSize = batchSize + self.channel = channel + self.state = .init( + State(allocator: channel.allocator) + ) + } + + func writeMessage(_ message: ClientOp) async throws { + #if SWIFT_NATS_BATCH_BUFFER_DISABLED + let b = channel.allocator.buffer(bytes: data) + try await channel.writeAndFlush(b) + #else + // Batch writes and if we have more than the batch size + // already in the buffer await until buffer is flushed + // to handle any back pressure + try await withUnsafeThrowingContinuation { continuation in + self.state.withLockedValue { state in + guard state.readableBytes < self.batchSize else { + state.waitingPromises.append((message, continuation)) + return + } + + state.writeMessage(message) + self.flushWhenIdle(state: &state) + continuation.resume() + } + + } + #endif + } + + private func flushWhenIdle(state: inout State) { + // The idea is to keep writing to the buffer while a writeAndFlush() is + // in progress, so we can batch as many messages as possible. + guard !state.isWriteInProgress else { + return + } + // We need a separate write buffer so we can free the message buffer for more + // messages to be collected. + let writeBuffer = state.getWriteBuffer() + state.isWriteInProgress = true + + let writePromise = self.channel.eventLoop.makePromise(of: Void.self) + writePromise.futureResult.whenComplete { result in + self.state.withLockedValue { state in + state.isWriteInProgress = false + switch result { + case .success: + for (message, continuation) in state.waitingPromises { + state.writeMessage(message) + continuation.resume() + } + state.waitingPromises.removeAll() + case .failure(let error): + for (_, continuation) in state.waitingPromises { + continuation.resume(throwing: error) + } + state.waitingPromises.removeAll() + state.clear() + } + + // Check if there are any pending flushes + if state.readableBytes > 0 { + self.flushWhenIdle(state: &state) + } + } + } + + self.channel.writeAndFlush(writeBuffer, promise: writePromise) + } +} diff --git a/Sources/Nats/ConcurrentQueue.swift b/Sources/Nats/ConcurrentQueue.swift new file mode 100644 index 0000000..75a312a --- /dev/null +++ b/Sources/Nats/ConcurrentQueue.swift @@ -0,0 +1,32 @@ +// Copyright 2024 The NATS 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 +// +// http://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. + +import NIOConcurrencyHelpers + +internal class ConcurrentQueue { + private var elements: [T] = [] + private let lock = NIOLock() + + func enqueue(_ element: T) { + lock.lock() + defer { lock.unlock() } + elements.append(element) + } + + func dequeue() -> T? { + lock.lock() + defer { lock.unlock() } + guard !elements.isEmpty else { return nil } + return elements.removeFirst() + } +} diff --git a/Sources/Nats/Extensions/ByteBuffer+Writer.swift b/Sources/Nats/Extensions/ByteBuffer+Writer.swift new file mode 100644 index 0000000..d92b474 --- /dev/null +++ b/Sources/Nats/Extensions/ByteBuffer+Writer.swift @@ -0,0 +1,87 @@ +// Copyright 2024 The NATS 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 +// +// http://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. + +import Foundation +import NIO + +extension ByteBuffer { + mutating func writeClientOp(_ op: ClientOp) { + switch op { + case .publish((let subject, let reply, let payload, let headers)): + if let payload = payload { + self.reserveCapacity( + minimumWritableBytes: payload.count + subject.utf8.count + + NatsOperation.publish.rawValue.count + 12) + if headers != nil { + self.writeBytes(NatsOperation.hpublish.rawBytes) + } else { + self.writeBytes(NatsOperation.publish.rawBytes) + } + self.writeString(" ") + self.writeString(subject) + self.writeString(" ") + if let reply = reply { + self.writeString("\(reply) ") + } + if let headers = headers { + let headers = headers.toBytes() + let totalLen = headers.count + payload.count + let headersLen = headers.count + self.writeString("\(headersLen) \(totalLen)\r\n") + self.writeData(headers) + } else { + self.writeString("\(payload.count)\r\n") + } + self.writeData(payload) + self.writeString("\r\n") + } else { + self.reserveCapacity( + minimumWritableBytes: subject.utf8.count + NatsOperation.publish.rawValue.count + + 12) + self.writeBytes(NatsOperation.publish.rawBytes) + self.writeString(" ") + self.writeString(subject) + if let reply = reply { + self.writeString("\(reply) ") + } + self.writeString("\r\n") + } + + case .subscribe((let sid, let subject, let queue)): + if let queue { + self.writeString( + "\(NatsOperation.subscribe.rawValue) \(subject) \(queue) \(sid)\r\n") + } else { + self.writeString("\(NatsOperation.subscribe.rawValue) \(subject) \(sid)\r\n") + } + + case .unsubscribe((let sid, let max)): + if let max { + self.writeString("\(NatsOperation.unsubscribe.rawValue) \(sid) \(max)\r\n") + } else { + self.writeString("\(NatsOperation.unsubscribe.rawValue) \(sid)\r\n") + } + case .connect(let info): + // This encode can't actually fail + let json = try! JSONEncoder().encode(info) + self.reserveCapacity(minimumWritableBytes: json.count + 5) + self.writeString("\(NatsOperation.connect.rawValue) ") + self.writeData(json) + self.writeString("\r\n") + case .ping: + self.writeString("\(NatsOperation.ping.rawValue)\r\n") + case .pong: + self.writeString("\(NatsOperation.pong.rawValue)\r\n") + } + } +} diff --git a/Sources/Nats/Extensions/Data+Base64.swift b/Sources/Nats/Extensions/Data+Base64.swift new file mode 100644 index 0000000..9216a03 --- /dev/null +++ b/Sources/Nats/Extensions/Data+Base64.swift @@ -0,0 +1,24 @@ +// Copyright 2024 The NATS 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 +// +// http://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. + +import Foundation + +extension Data { + /// Swift does not provide a way to encode data to base64 without padding in URL safe way. + func base64EncodedURLSafeNotPadded() -> String { + return self.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .trimmingCharacters(in: CharacterSet(charactersIn: "=")) + } +} diff --git a/Sources/Nats/Extensions/Data+Parser.swift b/Sources/Nats/Extensions/Data+Parser.swift new file mode 100644 index 0000000..aa5c538 --- /dev/null +++ b/Sources/Nats/Extensions/Data+Parser.swift @@ -0,0 +1,184 @@ +// Copyright 2024 The NATS 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 +// +// http://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. + +import Foundation + +extension Data { + private static let cr = UInt8(ascii: "\r") + private static let lf = UInt8(ascii: "\n") + private static let crlf = Data([cr, lf]) + private static var currentNum = 0 + private static var errored = false + internal static let versionLinePrefix = "NATS/1.0" + + func removePrefix(_ prefix: Data) -> Data { + guard self.starts(with: prefix) else { return self } + return self.dropFirst(prefix.count) + } + + func split( + separator: Data, maxSplits: Int = .max, omittingEmptySubsequences: Bool = true + ) + -> [Data] + { + var chunks: [Data] = [] + var start = startIndex + var end = startIndex + var splitsCount = 0 + + while end < count { + if splitsCount >= maxSplits { + break + } + if self[start.. NatsOperation? { + guard self.count > 2 else { return nil } + for operation in NatsOperation.allOperations() { + if self.starts(with: operation.rawBytes) { + return operation + } + } + return nil + } + + func starts(with bytes: [UInt8]) -> Bool { + guard self.count >= bytes.count else { return false } + return self.prefix(bytes.count).elementsEqual(bytes) + } + + internal mutating func prepend(_ other: Data) { + self = other + self + } + + internal func parseOutMessages() throws -> (ops: [ServerOp], remainder: Data?) { + var serverOps = [ServerOp]() + var startIndex = self.startIndex + var remainder: Data? + + while startIndex < self.endIndex { + var nextLineStartIndex: Int + var lineData: Data + if let range = self[startIndex...].range(of: Data.crlf) { + let lineEndIndex = range.lowerBound + nextLineStartIndex = + self.index(range.upperBound, offsetBy: 0, limitedBy: self.endIndex) + ?? self.endIndex + lineData = self[startIndex.. endIndex { + remainder = self[startIndex.. msg.headersLength { + payload = Data() + } + var headers = NatsHeaderMap() + + // if the whole msg length (including training crlf) is longer + // than the remaining chunk, break and return the remainder + if payloadEndIndex + Data.crlf.count > endIndex { + remainder = self[startIndex.. String? { + if let str = String(data: self, encoding: .utf8) { + return str + } + return nil + } +} diff --git a/Sources/Nats/Extensions/String+Utilities.swift b/Sources/Nats/Extensions/String+Utilities.swift new file mode 100644 index 0000000..f019dcf --- /dev/null +++ b/Sources/Nats/Extensions/String+Utilities.swift @@ -0,0 +1,38 @@ +// Copyright 2024 The NATS 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 +// +// http://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. + +import Foundation + +extension String { + private static let charactersToTrim: CharacterSet = .whitespacesAndNewlines.union( + CharacterSet(charactersIn: "'")) + + static func hash() -> String { + let uuid = String.uuid() + return uuid[0...7] + } + + func trimWhitespacesAndApostrophes() -> String { + return self.trimmingCharacters(in: String.charactersToTrim) + } + + static func uuid() -> String { + return UUID().uuidString.trimmingCharacters(in: .punctuationCharacters) + } + + subscript(bounds: CountableClosedRange) -> String { + let start = index(startIndex, offsetBy: bounds.lowerBound) + let end = index(startIndex, offsetBy: bounds.upperBound) + return String(self[start...end]) + } +} diff --git a/Sources/Nats/HTTPUpgradeRequestHandler.swift b/Sources/Nats/HTTPUpgradeRequestHandler.swift new file mode 100644 index 0000000..a7efea8 --- /dev/null +++ b/Sources/Nats/HTTPUpgradeRequestHandler.swift @@ -0,0 +1,182 @@ +// Copyright 2024 The NATS 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 +// +// http://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. + +import NIO +import NIOHTTP1 +import NIOWebSocket + +// Adapted from https://github.com/vapor/websocket-kit/blob/main/Sources/WebSocketKit/HTTPUpgradeRequestHandler.swift +internal final class HTTPUpgradeRequestHandler: ChannelInboundHandler, RemovableChannelHandler { + typealias InboundIn = HTTPClientResponsePart + typealias OutboundOut = HTTPClientRequestPart + + let host: String + let path: String + let query: String? + let headers: HTTPHeaders + let upgradePromise: EventLoopPromise + + private var requestSent = false + + init( + host: String, path: String, query: String?, headers: HTTPHeaders, + upgradePromise: EventLoopPromise + ) { + self.host = host + self.path = path + self.query = query + self.headers = headers + self.upgradePromise = upgradePromise + } + + func channelActive(context: ChannelHandlerContext) { + self.sendRequest(context: context) + context.fireChannelActive() + } + + func handlerAdded(context: ChannelHandlerContext) { + if context.channel.isActive { + self.sendRequest(context: context) + } + } + + private func sendRequest(context: ChannelHandlerContext) { + if self.requestSent { + // we might run into this handler twice, once in handlerAdded and once in channelActive. + return + } + self.requestSent = true + + var headers = self.headers + headers.add(name: "Host", value: self.host) + + var uri: String + if self.path.hasPrefix("/") || self.path.hasPrefix("ws://") || self.path.hasPrefix("wss://") + { + uri = self.path + } else { + uri = "/" + self.path + } + + if let query = self.query { + uri += "?\(query)" + } + + let requestHead = HTTPRequestHead( + version: HTTPVersion(major: 1, minor: 1), + method: .GET, + uri: uri, + headers: headers + ) + context.write(self.wrapOutboundOut(.head(requestHead)), promise: nil) + + let emptyBuffer = context.channel.allocator.buffer(capacity: 0) + let body = HTTPClientRequestPart.body(.byteBuffer(emptyBuffer)) + context.write(self.wrapOutboundOut(body), promise: nil) + + context.writeAndFlush(self.wrapOutboundOut(.end(nil)), promise: nil) + } + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + // `NIOHTTPClientUpgradeHandler` should consume the first response in the success case, + // any response we see here indicates a failure. Report the failure and tidy up at the end of the response. + let clientResponse = self.unwrapInboundIn(data) + switch clientResponse { + case .head(let responseHead): + self.upgradePromise.fail( + NatsError.ClientError.invalidConnection("ws error \(responseHead)")) + case .body: break + case .end: + context.close(promise: nil) + } + } + + func errorCaught(context: ChannelHandlerContext, error: Error) { + self.upgradePromise.fail(error) + context.close(promise: nil) + } +} + +internal final class WebSocketByteBufferCodec: ChannelDuplexHandler { + typealias InboundIn = WebSocketFrame + typealias InboundOut = ByteBuffer + typealias OutboundIn = ByteBuffer + typealias OutboundOut = WebSocketFrame + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + let frame = unwrapInboundIn(data) + + switch frame.opcode { + case .binary: + context.fireChannelRead(wrapInboundOut(frame.data)) + case .text: + preconditionFailure("We will never receive a text frame") + case .continuation: + preconditionFailure("We will never receive a continuation frame") + case .pong: + break + case .ping: + if frame.fin { + var frameData = frame.data + let maskingKey = frame.maskKey + if let maskingKey = maskingKey { + frameData.webSocketUnmask(maskingKey) + } + let bb = context.channel.allocator.buffer(bytes: frameData.readableBytesView) + self.send( + bb, + context: context, + opcode: .pong + ) + } else { + context.close(promise: nil) + } + default: + // We ignore all other frames. + break + } + } + + func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise?) { + let buffer = unwrapOutboundIn(data) + let frame = WebSocketFrame( + fin: true, + opcode: .binary, + maskKey: self.makeMaskKey(), + data: buffer + ) + context.write(wrapOutboundOut(frame), promise: promise) + } + + public func send( + _ data: ByteBuffer, + context: ChannelHandlerContext, + opcode: WebSocketOpcode = .binary, + fin: Bool = true, + promise: EventLoopPromise? = nil + ) { + let frame = WebSocketFrame( + fin: fin, + opcode: opcode, + maskKey: self.makeMaskKey(), + data: data + ) + context.writeAndFlush(wrapOutboundOut(frame), promise: promise) + } + + func makeMaskKey() -> WebSocketMaskingKey? { + /// See https://github.com/apple/swift/issues/66099 + var generator = SystemRandomNumberGenerator() + return WebSocketMaskingKey.random(using: &generator) + } +} diff --git a/Sources/Nats/NatsClient/NatsClient+Events.swift b/Sources/Nats/NatsClient/NatsClient+Events.swift new file mode 100644 index 0000000..23551ca --- /dev/null +++ b/Sources/Nats/NatsClient/NatsClient+Events.swift @@ -0,0 +1,57 @@ +// Copyright 2024 The NATS 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 +// +// http://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. + +import Foundation + +extension NatsClient { + + /// Registers a callback for given event types. + /// + /// - Parameters: + /// - events: an array of ``NatsEventKind`` for which the handler will be invoked. + /// - handler: a callback invoked upon triggering a specific event. + /// + /// - Returns an ID of the registered listener which can be used to disable it. + @discardableResult + public func on(_ events: [NatsEventKind], _ handler: @escaping (NatsEvent) -> Void) -> String { + guard let connectionHandler = self.connectionHandler else { + return "" + } + return connectionHandler.addListeners(for: events, using: handler) + } + + /// Registers a callback for given event type. + /// + /// - Parameters: + /// - events: a ``NatsEventKind`` for which the handler will be invoked. + /// - handler: a callback invoked upon triggering a specific event. + /// + /// - Returns an ID of the registered listener which can be used to disable it. + @discardableResult + public func on(_ event: NatsEventKind, _ handler: @escaping (NatsEvent) -> Void) -> String { + guard let connectionHandler = self.connectionHandler else { + return "" + } + return connectionHandler.addListeners(for: [event], using: handler) + } + + /// Disables the event listener. + /// + /// - Parameter id: an ID of a listener to be disabled (returned when creating it). + public func off(_ id: String) { + guard let connectionHandler = self.connectionHandler else { + return + } + connectionHandler.removeListener(id) + } +} diff --git a/Sources/Nats/NatsClient/NatsClient.swift b/Sources/Nats/NatsClient/NatsClient.swift new file mode 100755 index 0000000..61e0ab1 --- /dev/null +++ b/Sources/Nats/NatsClient/NatsClient.swift @@ -0,0 +1,352 @@ +// Copyright 2024 The NATS 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 +// +// http://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. + +import Dispatch +import Foundation +import Logging +import NIO +import NIOFoundationCompat +import Nuid + +public var logger = Logger(label: "Nats") + +/// NatsClient connection states +public enum NatsState { + case pending + case connecting + case connected + case disconnected + case closed + case suspended +} + +public struct Auth { + var user: String? + var password: String? + var token: String? + var credentialsPath: URL? + var nkeyPath: URL? + var nkey: String? + + init() { + + } + + init(user: String, password: String) { + self.user = user + self.password = password + } + init(token: String) { + self.token = token + } + static func fromCredentials(_ credentials: URL) -> Auth { + var auth = Auth() + auth.credentialsPath = credentials + return auth + } + static func fromNkey(_ nkey: URL) -> Auth { + var auth = Auth() + auth.nkeyPath = nkey + return auth + } + static func fromNkey(_ nkey: String) -> Auth { + var auth = Auth() + auth.nkey = nkey + return auth + } +} + +public class NatsClient { + public var connectedUrl: URL? { + connectionHandler?.connectedUrl + } + internal let allocator = ByteBufferAllocator() + internal var buffer: ByteBuffer + internal var connectionHandler: ConnectionHandler? + internal var inboxPrefix: String = "_INBOX." + + internal init() { + self.buffer = allocator.buffer(capacity: 1024) + } + + /// Returns a new inbox subject using the configured prefix and a generated NUID. + public func newInbox() -> String { + return inboxPrefix + nextNuid() + } +} + +extension NatsClient { + + /// Connects to a NATS server using configuration provided via ``NatsClientOptions``. + /// If ``NatsClientOptions/retryOnfailedConnect()`` is used, `connect()` + /// will not wait until the connection is established but rather return immediatelly. + /// + /// > **Throws:** + /// > - ``NatsError/ConnectError/invalidConfig(_:)`` if the provided configuration is invalid + /// > - ``NatsError/ConnectError/tlsFailure(_:)`` if upgrading to TLS connection fails + /// > - ``NatsError/ConnectError/timeout`` if there was a timeout waiting to establish TCP connection + /// > - ``NatsError/ConnectError/dns(_:)`` if there was an error during dns lookup + /// > - ``NatsError/ConnectError/io`` if there was other error establishing connection + /// > - ``NatsError/ServerError/autorization(_:)`` if connection could not be established due to invalid/missing/expired auth + /// > - ``NatsError/ServerError/other(_:)`` if the server responds to client connection with a different error (e.g. max connections exceeded) + public func connect() async throws { + logger.debug("connect") + guard let connectionHandler = self.connectionHandler else { + throw NatsError.ClientError.internalError("empty connection handler") + } + + // Check if already connected or in invalid state for connect() + let currentState = connectionHandler.currentState + switch currentState { + case .connected, .connecting: + throw NatsError.ClientError.alreadyConnected + case .closed: + throw NatsError.ClientError.connectionClosed + case .suspended: + throw NatsError.ClientError.invalidConnection( + "connection is suspended, use resume() instead") + case .pending, .disconnected: + // These states allow connection/reconnection + break + } + + // Set state to connecting immediately to prevent concurrent connect() calls + connectionHandler.setState(.connecting) + + do { + if !connectionHandler.retryOnFailedConnect { + try await connectionHandler.connect() + connectionHandler.setState(.connected) + connectionHandler.fire(.connected) + } else { + connectionHandler.handleReconnect() + } + } catch { + // Reset state on connection failure + connectionHandler.setState(.disconnected) + throw error + } + } + + /// Closes a connection to NATS server. + /// + /// - Throws ``NatsError/ClientError/connectionClosed`` if the conneciton is already closed. + public func close() async throws { + logger.debug("close") + guard let connectionHandler = self.connectionHandler else { + throw NatsError.ClientError.internalError("empty connection handler") + } + if case .closed = connectionHandler.currentState { + throw NatsError.ClientError.connectionClosed + } + try await connectionHandler.close() + } + + /// Suspends a connection to NATS server. + /// A suspended connection does not receive messages on subscriptions. + /// It can be resumed using ``resume()`` which restores subscriptions on successful reconnect. + /// + /// - Throws ``NatsError/ClientError/connectionClosed`` if the conneciton is closed. + public func suspend() async throws { + logger.debug("suspend") + guard let connectionHandler = self.connectionHandler else { + throw NatsError.ClientError.internalError("empty connection handler") + } + if case .closed = connectionHandler.currentState { + throw NatsError.ClientError.connectionClosed + } + try await connectionHandler.suspend() + } + + /// Resumes a suspended connection. + /// ``resume()`` will not wait for successful reconnection but rather trigger a reconnect process and return. + /// Register ``NatsEvent`` using ``NatsClient/on()`` to wait for successful reconnection. + /// + /// - Throws ``NatsError/ClientError`` if the conneciton is not in suspended state. + public func resume() async throws { + logger.debug("resume") + guard let connectionHandler = self.connectionHandler else { + throw NatsError.ClientError.internalError("empty connection handler") + } + if case .closed = connectionHandler.currentState { + throw NatsError.ClientError.connectionClosed + } + try await connectionHandler.resume() + } + + /// Forces a reconnect attempt to the server. + /// This is a non-blocking operation and will start the process without waiting for it to complete. + /// + /// - Throws ``NatsError/ClientError/connectionClosed`` if the conneciton is closed. + public func reconnect() async throws { + logger.debug("resume") + guard let connectionHandler = self.connectionHandler else { + throw NatsError.ClientError.internalError("empty connection handler") + } + if case .closed = connectionHandler.currentState { + throw NatsError.ClientError.connectionClosed + } + try await connectionHandler.reconnect() + } + + /// Publishes a message on a given subject. + /// + /// - Parameters: + /// - payload: data to be published. + /// - subject: a NATS subject on which the message will be published. + /// - reply: optional reply subject when publishing a request. + /// - headers: optional message headers. + /// + /// > **Throws:** + /// > - ``NatsError/ClientError/connectionClosed`` if the conneciton is closed. + /// > - ``NatsError/ClientError/io(_:)`` if there is an error writing message to a TCP socket (e.g. bloken pipe). + public func publish( + _ payload: Data, subject: String, reply: String? = nil, headers: NatsHeaderMap? = nil + ) async throws { + logger.debug("publish") + guard let connectionHandler = self.connectionHandler else { + throw NatsError.ClientError.internalError("empty connection handler") + } + if case .closed = connectionHandler.currentState { + throw NatsError.ClientError.connectionClosed + } + try await connectionHandler.write( + operation: ClientOp.publish((subject, reply, payload, headers))) + } + + /// Sends a blocking request on a given subject. + /// + /// - Parameters: + /// - payload: data to be published in the request. + /// - subject: a NATS subject on which the request will be published. + /// - headers: optional request headers. + /// - timeout: request timeout - defaults to 5 seconds. + /// + /// - Returns a ``NatsMessage`` containing the response. + /// + /// > **Throws:** + /// > - ``NatsError/ClientError/connectionClosed`` if the conneciton is closed. + /// > - ``NatsError/ClientError/io(_:)`` if there is an error writing message to a TCP socket (e.g. bloken pipe). + /// > - ``NatsError/RequestError/noResponders`` if there are no responders available for the request. + /// > - ``NatsError/RequestError/timeout`` if there was a timeout waiting for the response. + public func request( + _ payload: Data, subject: String, headers: NatsHeaderMap? = nil, timeout: TimeInterval = 5 + ) async throws -> NatsMessage { + logger.debug("request") + guard let connectionHandler = self.connectionHandler else { + throw NatsError.ClientError.internalError("empty connection handler") + } + if case .closed = connectionHandler.currentState { + throw NatsError.ClientError.connectionClosed + } + let inbox = newInbox() + + let sub = try await connectionHandler.subscribe(inbox) + try await sub.unsubscribe(after: 1) + try await connectionHandler.write( + operation: ClientOp.publish((subject, inbox, payload, headers))) + + return try await withThrowingTaskGroup( + of: NatsMessage?.self + ) { group in + group.addTask { + do { + return try await sub.makeAsyncIterator().next() + } catch NatsError.SubscriptionError.permissionDenied { + throw NatsError.RequestError.permissionDenied + } + } + + // task for the timeout + group.addTask { + try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000)) + return nil + } + + for try await result in group { + // if the result is not empty, return it (or throw status error) + if let msg = result { + group.cancelAll() + if let status = msg.status, status == StatusCode.noResponders { + throw NatsError.RequestError.noResponders + } + return msg + } else { + try await sub.unsubscribe() + group.cancelAll() + throw NatsError.RequestError.timeout + } + } + + // this should not be reachable + throw NatsError.ClientError.internalError("error waiting for response") + } + } + + /// Flushes the internal buffer ensuring that all messages are sent. + /// + /// - Throws ``NatsError/ClientError/connectionClosed`` if the conneciton is closed. + public func flush() async throws { + logger.debug("flush") + guard let connectionHandler = self.connectionHandler else { + throw NatsError.ClientError.internalError("empty connection handler") + } + if case .closed = connectionHandler.currentState { + throw NatsError.ClientError.connectionClosed + } + connectionHandler.channel?.flush() + } + + /// Subscribes to a subject to receive messages. + /// + /// - Parameters: + /// - subject:a subject the client want's to subscribe to. + /// - queue: optional queue group name. + /// + /// - Returns a ``NatsSubscription`` allowing iteration over incoming messages. + /// + /// > **Throws:** + /// > - ``NatsError/ClientError/connectionClosed`` if the conneciton is closed. + /// > - ``NatsError/ClientError/io(_:)`` if there is an error sending the SUB request to the server. + /// > - ``NatsError/SubscriptionError/invalidSubject`` if the provided subject is invalid. + /// > - ``NatsError/SubscriptionError/invalidQueue`` if the provided queue group is invalid. + public func subscribe(subject: String, queue: String? = nil) async throws -> NatsSubscription { + logger.info("subscribe to subject \(subject)") + guard let connectionHandler = self.connectionHandler else { + throw NatsError.ClientError.internalError("empty connection handler") + } + if case .closed = connectionHandler.currentState { + throw NatsError.ClientError.connectionClosed + } + return try await connectionHandler.subscribe(subject, queue: queue) + } + + /// Sends a PING to the server, returning the time it took for the server to respond. + /// + /// - Returns rtt of the request. + /// + /// > **Throws:** + /// > - ``NatsError/ClientError/connectionClosed`` if the conneciton is closed. + /// > - ``NatsError/ClientError/io(_:)`` if there is an error sending the SUB request to the server. + public func rtt() async throws -> TimeInterval { + guard let connectionHandler = self.connectionHandler else { + throw NatsError.ClientError.internalError("empty connection handler") + } + if case .closed = connectionHandler.currentState { + throw NatsError.ClientError.connectionClosed + } + let ping = RttCommand.makeFrom(channel: connectionHandler.channel) + await connectionHandler.sendPing(ping) + return try await ping.getRoundTripTime() + } +} diff --git a/Sources/Nats/NatsClient/NatsClientOptions.swift b/Sources/Nats/NatsClient/NatsClientOptions.swift new file mode 100644 index 0000000..c9d5c42 --- /dev/null +++ b/Sources/Nats/NatsClient/NatsClientOptions.swift @@ -0,0 +1,202 @@ +// Copyright 2024 The NATS 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 +// +// http://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. + +import Dispatch +import Foundation +import Logging +import NIO +import NIOFoundationCompat + +public class NatsClientOptions { + private var urls: [URL] = [] + private var pingInterval: TimeInterval = 60.0 + private var reconnectWait: TimeInterval = 2.0 + private var maxReconnects: Int? + private var initialReconnect = false + private var noRandomize = false + private var auth: Auth? = nil + private var withTls = false + private var tlsFirst = false + private var rootCertificate: URL? = nil + private var clientCertificate: URL? = nil + private var clientKey: URL? = nil + private var inboxPrefix: String = "_INBOX." + + public init() {} + + /// Sets the prefix for inbox subjects used for request/reply. + /// Defaults to "_INBOX." + public func inboxPrefix(_ prefix: String) -> NatsClientOptions { + if prefix.isEmpty { + self.inboxPrefix = "_INBOX." + return self + } + if prefix.last != "." { + self.inboxPrefix = prefix + "." + return self + } + self.inboxPrefix = prefix + return self + } + + /// A list of server urls that a client can connect to. + public func urls(_ urls: [URL]) -> NatsClientOptions { + self.urls = urls + return self + } + + /// A single url that the client can connect to. + public func url(_ url: URL) -> NatsClientOptions { + self.urls = [url] + return self + } + + /// The interval with which the client will send pings to NATS server. + /// Defaults to 60s. + public func pingInterval(_ pingInterval: TimeInterval) -> NatsClientOptions { + self.pingInterval = pingInterval + return self + } + + /// Wait time between reconnect attempts. + /// Defaults to 2s. + public func reconnectWait(_ reconnectWait: TimeInterval) -> NatsClientOptions { + self.reconnectWait = reconnectWait + return self + } + + /// Maximum number of reconnect attempts after each disconnect. + /// Defaults to unlimited. + public func maxReconnects(_ maxReconnects: Int) -> NatsClientOptions { + self.maxReconnects = maxReconnects + return self + } + + /// Username and password used to connect to the server. + public func usernameAndPassword(_ username: String, _ password: String) -> NatsClientOptions { + if self.auth == nil { + self.auth = Auth(user: username, password: password) + } else { + self.auth?.user = username + self.auth?.password = password + } + return self + } + + /// Token used for token auth to NATS server. + public func token(_ token: String) -> NatsClientOptions { + if self.auth == nil { + self.auth = Auth(token: token) + } else { + self.auth?.token = token + } + return self + } + + /// The location of a credentials file containing user JWT and Nkey seed. + public func credentialsFile(_ credentials: URL) -> NatsClientOptions { + if self.auth == nil { + self.auth = Auth.fromCredentials(credentials) + } else { + self.auth?.credentialsPath = credentials + } + return self + } + + /// The location of a public nkey file. + /// This and ``NatsClientOptions/nkey(_:)`` are mutually exclusive. + public func nkeyFile(_ nkey: URL) -> NatsClientOptions { + if self.auth == nil { + self.auth = Auth.fromNkey(nkey) + } else { + self.auth?.nkeyPath = nkey + } + return self + } + + /// Public nkey. + /// This and ``NatsClientOptions/nkeyFile(_:)`` are mutually exclusive. + public func nkey(_ nkey: String) -> NatsClientOptions { + if self.auth == nil { + self.auth = Auth.fromNkey(nkey) + } else { + self.auth?.nkey = nkey + } + return self + } + + /// Indicates whether the client requires an SSL connection. + public func requireTls() -> NatsClientOptions { + self.withTls = true + return self + } + + /// Indicates whether the client will attempt to perform a TLS handshake first, that is + /// before receiving the INFO protocol. This requires the server to also be + /// configured with such option, otherwise the connection will fail. + public func withTlsFirst() -> NatsClientOptions { + self.tlsFirst = true + return self + } + + /// The location of a root CAs file. + public func rootCertificates(_ rootCertificate: URL) -> NatsClientOptions { + self.rootCertificate = rootCertificate + return self + } + + /// The location of a client cert file. + public func clientCertificate(_ clientCertificate: URL, _ clientKey: URL) -> NatsClientOptions { + self.clientCertificate = clientCertificate + self.clientKey = clientKey + return self + } + + /// Indicates whether the client will retain the order of URLs to connect to provided in ``NatsClientOptions/urls(_:)`` + /// If not set, the client will randomize the server pool. + public func retainServersOrder() -> NatsClientOptions { + self.noRandomize = true + return self + } + + /// By default, ``NatsClient/connect()`` will return an error if + /// the connection to the server cannot be established. + /// + /// Setting `retryOnfailedConnect()` makes the client + /// establish the connection in the background even if the initial connect fails. + public func retryOnfailedConnect() -> NatsClientOptions { + self.initialReconnect = true + return self + } + + public func build() -> NatsClient { + let client = NatsClient() + client.inboxPrefix = inboxPrefix + client.connectionHandler = ConnectionHandler( + inputBuffer: client.buffer, + urls: urls, + reconnectWait: reconnectWait, + maxReconnects: maxReconnects, + retainServersOrder: noRandomize, + pingInterval: pingInterval, + auth: auth, + requireTls: withTls, + tlsFirst: tlsFirst, + clientCertificate: clientCertificate, + clientKey: clientKey, + rootCertificate: rootCertificate, + retryOnFailedConnect: initialReconnect + ) + return client + } +} diff --git a/Sources/Nats/NatsConnection.swift b/Sources/Nats/NatsConnection.swift new file mode 100644 index 0000000..a12be50 --- /dev/null +++ b/Sources/Nats/NatsConnection.swift @@ -0,0 +1,1098 @@ +// Copyright 2024 The NATS 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 +// +// http://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. + +import Atomics +import Dispatch +import Foundation +import NIO +import NIOConcurrencyHelpers +import NIOFoundationCompat +import NIOHTTP1 +import NIOSSL +import NIOWebSocket +import NKeys + +class ConnectionHandler: ChannelInboundHandler { + let lang = "Swift" + let version = "0.0.1" + + internal var connectedUrl: URL? + internal let allocator = ByteBufferAllocator() + internal var inputBuffer: ByteBuffer + internal var channel: Channel? + + private var eventHandlerStore: [NatsEventKind: [NatsEventHandler]] = [:] + + // Connection options + internal var retryOnFailedConnect = false + private var urls: [URL] + // nanoseconds representation of TimeInterval + private let reconnectWait: UInt64 + private let maxReconnects: Int? + private let retainServersOrder: Bool + private let pingInterval: TimeInterval + private let requireTls: Bool + private let tlsFirst: Bool + private var rootCertificate: URL? + private var clientCertificate: URL? + private var clientKey: URL? + + typealias InboundIn = ByteBuffer + private let state = NIOLockedValueBox(NatsState.pending) + private let subscriptions = NIOLockedValueBox([UInt64: NatsSubscription]()) + + // Helper methods for state access + internal var currentState: NatsState { + state.withLockedValue { $0 } + } + + internal func setState(_ newState: NatsState) { + state.withLockedValue { $0 = newState } + } + + private var subscriptionCounter = ManagedAtomic(0) + private var serverInfo: ServerInfo? + private var auth: Auth? + private let parseRemainder = NIOLockedValueBox(nil) + private var pingTask: RepeatedTask? + private var outstandingPings = ManagedAtomic(0) + private var reconnectAttempts = 0 + private var reconnectTask: Task<(), Error>? = nil + private let capturedConnectionError = NIOLockedValueBox(nil) + + private var group: MultiThreadedEventLoopGroup + + private let serverInfoContinuation = NIOLockedValueBox?>( + nil) + private let connectionEstablishedContinuation = NIOLockedValueBox< + CheckedContinuation? + >(nil) + + private let pingQueue = ConcurrentQueue() + private(set) var batchBuffer: BatchBuffer? + + init( + inputBuffer: ByteBuffer, urls: [URL], reconnectWait: TimeInterval, maxReconnects: Int?, + retainServersOrder: Bool, + pingInterval: TimeInterval, auth: Auth?, requireTls: Bool, tlsFirst: Bool, + clientCertificate: URL?, clientKey: URL?, + rootCertificate: URL?, retryOnFailedConnect: Bool + ) { + self.urls = urls + self.group = .singleton + self.inputBuffer = allocator.buffer(capacity: 1024) + self.reconnectWait = UInt64(reconnectWait * 1_000_000_000) + self.maxReconnects = maxReconnects + self.retainServersOrder = retainServersOrder + self.auth = auth + self.pingInterval = pingInterval + self.requireTls = requireTls + self.tlsFirst = tlsFirst + self.clientCertificate = clientCertificate + self.clientKey = clientKey + self.rootCertificate = rootCertificate + self.retryOnFailedConnect = retryOnFailedConnect + } + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + var byteBuffer = self.unwrapInboundIn(data) + inputBuffer.writeBuffer(&byteBuffer) + } + + func channelReadComplete(context: ChannelHandlerContext) { + guard inputBuffer.readableBytes > 0 else { + return + } + + var inputChunk = Data(buffer: inputBuffer) + + let remainder = parseRemainder.withLockedValue { value in + let current = value + value = nil + return current + } + + if let remainder = remainder, !remainder.isEmpty { + inputChunk.prepend(remainder) + } + + let parseResult: (ops: [ServerOp], remainder: Data?) + do { + parseResult = try inputChunk.parseOutMessages() + } catch { + // if parsing throws an error, clear buffer and remainder, then reconnect + inputBuffer.clear() + parseRemainder.withLockedValue { $0 = nil } + context.fireErrorCaught(error) + return + } + if let remainder = parseResult.remainder { + parseRemainder.withLockedValue { $0 = remainder } + } + for op in parseResult.ops { + // Only resume the server info continuation when we actually receive + // an INFO or -ERR op. Do NOT clear it for unrelated ops. + switch op { + case .error(let err): + if let continuation = serverInfoContinuation.withLockedValue({ cont in + let toResume = cont + cont = nil + return toResume + }) { + logger.debug("server info error") + continuation.resume(throwing: err) + continue + } + case .info(let info): + if let continuation = serverInfoContinuation.withLockedValue({ cont in + let toResume = cont + cont = nil + return toResume + }) { + logger.debug("server info") + continuation.resume(returning: info) + continue + } + default: + break + } + + let connEstablishedCont = connectionEstablishedContinuation.withLockedValue { cont in + let toResume = cont + cont = nil + return toResume + } + + if let continuation = connEstablishedCont { + logger.debug("conn established") + switch op { + case .error(let err): + continuation.resume(throwing: err) + default: + continuation.resume() + } + continue + } + + switch op { + case .ping: + logger.debug("ping") + Task { + do { + try await self.write(operation: .pong) + } catch let err as NatsError.ClientError { + logger.error("error sending pong: \(err)") + self.fire( + .error(err)) + } catch { + logger.error("unexpected error sending pong: \(error)") + } + } + case .pong: + logger.debug("pong") + self.outstandingPings.store(0, ordering: AtomicStoreOrdering.relaxed) + self.pingQueue.dequeue()?.setRoundTripTime() + case .error(let err): + logger.debug("error \(err)") + + switch err { + case .staleConnection, .maxConnectionsExceeded: + inputBuffer.clear() + parseRemainder.withLockedValue { $0 = nil } + context.fireErrorCaught(err) + case .permissionsViolation(let operation, let subject, _): + switch operation { + case .subscribe: + subscriptions.withLockedValue { subs in + for (_, s) in subs { + if s.subject == subject { + s.receiveError(NatsError.SubscriptionError.permissionDenied) + } + } + } + case .publish: + self.fire(.error(err)) + } + default: + self.fire(.error(err)) + } + + let normalizedError = err.normalizedError + // on some errors, force reconnect + if normalizedError == "stale connection" + || normalizedError == "maximum connections exceeded" + { + inputBuffer.clear() + parseRemainder.withLockedValue { $0 = nil } + context.fireErrorCaught(err) + } else { + self.fire(.error(err)) + } + case .message(let msg): + self.handleIncomingMessage(msg) + case .hMessage(let msg): + self.handleIncomingMessage(msg) + case .info(let serverInfo): + logger.debug("info \(op)") + self.serverInfo = serverInfo + if serverInfo.lameDuckMode { + self.fire(.lameDuckMode) + } + self.serverInfo = serverInfo + updateServersList(info: serverInfo) + default: + logger.debug("unknown operation type: \(op)") + } + } + inputBuffer.clear() + } + + private func handleIncomingMessage(_ message: MessageInbound) { + let natsMsg = NatsMessage( + payload: message.payload, subject: message.subject, replySubject: message.reply, + length: message.length, headers: nil, status: nil, description: nil) + subscriptions.withLockedValue { subs in + if let sub = subs[message.sid] { + sub.receiveMessage(natsMsg) + } + } + } + + private func handleIncomingMessage(_ message: HMessageInbound) { + let natsMsg = NatsMessage( + payload: message.payload, subject: message.subject, replySubject: message.reply, + length: message.length, headers: message.headers, status: message.status, + description: message.description) + subscriptions.withLockedValue { subs in + if let sub = subs[message.sid] { + sub.receiveMessage(natsMsg) + } + } + } + + func connect() async throws { + self.setState(.connecting) + var servers = self.urls + if !self.retainServersOrder { + servers = self.urls.shuffled() + } + var lastErr: Error? + + // if there are more reconnect attempts than the number of servers, + // we are after the initial connect, so sleep between servers + let shouldSleep = self.reconnectAttempts >= self.urls.count + for s in servers { + if let maxReconnects { + if reconnectAttempts > 0 && reconnectAttempts >= maxReconnects { + throw NatsError.ClientError.maxReconnects + } + } + self.reconnectAttempts += 1 + if shouldSleep { + try await Task.sleep(nanoseconds: self.reconnectWait) + } + + do { + try await connectToServer(s: s) + } catch let error as NatsError.ConnectError { + if case .invalidConfig(_) = error { + throw error + } + logger.debug("error connecting to server: \(error)") + lastErr = error + continue + } catch { + logger.debug("error connecting to server: \(error)") + lastErr = error + continue + } + lastErr = nil + break + } + if let lastErr { + self.state.withLockedValue { $0 = .disconnected } + switch lastErr { + case let error as ChannelError: + serverInfoContinuation.withLockedValue { $0 = nil } + var err: NatsError.ConnectError + switch error.self { + case .connectTimeout(_): + err = .timeout + default: + err = .io(error) + } + throw err + case let error as NIOConnectionError: + if let dnsAAAAError = error.dnsAAAAError { + throw NatsError.ConnectError.dns(dnsAAAAError) + } else if let dnsAError = error.dnsAError { + throw NatsError.ConnectError.dns(dnsAError) + } else { + throw NatsError.ConnectError.io(error) + } + case let err as NIOSSLError: + throw NatsError.ConnectError.tlsFailure(err) + case let err as BoringSSLError: + throw NatsError.ConnectError.tlsFailure(err) + case let err as NatsError.ServerError: + throw err + case let err as NatsError.ConnectError: + throw err + default: + throw NatsError.ConnectError.io(lastErr) + } + } + self.reconnectAttempts = 0 + guard let channel = self.channel else { + throw NatsError.ClientError.internalError("empty channel") + } + // Schedule the task to send a PING periodically + let pingInterval = TimeAmount.nanoseconds(Int64(self.pingInterval * 1_000_000_000)) + self.pingTask = channel.eventLoop.scheduleRepeatedTask( + initialDelay: pingInterval, delay: pingInterval + ) { _ in + Task { await self.sendPing() } + } + logger.debug("connection established") + return + } + + private func connectToServer(s: URL) async throws { + var infoTask: Task<(), Never>? = nil + // this continuation can throw NatsError.ServerError if server responds with + // -ERR to client connect (e.g. auth error) + let info: ServerInfo = try await withCheckedThrowingContinuation { continuation in + serverInfoContinuation.withLockedValue { $0 = continuation } + infoTask = Task { + await withTaskCancellationHandler { + do { + let (bootstrap, upgradePromise) = self.bootstrapConnection(to: s) + + guard let host = s.host, let port = s.port else { + upgradePromise.succeed() // avoid promise leaks + throw NatsError.ConnectError.invalidConfig("no url") + } + + let connect = bootstrap.connect(host: host, port: port) + connect.cascadeFailure(to: upgradePromise) + self.channel = try await connect.get() + + guard let channel = self.channel else { + upgradePromise.succeed() // avoid promise leaks + throw NatsError.ClientError.internalError("empty channel") + } + + try await upgradePromise.futureResult.get() + self.batchBuffer = BatchBuffer(channel: channel) + } catch { + let continuationToResume: CheckedContinuation? = self + .serverInfoContinuation.withLockedValue { cont in + guard let c = cont else { return nil } + cont = nil + return c + } + if let continuation = continuationToResume { + continuation.resume(throwing: error) + } + } + } onCancel: { + logger.debug("Connection task cancelled") + // Clean up resources + if let channel = self.channel { + channel.close(mode: .all, promise: nil) + self.channel = nil + } + self.batchBuffer = nil + + let continuationToResume: CheckedContinuation? = self + .serverInfoContinuation.withLockedValue { cont in + guard let c = cont else { return nil } + cont = nil + return c + } + if let continuation = continuationToResume { + continuation.resume(throwing: NatsError.ClientError.cancelled) + } + } + } + } + + await infoTask?.value + self.serverInfo = info + if (info.tlsRequired ?? false || self.requireTls) && !self.tlsFirst && s.scheme != "wss" { + let tlsConfig = try makeTLSConfig() + let sslContext = try NIOSSLContext(configuration: tlsConfig) + let sslHandler = try NIOSSLClientHandler( + context: sslContext, serverHostname: s.host) + try await self.channel?.pipeline.addHandler(sslHandler, position: .first) + } + + try await sendClientConnectInit() + self.connectedUrl = s + } + + private func makeTLSConfig() throws -> TLSConfiguration { + var tlsConfiguration = + TLSConfiguration.makeClientConfiguration() + if let rootCertificate = self.rootCertificate { + tlsConfiguration.trustRoots = .file( + rootCertificate.path) + } + if let clientCertificate = self.clientCertificate, + let clientKey = self.clientKey + { + // Load the client certificate from the PEM file + let certificate = try NIOSSLCertificate.fromPEMFile( + clientCertificate.path + ).map { NIOSSLCertificateSource.certificate($0) } + tlsConfiguration.certificateChain = certificate + + // Load the private key from the file + let privateKey = try NIOSSLPrivateKey( + file: clientKey.path, format: .pem) + tlsConfiguration.privateKey = .privateKey( + privateKey) + } + return tlsConfiguration + } + + private func sendClientConnectInit() async throws { + var initialConnect = ConnectInfo( + verbose: false, pedantic: false, userJwt: nil, nkey: "", name: "", echo: true, + lang: self.lang, version: self.version, natsProtocol: .dynamic, tlsRequired: false, + user: self.auth?.user ?? "", pass: self.auth?.password ?? "", + authToken: self.auth?.token ?? "", headers: true, noResponders: true) + + if self.auth?.nkey != nil && self.auth?.nkeyPath != nil { + throw NatsError.ConnectError.invalidConfig("cannot use both nkey and nkeyPath") + } + if let auth = self.auth, let credentialsPath = auth.credentialsPath { + let credentials = try await URLSession.shared.data(from: credentialsPath).0 + guard let jwt = JwtUtils.parseDecoratedJWT(contents: credentials) else { + throw NatsError.ConnectError.invalidConfig( + "failed to extract JWT from credentials file") + } + guard let nkey = JwtUtils.parseDecoratedNKey(contents: credentials) else { + throw NatsError.ConnectError.invalidConfig( + "failed to extract NKEY from credentials file") + } + guard let nonce = self.serverInfo?.nonce else { + throw NatsError.ConnectError.invalidConfig("missing nonce") + } + let keypair = try KeyPair(seed: String(data: nkey, encoding: .utf8)!) + let nonceData = nonce.data(using: .utf8)! + let sig = try keypair.sign(input: nonceData) + let base64sig = sig.base64EncodedURLSafeNotPadded() + initialConnect.signature = base64sig + initialConnect.userJwt = String(data: jwt, encoding: .utf8)! + } + if let nkey = self.auth?.nkeyPath { + let nkeyData = try await URLSession.shared.data(from: nkey).0 + + guard let nkeyContent = String(data: nkeyData, encoding: .utf8) else { + throw NatsError.ConnectError.invalidConfig("failed to read NKEY file") + } + let keypair = try KeyPair( + seed: nkeyContent.trimmingCharacters(in: .whitespacesAndNewlines) + ) + + guard let nonce = self.serverInfo?.nonce else { + throw NatsError.ConnectError.invalidConfig("missing nonce") + } + let sig = try keypair.sign(input: nonce.data(using: .utf8)!) + let base64sig = sig.base64EncodedURLSafeNotPadded() + initialConnect.signature = base64sig + initialConnect.nkey = keypair.publicKeyEncoded + } + if let nkey = self.auth?.nkey { + let keypair = try KeyPair(seed: nkey) + guard let nonce = self.serverInfo?.nonce else { + throw NatsError.ConnectError.invalidConfig("missing nonce") + } + let nonceData = nonce.data(using: .utf8)! + let sig = try keypair.sign(input: nonceData) + let base64sig = sig.base64EncodedURLSafeNotPadded() + initialConnect.signature = base64sig + initialConnect.nkey = keypair.publicKeyEncoded + } + let connect = initialConnect + // this continuation can throw NatsError.ServerError if server responds with + // -ERR to client connect (e.g. auth error) + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + connectionEstablishedContinuation.withLockedValue { $0 = continuation } + Task.detached { + do { + try await self.write(operation: ClientOp.connect(connect)) + try await self.write(operation: ClientOp.ping) + self.channel?.flush() + } catch { + let continuationToResume: CheckedContinuation? = self + .connectionEstablishedContinuation.withLockedValue { cont in + guard let c = cont else { return nil } + cont = nil + return c + } + if let continuation = continuationToResume { + continuation.resume(throwing: error) + } + } + } + } + } onCancel: { + logger.debug("Client connect initialization cancelled") + // Clean up resources + if let channel = self.channel { + channel.close(mode: .all, promise: nil) + self.channel = nil + } + self.batchBuffer = nil + + let continuationToResume: CheckedContinuation? = self + .connectionEstablishedContinuation.withLockedValue { cont in + guard let c = cont else { return nil } + cont = nil + return c + } + if let continuation = continuationToResume { + continuation.resume(throwing: NatsError.ClientError.cancelled) + } + } + } + + private func bootstrapConnection( + to server: URL + ) -> (ClientBootstrap, EventLoopPromise) { + let upgradePromise: EventLoopPromise = self.group.any().makePromise(of: Void.self) + let bootstrap = ClientBootstrap(group: self.group) + .channelOption( + ChannelOptions.socket( + SocketOptionLevel(SOL_SOCKET), SO_REUSEADDR), + value: 1 + ) + .channelInitializer { channel in + if self.requireTls && self.tlsFirst { + upgradePromise.succeed(()) + do { + let tlsConfig = try self.makeTLSConfig() + let sslContext = try NIOSSLContext( + configuration: tlsConfig) + let sslHandler = try NIOSSLClientHandler( + context: sslContext, serverHostname: server.host!) + //Fixme(jrm): do not ignore error from addHandler future. + channel.pipeline.addHandler(sslHandler).flatMap { _ in + channel.pipeline.addHandler(self) + }.whenComplete { result in + switch result { + case .success(): + print("success") + case .failure(let error): + print("error: \(error)") + } + } + return channel.eventLoop.makeSucceededFuture(()) + } catch { + let tlsError = NatsError.ConnectError.tlsFailure(error) + return channel.eventLoop.makeFailedFuture(tlsError) + } + } else { + if server.scheme == "ws" || server.scheme == "wss" { + let httpUpgradeRequestHandler = HTTPUpgradeRequestHandler( + host: server.host ?? "localhost", + path: server.path, + query: server.query, + headers: HTTPHeaders(), // TODO (mtmk): pass in from client options + upgradePromise: upgradePromise) + let httpUpgradeRequestHandlerBox = NIOLoopBound( + httpUpgradeRequestHandler, eventLoop: channel.eventLoop) + + let websocketUpgrader = NIOWebSocketClientUpgrader( + maxFrameSize: 8 * 1024 * 1024, + automaticErrorHandling: true, + upgradePipelineHandler: { channel, _ in + let wsh = NIOWebSocketFrameAggregator( + minNonFinalFragmentSize: 0, + maxAccumulatedFrameCount: Int.max, + maxAccumulatedFrameSize: Int.max + ) + return channel.pipeline.addHandler(wsh).flatMap { + channel.pipeline.addHandler(WebSocketByteBufferCodec()).flatMap + { + channel.pipeline.addHandler(self) + } + } + } + ) + + let config: NIOHTTPClientUpgradeConfiguration = ( + upgraders: [websocketUpgrader], + completionHandler: { context in + upgradePromise.succeed(()) + channel.pipeline.removeHandler( + httpUpgradeRequestHandlerBox.value, promise: nil) + } + ) + + if server.scheme == "wss" { + do { + let tlsConfig = try self.makeTLSConfig() + let sslContext = try NIOSSLContext( + configuration: tlsConfig) + let sslHandler = try NIOSSLClientHandler( + context: sslContext, serverHostname: server.host!) + // The sync methods here are safe because we're on the channel event loop + // due to the promise originating on the event loop of the channel. + try channel.pipeline.syncOperations.addHandler(sslHandler) + } catch { + let tlsError = NatsError.ConnectError.tlsFailure(error) + upgradePromise.fail(tlsError) + return channel.eventLoop.makeFailedFuture(tlsError) + } + } + + //Fixme(jrm): do not ignore error from addHandler future. + channel.pipeline.addHTTPClientHandlers( + leftOverBytesStrategy: .forwardBytes, + withClientUpgrade: config + ).flatMap { + channel.pipeline.addHandler(httpUpgradeRequestHandlerBox.value) + }.whenComplete { result in + switch result { + case .success(): + logger.debug("success") + case .failure(let error): + logger.debug("error: \(error)") + } + } + } else { + upgradePromise.succeed(()) + //Fixme(jrm): do not ignore error from addHandler future. + channel.pipeline.addHandler(self).whenComplete { result in + switch result { + case .success(): + logger.debug("success") + case .failure(let error): + logger.debug("error: \(error)") + } + } + } + return channel.eventLoop.makeSucceededFuture(()) + } + }.connectTimeout(.seconds(5)) + return (bootstrap, upgradePromise) + } + + private func updateServersList(info: ServerInfo) { + if let connectUrls = info.connectUrls { + for connectUrl in connectUrls { + guard let url = URL(string: connectUrl) else { + continue + } + if !self.urls.contains(url) { + urls.append(url) + } + } + } + } + + func close() async throws { + self.reconnectTask?.cancel() + try await self.reconnectTask?.value + + guard let eventLoop = self.channel?.eventLoop else { + self.state.withLockedValue { $0 = .closed } + self.pingTask?.cancel() + self.fire(.closed) + return + } + let promise = eventLoop.makePromise(of: Void.self) + + eventLoop.execute { + self.state.withLockedValue { $0 = .closed } + self.pingTask?.cancel() + self.channel?.close(mode: .all, promise: promise) + } + + do { + try await promise.futureResult.get() + } catch ChannelError.alreadyClosed { + // we don't want to throw an error if channel is already closed + // as that would mean we would get an error closing client during reconnect + } + + self.fire(.closed) + } + + private func disconnect() async throws { + self.pingTask?.cancel() + try await self.channel?.close().get() + } + + func suspend() async throws { + self.reconnectTask?.cancel() + _ = try await self.reconnectTask?.value + + // Handle case where channel is already nil (e.g., during rapid reconnections) + guard let eventLoop = self.channel?.eventLoop else { + // Set state to suspended even if channel is nil + self.state.withLockedValue { $0 = .suspended } + return + } + let promise = eventLoop.makePromise(of: Void.self) + + eventLoop.execute { // This ensures the code block runs on the event loop + let shouldClose = self.state.withLockedValue { currentState in + let wasConnected = currentState == .connected + currentState = .suspended + return wasConnected + } + + if shouldClose { + self.pingTask?.cancel() + self.channel?.close(mode: .all, promise: promise) + } else { + promise.succeed() + } + } + + try await promise.futureResult.get() + self.fire(.suspended) + } + + func resume() async throws { + guard let eventLoop = self.channel?.eventLoop else { + throw NatsError.ClientError.internalError("channel should not be nil") + } + try await eventLoop.submit { + let canResume = self.state.withLockedValue { $0 == .suspended } + guard canResume else { + throw NatsError.ClientError.invalidConnection( + "unable to resume connection - connection is not in suspended state") + } + self.handleReconnect() + }.get() + } + + func reconnect() async throws { + try await suspend() + try await resume() + } + + internal func sendPing(_ rttCommand: RttCommand? = nil) async { + let pingsOut = self.outstandingPings.wrappingIncrementThenLoad( + ordering: AtomicUpdateOrdering.relaxed) + if pingsOut > 2 { + handleDisconnect() + return + } + let ping = ClientOp.ping + do { + self.pingQueue.enqueue(rttCommand ?? RttCommand.makeFrom(channel: self.channel)) + try await self.write(operation: ping) + logger.debug("sent ping: \(pingsOut)") + } catch { + logger.error("Unable to send ping: \(error)") + } + + } + + func channelActive(context: ChannelHandlerContext) { + logger.debug("TCP channel active") + + parseRemainder.withLockedValue { $0 = nil } + + inputBuffer = context.channel.allocator.buffer(capacity: 1024 * 1024 * 8) + } + + func channelInactive(context: ChannelHandlerContext) { + logger.debug("TCP channel inactive") + + // If we lost the channel before we delivered server INFO or connection + // establishment, make sure to fail any pending continuations to avoid leaks. + // Use captured error if available (e.g., TLS failure), otherwise use connectionClosed. + let errorToUse: Error = capturedConnectionError.withLockedValue({ err in + let captured = err + err = nil // Clear after using + if let capturedError = captured { + return NatsError.ConnectError.tlsFailure(capturedError) + } else { + return NatsError.ClientError.connectionClosed + } + }) + + if let continuation = serverInfoContinuation.withLockedValue({ cont in + let toResume = cont + cont = nil + return toResume + }) { + continuation.resume(throwing: errorToUse) + } + + if let continuation = connectionEstablishedContinuation.withLockedValue({ cont in + let toResume = cont + cont = nil + return toResume + }) { + continuation.resume(throwing: errorToUse) + } + + let shouldHandleDisconnect = state.withLockedValue { $0 == .connected } + if shouldHandleDisconnect { + handleDisconnect() + } + } + + func errorCaught(context: ChannelHandlerContext, error: Error) { + logger.debug("Encountered error on the channel: \(error)") + + // Capture connection-stage errors (especially TLS) for proper error reporting BEFORE closing + let isConnecting = state.withLockedValue { $0 == .pending || $0 == .connecting } + if isConnecting { + capturedConnectionError.withLockedValue { $0 = error } + } + + context.close(promise: nil) + + if let natsErr = error as? NatsErrorProtocol { + self.fire(.error(natsErr)) + } else { + logger.error("unexpected error: \(error)") + } + let currentState = state.withLockedValue { $0 } + if currentState == .pending || currentState == .connecting { + handleDisconnect() + } else if currentState == .disconnected { + handleReconnect() + } + } + + func handleDisconnect() { + state.withLockedValue { $0 = .disconnected } + if let channel = self.channel { + let promise = channel.eventLoop.makePromise(of: Void.self) + Task { + do { + try await self.disconnect() + promise.succeed() + } catch ChannelError.alreadyClosed { + // if the channel was already closed, no need to return error + promise.succeed() + } catch { + promise.fail(error) + } + } + promise.futureResult.whenComplete { result in + do { + try result.get() + self.fire(.disconnected) + } catch { + logger.error("Error closing connection: \(error)") + } + } + } + + handleReconnect() + } + + func handleReconnect() { + reconnectTask = Task { + var connected = false + while !Task.isCancelled + && (maxReconnects == nil || self.reconnectAttempts < maxReconnects!) + { + do { + try await self.connect() + connected = true + break // Successfully connected + } catch is CancellationError { + logger.debug("Reconnect task cancelled") + return + } catch { + logger.debug("Could not reconnect: \(error)") + if !Task.isCancelled { + try await Task.sleep(nanoseconds: self.reconnectWait) + } + } + } + + // Early return if cancelled + if Task.isCancelled { + logger.debug("Reconnect task cancelled after connection attempts") + return + } + + // If we got here without connecting and weren't cancelled, we hit max reconnects + if !connected { + logger.error("Could not reconnect; maxReconnects exceeded") + try await self.close() + return + } + + // Recreate subscriptions - safely copy first + let subsToRestore = subscriptions.withLockedValue { Array($0) } + for (sid, sub) in subsToRestore { + do { + try await write(operation: ClientOp.subscribe((sid, sub.subject, nil))) + } catch { + logger.error("Error recreating subscription \(sid): \(error)") + } + } + + self.channel?.eventLoop.execute { + self.state.withLockedValue { $0 = .connected } + self.fire(.connected) + } + } + } + + func write(operation: ClientOp) async throws { + guard let buffer = self.batchBuffer else { + throw NatsError.ClientError.invalidConnection("not connected") + } + do { + try await buffer.writeMessage(operation) + } catch { + throw NatsError.ClientError.io(error) + } + } + + internal func subscribe( + _ subject: String, queue: String? = nil + ) async throws -> NatsSubscription { + let sid = self.subscriptionCounter.wrappingIncrementThenLoad( + ordering: AtomicUpdateOrdering.relaxed) + let sub = try NatsSubscription(sid: sid, subject: subject, queue: queue, conn: self) + + // Add subscription BEFORE sending command to avoid race condition + subscriptions.withLockedValue { $0[sid] = sub } + + do { + try await write(operation: ClientOp.subscribe((sid, subject, queue))) + } catch { + // Remove subscription if subscribe command fails + subscriptions.withLockedValue { $0.removeValue(forKey: sid) } + throw error + } + + return sub + } + + internal func unsubscribe(sub: NatsSubscription, max: UInt64?) async throws { + if let max, sub.delivered < max { + // if max is set and the sub has not yet reached it, send unsub with max set + // and do not remove the sub from connection + try await write(operation: ClientOp.unsubscribe((sid: sub.sid, max: max))) + sub.max = max + } else { + // if max is not set or the subscription received at least as many + // messages as max, send unsub command without max and remove sub from connection + try await write(operation: ClientOp.unsubscribe((sid: sub.sid, max: nil))) + self.removeSub(sub: sub) + } + } + + internal func removeSub(sub: NatsSubscription) { + subscriptions.withLockedValue { $0.removeValue(forKey: sub.sid) } + sub.complete() + } +} + +extension ConnectionHandler { + + internal func fire(_ event: NatsEvent) { + let eventKind = event.kind() + guard let handlerStore = self.eventHandlerStore[eventKind] else { return } + + for handler in handlerStore { + handler.handler(event) + } + } + + internal func addListeners( + for events: [NatsEventKind], using handler: @escaping (NatsEvent) -> Void + ) -> String { + + let id = String.hash() + + for event in events { + if self.eventHandlerStore[event] == nil { + self.eventHandlerStore[event] = [] + } + self.eventHandlerStore[event]?.append( + NatsEventHandler(lid: id, handler: handler)) + } + + return id + + } + + internal func removeListener(_ id: String) { + + for event in NatsEventKind.all { + + let handlerStore = self.eventHandlerStore[event] + if let store = handlerStore { + self.eventHandlerStore[event] = store.filter { $0.listenerId != id } + } + + } + + } + +} + +/// Nats events +public enum NatsEventKind: String { + case connected = "connected" + case disconnected = "disconnected" + case closed = "closed" + case suspended = "suspended" + case lameDuckMode = "lameDuckMode" + case error = "error" + static let all = [connected, disconnected, closed, lameDuckMode, error] +} + +public enum NatsEvent { + case connected + case disconnected + case suspended + case closed + case lameDuckMode + case error(NatsErrorProtocol) + + public func kind() -> NatsEventKind { + switch self { + case .connected: + return .connected + case .disconnected: + return .disconnected + case .suspended: + return .suspended + case .closed: + return .closed + case .lameDuckMode: + return .lameDuckMode + case .error(_): + return .error + } + } +} + +internal struct NatsEventHandler { + let listenerId: String + let handler: (NatsEvent) -> Void + init(lid: String, handler: @escaping (NatsEvent) -> Void) { + self.listenerId = lid + self.handler = handler + } +} diff --git a/Sources/Nats/NatsError.swift b/Sources/Nats/NatsError.swift new file mode 100644 index 0000000..9bd1713 --- /dev/null +++ b/Sources/Nats/NatsError.swift @@ -0,0 +1,244 @@ +// Copyright 2024 The NATS 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 +// +// http://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. + +import Foundation + +public protocol NatsErrorProtocol: Error, CustomStringConvertible {} + +public enum NatsError { + public enum ServerError: NatsErrorProtocol, Equatable { + case staleConnection + case maxConnectionsExceeded + case authorizationViolation + case authenticationExpired + case authenticationRevoked + case authenticationTimeout + case permissionsViolation(Operation, String, String?) + case proto(String) + + public var description: String { + switch self { + case .staleConnection: + return "nats: stale connection" + case .maxConnectionsExceeded: + return "nats: maximum connections exceeded" + case .authorizationViolation: + return "nats: authorization violation" + case .authenticationExpired: + return "nats: authentication expired" + case .authenticationRevoked: + return "nats: authentication revoked" + case .authenticationTimeout: + return "nats: authentication timeout" + case .permissionsViolation(let operation, let subject, let queue): + if let queue { + return + "nats: permissions violation for operation \"\(operation)\" on subject \"\(subject)\" using queue \"\(queue)\"" + } else { + return + "nats: permissions violation for operation \"\(operation)\" on subject \"\(subject)\"" + } + case .proto(let error): + return "nats: \(error)" + } + } + var normalizedError: String { + return description.trimWhitespacesAndApostrophes().lowercased() + } + init(_ error: String) { + let normalizedError = error.trimWhitespacesAndApostrophes().lowercased() + if normalizedError.contains("stale connection") { + self = .staleConnection + } else if normalizedError.contains("maximum connections exceeded") { + self = .maxConnectionsExceeded + } else if normalizedError.contains("authorization violation") { + self = .authorizationViolation + } else if normalizedError.contains("authentication expired") { + self = .authenticationExpired + } else if normalizedError.contains("authentication revoked") { + self = .authenticationRevoked + } else if normalizedError.contains("authentication timeout") { + self = .authenticationTimeout + } else if normalizedError.contains("permissions violation") { + if let (operation, subject, queue) = NatsError.ServerError.parsePermissions( + error: error) + { + self = .permissionsViolation(operation, subject, queue) + } else { + self = .proto(error) + } + } else { + self = .proto(error) + } + } + + public enum Operation: String, Equatable { + case publish = "Publish" + case subscribe = "Subscription" + } + + internal static func parsePermissions(error: String) -> (Operation, String, String?)? { + let pattern = "(Publish|Subscription) to \"(\\S+)\"" + let regex = try! NSRegularExpression(pattern: pattern) + let matches = regex.matches( + in: error, options: [], range: NSRange(location: 0, length: error.utf16.count)) + + guard let match = matches.first else { + return nil + } + + var operation: Operation? + if let operationRange = Range(match.range(at: 1), in: error) { + let operationString = String(error[operationRange]) + operation = Operation(rawValue: operationString) + } + + var subject: String? + if let subjectRange = Range(match.range(at: 2), in: error) { + subject = String(error[subjectRange]) + } + + let queuePattern = "using queue \"(\\S+)\"" + let queueRegex = try! NSRegularExpression(pattern: queuePattern) + let queueMatches = queueRegex.matches( + in: error, options: [], range: NSRange(location: 0, length: error.utf16.count)) + + var queue: String? + if let match = queueMatches.first, let queueRange = Range(match.range(at: 1), in: error) + { + queue = String(error[queueRange]) + } + + if let operation, let subject { + return (operation, subject, queue) + } else { + return nil + } + } + } + + public enum ProtocolError: NatsErrorProtocol, Equatable { + case invalidOperation(String) + case parserFailure(String) + + public var description: String { + switch self { + case .invalidOperation(let op): + return "nats: unknown server operation: \(op)" + case .parserFailure(let cause): + return "nats: parser failure: \(cause)" + } + } + } + + public enum ClientError: NatsErrorProtocol { + case internalError(String) + case maxReconnects + case connectionClosed + case io(Error) + case invalidConnection(String) + case cancelled + case alreadyConnected + + public var description: String { + switch self { + case .internalError(let error): + return "nats: internal error: \(error)" + case .maxReconnects: + return "nats: max reconnects exceeded" + case .connectionClosed: + return "nats: connection is closed" + case .io(let error): + return "nats: IO error: \(error)" + case .invalidConnection(let error): + return "nats: \(error)" + case .cancelled: + return "nats: operation cancelled" + case .alreadyConnected: + return "nats: client is already connected or connecting" + } + } + } + + public enum ConnectError: NatsErrorProtocol { + case invalidConfig(String) + case tlsFailure(Error) + case timeout + case dns(Error) + case io(Error) + + public var description: String { + switch self { + case .invalidConfig(let error): + return "nats: invalid client configuration: \(error)" + case .tlsFailure(let error): + return "nats: TLS error: \(error)" + case .timeout: + return "nats: timed out waiting for connection" + case .dns(let error): + return "nats: DNS lookup error: \(error)" + case .io(let error): + return "nats: error establishing connection: \(error)" + } + } + } + + public enum RequestError: NatsErrorProtocol, Equatable { + case noResponders + case timeout + case permissionDenied + + public var description: String { + switch self { + case .noResponders: + return "nats: no responders available for request" + case .timeout: + return "nats: request timed out" + case .permissionDenied: + return "nats: permission denied" + } + } + } + + public enum SubscriptionError: NatsErrorProtocol, Equatable { + case invalidSubject + case invalidQueue + case permissionDenied + case subscriptionClosed + + public var description: String { + switch self { + case .invalidSubject: + return "nats: invalid subject name" + case .invalidQueue: + return "nats: invalid queue group name" + case .permissionDenied: + return "nats: permission denied" + case .subscriptionClosed: + return "nats: subscription closed" + } + } + } + + public enum ParseHeaderError: NatsErrorProtocol, Equatable { + case invalidCharacter + + public var description: String { + switch self { + case .invalidCharacter: + return + "nats: invalid header name (name cannot contain non-ascii alphanumeric characters other than '-')" + } + } + } +} diff --git a/Sources/Nats/NatsHeaders.swift b/Sources/Nats/NatsHeaders.swift new file mode 100644 index 0000000..b24a319 --- /dev/null +++ b/Sources/Nats/NatsHeaders.swift @@ -0,0 +1,159 @@ +// Copyright 2024 The NATS 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 +// +// http://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. + +import Foundation + +// Represents NATS header field value in Swift. +public struct NatsHeaderValue: Equatable, CustomStringConvertible { + private var inner: String + + public init(_ value: String) { + self.inner = value + } + + public var description: String { + return inner + } +} + +// Custom header representation in Swift +public struct NatsHeaderName: Equatable, Hashable, CustomStringConvertible { + private var inner: String + + public init(_ value: String) throws { + if value.contains(where: { $0 == ":" || $0.asciiValue! < 33 || $0.asciiValue! > 126 }) { + throw NatsError.ParseHeaderError.invalidCharacter + } + self.inner = value + } + + public var description: String { + return inner + } + + // Example of standard headers + public static let natsStream = try! NatsHeaderName("Nats-Stream") + public static let natsSequence = try! NatsHeaderName("Nats-Sequence") + public static let natsTimestamp = try! NatsHeaderName("Nats-Time-Stamp") + public static let natsSubject = try! NatsHeaderName("Nats-Subject") + // Add other standard headers as needed... +} + +// Represents a NATS header map in Swift. +public struct NatsHeaderMap: Equatable { + private var inner: [NatsHeaderName: [NatsHeaderValue]] + internal var status: StatusCode? = nil + internal var description: String? = nil + + public init() { + self.inner = [:] + } + + public init(from headersString: String) throws { + self.inner = [:] + let headersArray = headersString.split(separator: "\r\n") + let versionLine = headersArray[0] + guard versionLine.hasPrefix(Data.versionLinePrefix) else { + throw NatsError.ProtocolError.parserFailure( + "header version line does not begin with `NATS/1.0`") + } + let versionLineSuffix = + versionLine + .dropFirst(Data.versionLinePrefix.count) + .trimmingCharacters(in: .whitespacesAndNewlines) + + // handle inlines status and description + if versionLineSuffix.count > 0 { + let statusAndDesc = versionLineSuffix.split( + separator: " ", maxSplits: 1) + guard let status = StatusCode(statusAndDesc[0]) else { + throw NatsError.ProtocolError.parserFailure("could not parse status parameter") + } + self.status = status + if statusAndDesc.count > 1 { + self.description = String(statusAndDesc[1]) + } + } + + for header in headersArray.dropFirst() { + let headerParts = header.split(separator: ":", maxSplits: 1) + if headerParts.count == 2 { + self.append( + try NatsHeaderName(String(headerParts[0])), + NatsHeaderValue(String(headerParts[1]).trimmingCharacters(in: .whitespaces))) + } else { + logger.error("Error parsing header: \(header)") + } + } + } + + var isEmpty: Bool { + return inner.isEmpty + } + + public mutating func insert(_ name: NatsHeaderName, _ value: NatsHeaderValue) { + self.inner[name] = [value] + } + + public mutating func append(_ name: NatsHeaderName, _ value: NatsHeaderValue) { + if inner[name] != nil { + inner[name]?.append(value) + } else { + insert(name, value) + } + } + + public func get(_ name: NatsHeaderName) -> NatsHeaderValue? { + return inner[name]?.first + } + + public func getAll(_ name: NatsHeaderName) -> [NatsHeaderValue] { + return inner[name] ?? [] + } + + //TODO(jrm): can we use unsafe methods here? Probably yes. + func toBytes() -> [UInt8] { + var bytes: [UInt8] = [] + bytes.append(contentsOf: "NATS/1.0\r\n".utf8) + for (name, values) in inner { + for value in values { + bytes.append(contentsOf: name.description.utf8) + bytes.append(contentsOf: ":".utf8) + bytes.append(contentsOf: value.description.utf8) + bytes.append(contentsOf: "\r\n".utf8) + } + } + bytes.append(contentsOf: "\r\n".utf8) + return bytes + } + + // Implementing the == operator to exclude status and desc internal properties + public static func == (lhs: NatsHeaderMap, rhs: NatsHeaderMap) -> Bool { + return lhs.inner == rhs.inner + } +} + +extension NatsHeaderMap { + public subscript(name: NatsHeaderName) -> NatsHeaderValue? { + get { + return get(name) + } + set { + if let value = newValue { + insert(name, value) + } else { + inner[name] = nil + } + } + } +} diff --git a/Sources/Nats/NatsJwtUtils.swift b/Sources/Nats/NatsJwtUtils.swift new file mode 100644 index 0000000..5d6e446 --- /dev/null +++ b/Sources/Nats/NatsJwtUtils.swift @@ -0,0 +1,69 @@ +// Copyright 2024 The NATS 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 +// +// http://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. + +import Foundation + +class JwtUtils { + // This regular expression is equivalent to the one used in Rust. + static let userConfigRE: NSRegularExpression = { + do { + return try NSRegularExpression( + pattern: + "\\s*(?:(?:-{3,}.*-{3,}\\r?\\n)([\\w\\-.=]+)(?:\\r?\\n-{3,}.*-{3,}\\r?\\n))", + options: []) + } catch { + fatalError("Invalid regular expression: \(error)") + } + }() + + /// Parses a credentials file and returns its user JWT. + static func parseDecoratedJWT(contents: String) -> String? { + let matches = userConfigRE.matches( + in: contents, options: [], range: NSRange(contents.startIndex..., in: contents)) + if let match = matches.first, let range = Range(match.range(at: 1), in: contents) { + return String(contents[range]) + } + return nil + } + /// Parses a credentials file and returns its user JWT. + static func parseDecoratedJWT(contents: Data) -> Data? { + guard let contentsString = String(data: contents, encoding: .utf8) else { + return nil + } + if let match = parseDecoratedJWT(contents: contentsString) { + return match.data(using: .utf8) + } + return nil + } + + /// Parses a credentials file and returns its nkey. + static func parseDecoratedNKey(contents: String) -> String? { + let matches = userConfigRE.matches( + in: contents, options: [], range: NSRange(contents.startIndex..., in: contents)) + if matches.count > 1, let range = Range(matches[1].range(at: 1), in: contents) { + return String(contents[range]) + } + return nil + } + + /// Parses a credentials file and returns its nkey. + static func parseDecoratedNKey(contents: Data) -> Data? { + guard let contentsString = String(data: contents, encoding: .utf8) else { + return nil + } + if let match = parseDecoratedNKey(contents: contentsString) { + return match.data(using: .utf8) + } + return nil + } +} diff --git a/Sources/Nats/NatsMessage.swift b/Sources/Nats/NatsMessage.swift new file mode 100644 index 0000000..d8ec1fe --- /dev/null +++ b/Sources/Nats/NatsMessage.swift @@ -0,0 +1,60 @@ +// Copyright 2024 The NATS 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 +// +// http://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. + +import Foundation + +public struct NatsMessage { + public let payload: Data? + public let subject: String + public let replySubject: String? + public let length: Int + public let headers: NatsHeaderMap? + public let status: StatusCode? + public let description: String? +} + +public struct StatusCode: Equatable { + public static let idleHeartbeat = StatusCode(value: 100) + public static let ok = StatusCode(value: 200) + public static let badRequest = StatusCode(value: 400) + public static let notFound = StatusCode(value: 404) + public static let timeout = StatusCode(value: 408) + public static let noResponders = StatusCode(value: 503) + public static let requestTerminated = StatusCode(value: 409) + + let value: UInt16 + + // non-optional initializer for static status codes + private init(value: UInt16) { + self.value = value + } + + init?(_ value: UInt16) { + if !(100..<1000 ~= value) { + return nil + } + + self.value = value + } + + init?(_ value: any StringProtocol) { + guard let status = UInt16(value) else { + return nil + } + if !(100..<1000 ~= status) { + return nil + } + + self.value = status + } +} diff --git a/Sources/Nats/NatsProto.swift b/Sources/Nats/NatsProto.swift new file mode 100644 index 0000000..124df80 --- /dev/null +++ b/Sources/Nats/NatsProto.swift @@ -0,0 +1,341 @@ +// Copyright 2024 The NATS 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 +// +// http://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. + +import Foundation +import NIO + +internal struct NatsOperation: RawRepresentable, Hashable { + let rawValue: String + + static let connect = NatsOperation(rawValue: "CONNECT") + static let subscribe = NatsOperation(rawValue: "SUB") + static let unsubscribe = NatsOperation(rawValue: "UNSUB") + static let publish = NatsOperation(rawValue: "PUB") + static let hpublish = NatsOperation(rawValue: "HPUB") + static let message = NatsOperation(rawValue: "MSG") + static let hmessage = NatsOperation(rawValue: "HMSG") + static let info = NatsOperation(rawValue: "INFO") + static let ok = NatsOperation(rawValue: "+OK") + static let error = NatsOperation(rawValue: "-ERR") + static let ping = NatsOperation(rawValue: "PING") + static let pong = NatsOperation(rawValue: "PONG") + + var rawBytes: String.UTF8View { + self.rawValue.utf8 + } + + static func allOperations() -> [NatsOperation] { + return [ + .connect, .subscribe, .unsubscribe, .publish, .message, .hmessage, .info, .ok, .error, + .ping, .pong, + ] + } +} + +enum ServerOp { + case ok + case info(ServerInfo) + case ping + case pong + case error(NatsError.ServerError) + case message(MessageInbound) + case hMessage(HMessageInbound) + + static func parse(from msg: Data) throws -> ServerOp { + guard msg.count > 2 else { + throw NatsError.ProtocolError.parserFailure( + "unable to parse inbound message: \(String(data: msg, encoding: .utf8)!)") + } + guard let msgType = msg.getMessageType() else { + throw NatsError.ProtocolError.invalidOperation(String(data: msg, encoding: .utf8)!) + } + switch msgType { + case .message: + return try message(MessageInbound.parse(data: msg)) + case .hmessage: + return try hMessage(HMessageInbound.parse(data: msg)) + case .info: + return try info(ServerInfo.parse(data: msg)) + case .ok: + return ok + case .error: + if let errMsg = msg.removePrefix(Data(NatsOperation.error.rawBytes)).toString() { + return error(NatsError.ServerError(errMsg)) + } + return error(NatsError.ServerError("unexpected error")) + case .ping: + return ping + case .pong: + return pong + default: + throw NatsError.ProtocolError.invalidOperation( + "unknown server op: \(String(data: msg, encoding: .utf8)!)") + } + } +} + +internal struct HMessageInbound: Equatable { + private static let newline = UInt8(ascii: "\n") + private static let space = UInt8(ascii: " ") + var subject: String + var sid: UInt64 + var reply: String? + var payload: Data? + var headers: NatsHeaderMap + var headersLength: Int + var length: Int + var status: StatusCode? + var description: String? + + // Parse the operation syntax: HMSG [reply-to] + internal static func parse(data: Data) throws -> HMessageInbound { + let protoComponents = + data + .dropFirst(NatsOperation.hmessage.rawValue.count) // Assuming msg starts with "HMSG " + .split(separator: space) + .filter { !$0.isEmpty } + + let parseArgs: ((Data, Data, Data?, Data, Data) throws -> HMessageInbound) = { + subjectData, sidData, replyData, lengthHeaders, lengthData in + let subject = String(decoding: subjectData, as: UTF8.self) + guard let sid = UInt64(String(decoding: sidData, as: UTF8.self)) else { + throw NatsError.ProtocolError.parserFailure( + "unable to parse subscription ID as number") + } + var replySubject: String? = nil + if let replyData = replyData { + replySubject = String(decoding: replyData, as: UTF8.self) + } + let headersLength = Int(String(decoding: lengthHeaders, as: UTF8.self)) ?? 0 + let length = Int(String(decoding: lengthData, as: UTF8.self)) ?? 0 + return HMessageInbound( + subject: subject, sid: sid, reply: replySubject, payload: nil, + headers: NatsHeaderMap(), + headersLength: headersLength, length: length) + } + + var msg: HMessageInbound + switch protoComponents.count { + case 4: + msg = try parseArgs( + protoComponents[0], protoComponents[1], nil, protoComponents[2], + protoComponents[3]) + case 5: + msg = try parseArgs( + protoComponents[0], protoComponents[1], protoComponents[2], protoComponents[3], + protoComponents[4]) + default: + throw NatsError.ProtocolError.parserFailure("unable to parse inbound message header") + } + return msg + } +} + +// TODO(pp): add headers and HMSG parsing +internal struct MessageInbound: Equatable { + private static let newline = UInt8(ascii: "\n") + private static let space = UInt8(ascii: " ") + var subject: String + var sid: UInt64 + var reply: String? + var payload: Data? + var length: Int + + // Parse the operation syntax: MSG [reply-to] + internal static func parse(data: Data) throws -> MessageInbound { + let protoComponents = + data + .dropFirst(NatsOperation.message.rawValue.count) // Assuming msg starts with "MSG " + .split(separator: space) + .filter { !$0.isEmpty } + + let parseArgs: ((Data, Data, Data?, Data) throws -> MessageInbound) = { + subjectData, sidData, replyData, lengthData in + let subject = String(decoding: subjectData, as: UTF8.self) + guard let sid = UInt64(String(decoding: sidData, as: UTF8.self)) else { + throw NatsError.ProtocolError.parserFailure( + "unable to parse subscription ID as number") + } + var replySubject: String? = nil + if let replyData = replyData { + replySubject = String(decoding: replyData, as: UTF8.self) + } + let length = Int(String(decoding: lengthData, as: UTF8.self)) ?? 0 + return MessageInbound( + subject: subject, sid: sid, reply: replySubject, payload: nil, length: length) + } + + var msg: MessageInbound + switch protoComponents.count { + case 3: + msg = try parseArgs(protoComponents[0], protoComponents[1], nil, protoComponents[2]) + case 4: + msg = try parseArgs( + protoComponents[0], protoComponents[1], protoComponents[2], protoComponents[3]) + default: + throw NatsError.ProtocolError.parserFailure("unable to parse inbound message header") + } + return msg + } +} + +/// Struct representing server information in NATS. +struct ServerInfo: Codable, Equatable { + /// The unique identifier of the NATS server. + let serverId: String + /// Generated Server Name. + let serverName: String + /// The host specified in the cluster parameter/options. + let host: String + /// The port number specified in the cluster parameter/options. + let port: UInt16 + /// The version of the NATS server. + let version: String + /// If this is set, then the server should try to authenticate upon connect. + let authRequired: Bool? + /// If this is set, then the server must authenticate using TLS. + let tlsRequired: Bool? + /// Maximum payload size that the server will accept. + let maxPayload: UInt + /// The protocol version in use. + let proto: Int8 + /// The server-assigned client ID. This may change during reconnection. + let clientId: UInt64? + /// The version of golang the NATS server was built with. + let go: String + /// The nonce used for nkeys. + let nonce: String? + /// A list of server urls that a client can connect to. + let connectUrls: [String]? + /// The client IP as known by the server. + let clientIp: String + /// Whether the server supports headers. + let headers: Bool + /// Whether server goes into lame duck + private let _lameDuckMode: Bool? + var lameDuckMode: Bool { + return _lameDuckMode ?? false + } + + private static let prefix = NatsOperation.info.rawValue.data(using: .utf8)! + + private enum CodingKeys: String, CodingKey { + case serverId = "server_id" + case serverName = "server_name" + case host + case port + case version + case authRequired = "auth_required" + case tlsRequired = "tls_required" + case maxPayload = "max_payload" + case proto + case clientId = "client_id" + case go + case nonce + case connectUrls = "connect_urls" + case clientIp = "client_ip" + case headers + case _lameDuckMode = "ldm" + } + + internal static func parse(data: Data) throws -> ServerInfo { + let info = data.removePrefix(prefix) + return try JSONDecoder().decode(self, from: info) + } +} + +enum ClientOp { + case publish((subject: String, reply: String?, payload: Data?, headers: NatsHeaderMap?)) + case subscribe((sid: UInt64, subject: String, queue: String?)) + case unsubscribe((sid: UInt64, max: UInt64?)) + case connect(ConnectInfo) + case ping + case pong +} + +/// Info to construct a CONNECT message. +struct ConnectInfo: Encodable { + /// Turns on +OK protocol acknowledgments. + var verbose: Bool + /// Turns on additional strict format checking, e.g. for properly formed + /// subjects. + var pedantic: Bool + /// User's JWT. + var userJwt: String? + /// Public nkey. + var nkey: String + /// Signed nonce, encoded to Base64URL. + var signature: String? + /// Optional client name. + var name: String + /// If set to `true`, the server (version 1.2.0+) will not send originating + /// messages from this connection to its own subscriptions. Clients should + /// set this to `true` only for server supporting this feature, which is + /// when proto in the INFO protocol is set to at least 1. + var echo: Bool + /// The implementation language of the client. + var lang: String + /// The version of the client. + var version: String + /// Sending 0 (or absent) indicates client supports original protocol. + /// Sending 1 indicates that the client supports dynamic reconfiguration + /// of cluster topology changes by asynchronously receiving INFO messages + /// with known servers it can reconnect to. + var natsProtocol: NatsProtocol + /// Indicates whether the client requires an SSL connection. + var tlsRequired: Bool + /// Connection username (if `auth_required` is set) + var user: String + /// Connection password (if auth_required is set) + var pass: String + /// Client authorization token (if auth_required is set) + var authToken: String + /// Whether the client supports the usage of headers. + var headers: Bool + /// Whether the client supports no_responders. + var noResponders: Bool + enum CodingKeys: String, CodingKey { + case verbose + case pedantic + case userJwt = "jwt" + case nkey + case signature = "sig" // Custom key name for JSON + case name + case echo + case lang + case version + case natsProtocol = "protocol" + case tlsRequired = "tls_required" + case user + case pass + case authToken = "auth_token" + case headers + case noResponders = "no_responders" + } +} + +enum NatsProtocol: Encodable { + case original + case dynamic + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + switch self { + case .original: + try container.encode(0) + case .dynamic: + try container.encode(1) + } + } +} diff --git a/Sources/Nats/NatsSubscription.swift b/Sources/Nats/NatsSubscription.swift new file mode 100644 index 0000000..c06f78c --- /dev/null +++ b/Sources/Nats/NatsSubscription.swift @@ -0,0 +1,185 @@ +// Copyright 2024 The NATS 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 +// +// http://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. + +import Foundation + +// TODO(pp): Implement slow consumer +public class NatsSubscription: AsyncSequence { + public typealias Element = NatsMessage + public typealias AsyncIterator = SubscriptionIterator + + public let subject: String + public let queue: String? + internal var max: UInt64? + internal var delivered: UInt64 = 0 + internal let sid: UInt64 + + private var buffer: [Result] + private let capacity: UInt64 + private var closed = false + private var continuation: + CheckedContinuation?, Never>? + private let lock = NSLock() + private let conn: ConnectionHandler + + private static let defaultSubCapacity: UInt64 = 512 * 1024 + + convenience init(sid: UInt64, subject: String, queue: String?, conn: ConnectionHandler) throws { + try self.init( + sid: sid, subject: subject, queue: queue, capacity: NatsSubscription.defaultSubCapacity, + conn: conn) + } + + init( + sid: UInt64, subject: String, queue: String?, capacity: UInt64, conn: ConnectionHandler + ) throws { + if !NatsSubscription.validSubject(subject) { + throw NatsError.SubscriptionError.invalidSubject + } + if let queue, !NatsSubscription.validQueue(queue) { + throw NatsError.SubscriptionError.invalidQueue + } + self.sid = sid + self.subject = subject + self.queue = queue + self.capacity = capacity + self.buffer = [] + self.conn = conn + } + + public func makeAsyncIterator() -> SubscriptionIterator { + return SubscriptionIterator(subscription: self) + } + + func receiveMessage(_ message: NatsMessage) { + lock.withLock { + if let continuation = self.continuation { + // Immediately use the continuation if it exists + self.continuation = nil + continuation.resume(returning: .success(message)) + } else if buffer.count < capacity { + // Only append to buffer if no continuation is available + // TODO(pp): Hadndle SlowConsumer as subscription event + buffer.append(.success(message)) + } + } + } + + func receiveError(_ error: NatsError.SubscriptionError) { + lock.withLock { + if let continuation = self.continuation { + // Immediately use the continuation if it exists + self.continuation = nil + continuation.resume(returning: .failure(error)) + } else { + buffer.append(.failure(error)) + } + } + } + + internal func complete() { + lock.withLock { + closed = true + if let continuation { + self.continuation = nil + continuation.resume(returning: nil) + } + + } + } + + // AsyncIterator implementation + public class SubscriptionIterator: AsyncIteratorProtocol { + private var subscription: NatsSubscription + + init(subscription: NatsSubscription) { + self.subscription = subscription + } + + public func next() async throws -> Element? { + try await subscription.nextMessage() + } + } + + private func nextMessage() async throws -> Element? { + let result: Result? = await withCheckedContinuation { + continuation in + lock.withLock { + if closed { + continuation.resume(returning: nil) + return + } + + delivered += 1 + if let message = buffer.first { + buffer.removeFirst() + continuation.resume(returning: message) + } else { + self.continuation = continuation + } + } + } + if let max, delivered >= max { + conn.removeSub(sub: self) + } + switch result { + case .success(let msg): + return msg + case .failure(let error): + throw error + default: + return nil + } + } + + /// Unsubscribes from subscription. + /// + /// - Parameter after: If set, unsubscribe will be performed after reaching given number of messages. + /// If it already reached or surpassed the passed value, it will immediately stop. + /// + /// > **Throws:** + /// > - ``NatsError/ClientError/connectionClosed`` if the conneciton is closed. + /// > - ``NatsError/SubscriptionError/subscriptionClosed`` if the subscription is already closed + public func unsubscribe(after: UInt64? = nil) async throws { + logger.info("unsubscribe from subject \(subject)") + if case .closed = self.conn.currentState { + throw NatsError.ClientError.connectionClosed + } + if self.closed { + throw NatsError.SubscriptionError.subscriptionClosed + } + return try await self.conn.unsubscribe(sub: self, max: after) + } + + // validateSubject will do a basic subject validation. + // Spaces are not allowed and all tokens should be > 0 in length. + private static func validSubject(_ subj: String) -> Bool { + let whitespaceCharacterSet = CharacterSet.whitespacesAndNewlines + if subj.rangeOfCharacter(from: whitespaceCharacterSet) != nil { + return false + } + let tokens = subj.split(separator: ".") + for token in tokens { + if token.isEmpty { + return false + } + } + return true + } + + // validQueue will check a queue name for whitespaces. + private static func validQueue(_ queue: String) -> Bool { + let whitespaceCharacterSet = CharacterSet.whitespacesAndNewlines + return queue.rangeOfCharacter(from: whitespaceCharacterSet) == nil + } +} diff --git a/Sources/Nats/RttCommand.swift b/Sources/Nats/RttCommand.swift new file mode 100644 index 0000000..14675a4 --- /dev/null +++ b/Sources/Nats/RttCommand.swift @@ -0,0 +1,39 @@ +// Copyright 2024 The NATS 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 +// +// http://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. + +import Foundation +import NIOCore + +internal class RttCommand { + let startTime = DispatchTime.now() + let promise: EventLoopPromise? + + static func makeFrom(channel: Channel?) -> RttCommand { + RttCommand(promise: channel?.eventLoop.makePromise(of: TimeInterval.self)) + } + + private init(promise: EventLoopPromise?) { + self.promise = promise + } + + func setRoundTripTime() { + let now = DispatchTime.now() + let nanoTime = now.uptimeNanoseconds - startTime.uptimeNanoseconds + let rtt = TimeInterval(nanoTime) / 1_000_000_000 // Convert nanos to seconds + promise?.succeed(rtt) + } + + func getRoundTripTime() async throws -> TimeInterval { + try await promise?.futureResult.get() ?? 0 + } +} diff --git a/Sources/NatsServer/NatsServer.swift b/Sources/NatsServer/NatsServer.swift new file mode 100644 index 0000000..521f4c3 --- /dev/null +++ b/Sources/NatsServer/NatsServer.swift @@ -0,0 +1,206 @@ +// Copyright 2024 The NATS 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 +// +// http://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. + +import Foundation +import XCTest + +public class NatsServer { + public var port: Int? { return natsServerPort } + public var clientURL: String { + let scheme = tlsEnabled ? "tls://" : "nats://" + if let natsServerPort { + return "\(scheme)localhost:\(natsServerPort)" + } else { + return "" + } + } + + public var clientWebsocketURL: String { + let scheme = tlsEnabled ? "wss://" : "ws://" + if let natsWebsocketPort { + return "\(scheme)localhost:\(natsWebsocketPort)" + } else { + return "" + } + } + + private var process: Process? + private var natsServerPort: Int? + private var natsWebsocketPort: Int? + private var tlsEnabled = false + private var pidFile: URL? + + public init() {} + + // TODO: When implementing JetStream, creating and deleting store dir should be handled in start/stop methods + public func start( + port: Int = -1, cfg: String? = nil, file: StaticString = #file, line: UInt = #line + ) { + XCTAssertNil( + self.process, "nats-server is already running on port \(port)", file: file, line: line) + let process = Process() + let pipe = Pipe() + + let fileManager = FileManager.default + pidFile = fileManager.temporaryDirectory.appendingPathComponent("nats-server.pid") + + let tempDir = FileManager.default.temporaryDirectory.appending(component: UUID().uuidString) + + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = [ + "nats-server", "-p", "\(port)", "-P", pidFile!.path, "--store_dir", + "\(tempDir.absoluteString)", + ] + if let cfg { + process.arguments?.append(contentsOf: ["-c", cfg]) + } + process.standardError = pipe + process.standardOutput = pipe + + let outputHandle = pipe.fileHandleForReading + let semaphore = DispatchSemaphore(value: 0) + var lineCount = 0 + let maxLines = 100 + var serverError: String? + var outputBuffer = Data() + + outputHandle.readabilityHandler = { fileHandle in + let data = fileHandle.availableData + guard data.count > 0 else { return } + outputBuffer.append(data) + + guard let output = String(data: outputBuffer, encoding: .utf8) else { return } + + let lines = output.split(separator: "\n", omittingEmptySubsequences: false) + let completedLines = lines.dropLast() + + for lineSequence in completedLines { + let line = String(lineSequence) + lineCount += 1 + + let errorLine = self.extracErrorMessage(from: line) + + if let port = self.extractPort(from: line, for: "client connections") { + self.natsServerPort = port + } + + if let port = self.extractPort(from: line, for: "websocket clients") { + self.natsWebsocketPort = port + } + + let ready = line.contains("Server is ready") + + if !self.tlsEnabled && self.isTLS(from: line) { + self.tlsEnabled = true + } + + if ready || errorLine != nil || lineCount >= maxLines { + serverError = errorLine + semaphore.signal() + outputHandle.readabilityHandler = nil + return + } + } + + if output.hasSuffix("\n") { + outputBuffer.removeAll() + } else { + if let lastLine = lines.last, let incompleteLine = lastLine.data(using: .utf8) { + outputBuffer = incompleteLine + } + } + } + + XCTAssertNoThrow( + try process.run(), "error starting nats-server on port \(port)", file: file, line: line) + + let result = semaphore.wait(timeout: .now() + .seconds(10)) + + XCTAssertFalse( + result == .timedOut, "timeout waiting for server to be ready", file: file, line: line) + XCTAssertNil( + serverError, "error starting nats-server: \(serverError!)", file: file, line: line) + + self.process = process + } + + public func stop() { + if process == nil { + return + } + + self.process?.terminate() + process?.waitUntilExit() + process = nil + natsServerPort = port + tlsEnabled = false + } + + public func sendSignal(_ signal: Signal, file: StaticString = #file, line: UInt = #line) { + let process = Process() + + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["nats-server", "--signal", "\(signal.rawValue)=\(self.pidFile!.path)"] + + XCTAssertNoThrow( + try process.run(), "error setting signal", file: file, line: line) + self.process = nil + } + + private func extractPort(from string: String, for phrase: String) -> Int? { + // Listening for websocket clients on + // Listening for client connections on + let pattern = "Listening for \(phrase) on .*?:(\\d+)$" + + let regex = try! NSRegularExpression(pattern: pattern) + let nsrange = NSRange(string.startIndex.. String? { + if logLine.contains("nats-server: No such file or directory") { + return "nats-server not found - make sure nats-server can be found in PATH" + } + guard let range = logLine.range(of: "[FTL]") else { + return nil + } + + let messageStartIndex = range.upperBound + let message = logLine[messageStartIndex...] + + return String(message).trimmingCharacters(in: .whitespaces) + } + + private func isTLS(from logLine: String) -> Bool { + return logLine.contains("TLS required for client connections") + || logLine.contains("websocket clients on wss://") + } + + deinit { + stop() + } + + public enum Signal: String { + case lameDuckMode = "ldm" + case reload = "reload" + } +} diff --git a/Sources/bench/main.swift b/Sources/bench/main.swift new file mode 100644 index 0000000..276445b --- /dev/null +++ b/Sources/bench/main.swift @@ -0,0 +1,65 @@ +// Copyright 2024 The NATS 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 +// +// http://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. + +import Foundation +import Nats + +if CommandLine.arguments.count != 4 { + exit(usage()) +} + +let cmd = CommandLine.arguments[1] + +if cmd != "pub" { + exit(usage()) +} + +let subject = CommandLine.arguments[2] +guard let msgs = Int(CommandLine.arguments[3]) else { + exit(usage()) +} + +let nats = NatsClientOptions() + .url(URL(string: ProcessInfo.processInfo.environment["NATS_URL"] ?? "nats://localhost:4222")!) + .build() + +print("connect") +try await nats.connect() + +let data = Data(repeating: 0, count: 128) + +print("start") +let now = DispatchTime.now() + +if cmd == "pub" { + try await pub() +} + +let elapsed = DispatchTime.now().uptimeNanoseconds - now.uptimeNanoseconds +let msgsPerSec: Double = Double(msgs) / (Double(elapsed) / 1_000_000_000) +print("elapsed: \(elapsed / 1_000_000)ms ~ \(msgsPerSec) msgs/s") + +func pub() async throws { + print("publish") + for _ in 1...msgs { + try await nats.publish(data, subject: subject) + } + + print("flush") + _ = try await nats.rtt() +} + +func usage() -> Int32 { + print("Usage: bench pub ") + return 2 +} diff --git a/Tests/JetStreamTests/Integration/ConsumerTests.swift b/Tests/JetStreamTests/Integration/ConsumerTests.swift new file mode 100644 index 0000000..d217073 --- /dev/null +++ b/Tests/JetStreamTests/Integration/ConsumerTests.swift @@ -0,0 +1,418 @@ +// Copyright 2024 The NATS 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 +// +// http://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. + +import JetStream +import Logging +import Nats +import NatsServer +import XCTest + +class ConsumerTests: XCTestCase { + + static var allTests = [ + ("testFetchWithDefaultOptions", testFetchWithDefaultOptions), + ("testFetchConsumerDeleted", testFetchConsumerDeleted), + ("testFetchExpires", testFetchExpires), + ("testFetchInvalidIdleHeartbeat", testFetchInvalidIdleHeartbeat), + ("testAck", testAck), + ("testNak", testNak), + ("testNakWithDelay", testNakWithDelay), + ("testTerm", testTerm), + ] + + var natsServer = NatsServer() + + override func tearDown() { + super.tearDown() + natsServer.stop() + } + + func testFetchWithDefaultOptions() async throws { + let bundle = Bundle.module + natsServer.start( + cfg: bundle.url(forResource: "jetstream", withExtension: "conf")!.relativePath) + logger.logLevel = .critical + + let client = NatsClientOptions().url(URL(string: natsServer.clientURL)!).build() + try await client.connect() + + let ctx = JetStreamContext(client: client) + + let streamCfg = StreamConfig(name: "test", subjects: ["foo.*"]) + let stream = try await ctx.createStream(cfg: streamCfg) + + let consumer = try await stream.createConsumer(cfg: ConsumerConfig(name: "cons")) + + let payload = "hello".data(using: .utf8)! + // publish some messages on stream + for _ in 1...100 { + let ack = try await ctx.publish("foo.A", message: payload) + _ = try await ack.wait() + } + let info = try await stream.info() + XCTAssertEqual(info.state.messages, 100) + + let batch = try await consumer.fetch(batch: 30) + + var i = 0 + for try await msg in batch { + try await msg.ack() + XCTAssertEqual(msg.payload, payload) + i += 1 + } + XCTAssertEqual(i, 30) + } + + func testFetchConsumerDeleted() async throws { + let bundle = Bundle.module + natsServer.start( + cfg: bundle.url(forResource: "jetstream", withExtension: "conf")!.relativePath) + logger.logLevel = .critical + + let client = NatsClientOptions().url(URL(string: natsServer.clientURL)!).build() + try await client.connect() + + let ctx = JetStreamContext(client: client) + + let streamCfg = StreamConfig(name: "test", subjects: ["foo.*"]) + let stream = try await ctx.createStream(cfg: streamCfg) + + let consumer = try await stream.createConsumer(cfg: ConsumerConfig(name: "cons")) + + let payload = "hello".data(using: .utf8)! + // publish some messages on stream + for _ in 1...10 { + let ack = try await ctx.publish("foo.A", message: payload) + _ = try await ack.wait() + } + let info = try await stream.info() + XCTAssertEqual(info.state.messages, 10) + + let batch = try await consumer.fetch(batch: 30) + + sleep(1) + try await stream.deleteConsumer(name: "cons") + var i = 0 + do { + for try await msg in batch { + try await msg.ack() + XCTAssertEqual(msg.payload, payload) + i += 1 + } + } catch JetStreamError.FetchError.consumerDeleted { + XCTAssertEqual(i, 10) + return + } + XCTFail("should get consumer deleted") + } + + func testFetchExpires() async throws { + let bundle = Bundle.module + natsServer.start( + cfg: bundle.url(forResource: "jetstream", withExtension: "conf")!.relativePath) + logger.logLevel = .critical + + let client = NatsClientOptions().url(URL(string: natsServer.clientURL)!).build() + try await client.connect() + + let ctx = JetStreamContext(client: client) + + let streamCfg = StreamConfig(name: "test", subjects: ["foo.*"]) + let stream = try await ctx.createStream(cfg: streamCfg) + + let consumer = try await stream.createConsumer(cfg: ConsumerConfig(name: "cons")) + + let payload = "hello".data(using: .utf8)! + // publish some messages on stream + for _ in 1...10 { + let ack = try await ctx.publish("foo.A", message: payload) + _ = try await ack.wait() + } + let info = try await stream.info() + XCTAssertEqual(info.state.messages, 10) + + let batch = try await consumer.fetch(batch: 30, expires: 1) + + var i = 0 + for try await msg in batch { + try await msg.ack() + XCTAssertEqual(msg.payload, payload) + i += 1 + } + XCTAssertEqual(i, 10) + } + + func testFetchInvalidIdleHeartbeat() async throws { + let bundle = Bundle.module + natsServer.start( + cfg: bundle.url(forResource: "jetstream", withExtension: "conf")!.relativePath) + logger.logLevel = .critical + + let client = NatsClientOptions().url(URL(string: natsServer.clientURL)!).build() + try await client.connect() + + let ctx = JetStreamContext(client: client) + + let streamCfg = StreamConfig(name: "test", subjects: ["foo.*"]) + let stream = try await ctx.createStream(cfg: streamCfg) + + let consumer = try await stream.createConsumer(cfg: ConsumerConfig(name: "cons")) + + let batch = try await consumer.fetch(batch: 30, expires: 1, idleHeartbeat: 2) + + do { + for try await _ in batch {} + } catch JetStreamError.FetchError.badRequest { + // success + return + } + XCTFail("should get bad request") + } + + func testFetchMissingHeartbeat() async throws { + let bundle = Bundle.module + natsServer.start( + cfg: bundle.url(forResource: "jetstream", withExtension: "conf")!.relativePath) + logger.logLevel = .critical + + let client = NatsClientOptions().url(URL(string: natsServer.clientURL)!).build() + try await client.connect() + + let ctx = JetStreamContext(client: client) + + let streamCfg = StreamConfig(name: "test", subjects: ["foo.*"]) + let stream = try await ctx.createStream(cfg: streamCfg) + + let consumer = try await stream.createConsumer(cfg: ConsumerConfig(name: "cons")) + + let payload = "hello".data(using: .utf8)! + // publish some messages on stream + for _ in 1...10 { + let ack = try await ctx.publish("foo.A", message: payload) + _ = try await ack.wait() + } + let info = try await stream.info() + XCTAssertEqual(info.state.messages, 10) + + try await stream.deleteConsumer(name: "cons") + + let batch = try await consumer.fetch(batch: 30, idleHeartbeat: 1) + + do { + for try await _ in batch {} + } catch JetStreamError.FetchError.noHeartbeatReceived { + return + } catch JetStreamError.FetchError.noResponders { + // This is also expected when the consumer has been deleted + return + } + XCTFail("should get missing heartbeats or no responders error") + } + + func testAck() async throws { + let bundle = Bundle.module + natsServer.start( + cfg: bundle.url(forResource: "jetstream", withExtension: "conf")!.relativePath) + logger.logLevel = .critical + + let client = NatsClientOptions().url(URL(string: natsServer.clientURL)!).build() + try await client.connect() + + let ctx = JetStreamContext(client: client) + + let streamCfg = StreamConfig(name: "test", subjects: ["foo.*"]) + let stream = try await ctx.createStream(cfg: streamCfg) + + // create a consumer with 500ms ack wait + let consumer = try await stream.createConsumer( + cfg: ConsumerConfig(name: "cons", ackWait: NanoTimeInterval(0.5))) + + // publish some messages on stream + for i in 0..<100 { + let ack = try await ctx.publish("foo.A", message: "\(i)".data(using: .utf8)!) + _ = try await ack.wait() + } + let info = try await stream.info() + XCTAssertEqual(info.state.messages, 100) + + var batch = try await consumer.fetch(batch: 10) + + var i = 0 + for try await msg in batch { + try await msg.ack() + XCTAssertEqual(String(decoding: msg.payload!, as: UTF8.self), "\(i)") + i += 1 + } + XCTAssertEqual(i, 10) + + // now wait 1 second and make sure the messages are not re-delivered + sleep(1) + + batch = try await consumer.fetch(batch: 10) + + for try await msg in batch { + try await msg.ack() + XCTAssertEqual(String(decoding: msg.payload!, as: UTF8.self), "\(i)") + i += 1 + } + XCTAssertEqual(i, 20) + } + + func testNak() async throws { + let bundle = Bundle.module + natsServer.start( + cfg: bundle.url(forResource: "jetstream", withExtension: "conf")!.relativePath) + logger.logLevel = .critical + + let client = NatsClientOptions().url(URL(string: natsServer.clientURL)!).build() + try await client.connect() + + let ctx = JetStreamContext(client: client) + + let streamCfg = StreamConfig(name: "test", subjects: ["foo.*"]) + let stream = try await ctx.createStream(cfg: streamCfg) + + // create a consumer with 500ms ack wait + let consumer = try await stream.createConsumer( + cfg: ConsumerConfig(name: "cons", ackWait: NanoTimeInterval(0.5))) + + // publish some messages on stream + for i in 0..<10 { + let ack = try await ctx.publish("foo.A", message: "\(i)".data(using: .utf8)!) + _ = try await ack.wait() + } + let info = try await stream.info() + XCTAssertEqual(info.state.messages, 10) + + var batch = try await consumer.fetch(batch: 1) + var iter = batch.makeAsyncIterator() + var msg = try await iter.next()! + XCTAssertEqual(String(decoding: msg.payload!, as: UTF8.self), "\(0)") + var meta = try msg.metadata() + XCTAssertEqual(meta.streamSequence, 1) + XCTAssertEqual(meta.consumerSequence, 1) + try await msg.ack(ackType: .nak()) + + // now fetch the message again, it should be redelivered + batch = try await consumer.fetch(batch: 1) + iter = batch.makeAsyncIterator() + msg = try await iter.next()! + XCTAssertEqual(String(decoding: msg.payload!, as: UTF8.self), "\(0)") + meta = try msg.metadata() + XCTAssertEqual(meta.streamSequence, 1) + XCTAssertEqual(meta.consumerSequence, 2) + try await msg.ack() + } + + func testNakWithDelay() async throws { + let bundle = Bundle.module + natsServer.start( + cfg: bundle.url(forResource: "jetstream", withExtension: "conf")!.relativePath) + logger.logLevel = .critical + + let client = NatsClientOptions().url(URL(string: natsServer.clientURL)!).build() + try await client.connect() + + let ctx = JetStreamContext(client: client) + + let streamCfg = StreamConfig(name: "test", subjects: ["foo.*"]) + let stream = try await ctx.createStream(cfg: streamCfg) + + // create a consumer with 500ms ack wait + let consumer = try await stream.createConsumer(cfg: ConsumerConfig(name: "cons")) + + // publish some messages on stream + for i in 0..<10 { + let ack = try await ctx.publish("foo.A", message: "\(i)".data(using: .utf8)!) + _ = try await ack.wait() + } + let info = try await stream.info() + XCTAssertEqual(info.state.messages, 10) + + var batch = try await consumer.fetch(batch: 1) + var iter = batch.makeAsyncIterator() + var msg = try await iter.next()! + XCTAssertEqual(String(decoding: msg.payload!, as: UTF8.self), "\(0)") + var meta = try msg.metadata() + XCTAssertEqual(meta.streamSequence, 1) + XCTAssertEqual(meta.consumerSequence, 1) + try await msg.ack(ackType: .nak(delay: 0.5)) + + // now fetch the next message immediately, it should be the next message + batch = try await consumer.fetch(batch: 1) + iter = batch.makeAsyncIterator() + msg = try await iter.next()! + XCTAssertEqual(String(decoding: msg.payload!, as: UTF8.self), "\(1)") + meta = try msg.metadata() + XCTAssertEqual(meta.streamSequence, 2) + XCTAssertEqual(meta.consumerSequence, 2) + try await msg.ack() + + // wait a second, the first message should be redelivered at this point + sleep(1) + batch = try await consumer.fetch(batch: 1) + iter = batch.makeAsyncIterator() + msg = try await iter.next()! + XCTAssertEqual(String(decoding: msg.payload!, as: UTF8.self), "\(0)") + meta = try msg.metadata() + XCTAssertEqual(meta.streamSequence, 1) + XCTAssertEqual(meta.consumerSequence, 3) + } + + func testTerm() async throws { + let bundle = Bundle.module + natsServer.start( + cfg: bundle.url(forResource: "jetstream", withExtension: "conf")!.relativePath) + logger.logLevel = .critical + + let client = NatsClientOptions().url(URL(string: natsServer.clientURL)!).build() + try await client.connect() + + let ctx = JetStreamContext(client: client) + + let streamCfg = StreamConfig(name: "test", subjects: ["foo.*"]) + let stream = try await ctx.createStream(cfg: streamCfg) + + // create a consumer with 500ms ack wait + let consumer = try await stream.createConsumer( + cfg: ConsumerConfig(name: "cons", ackWait: NanoTimeInterval(0.5))) + + // publish some messages on stream + for i in 0..<10 { + let ack = try await ctx.publish("foo.A", message: "\(i)".data(using: .utf8)!) + _ = try await ack.wait() + } + let info = try await stream.info() + XCTAssertEqual(info.state.messages, 10) + + var batch = try await consumer.fetch(batch: 1) + var iter = batch.makeAsyncIterator() + var msg = try await iter.next()! + XCTAssertEqual(String(decoding: msg.payload!, as: UTF8.self), "\(0)") + var meta = try msg.metadata() + XCTAssertEqual(meta.streamSequence, 1) + XCTAssertEqual(meta.consumerSequence, 1) + try await msg.ack(ackType: .term()) + + // wait 1s, the first message should not be redelivered (even though we are past ack wait) + sleep(1) + batch = try await consumer.fetch(batch: 1) + iter = batch.makeAsyncIterator() + msg = try await iter.next()! + XCTAssertEqual(String(decoding: msg.payload!, as: UTF8.self), "\(1)") + meta = try msg.metadata() + XCTAssertEqual(meta.streamSequence, 2) + XCTAssertEqual(meta.consumerSequence, 2) + try await msg.ack() + } +} diff --git a/Tests/JetStreamTests/Integration/JetStreamTests.swift b/Tests/JetStreamTests/Integration/JetStreamTests.swift new file mode 100644 index 0000000..241e8f1 --- /dev/null +++ b/Tests/JetStreamTests/Integration/JetStreamTests.swift @@ -0,0 +1,1033 @@ +// Copyright 2024 The NATS 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 +// +// http://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. + +import JetStream +import Logging +import NIO +import Nats +import NatsServer +import XCTest + +class JetStreamTests: XCTestCase { + + static var allTests = [ + ("testJetStreamContext", testJetStreamContext), + ("testJetStreamNotEnabled", testJetStreamNotEnabled), + ("testJetStreamNotEnabledForAccount", testJetStreamNotEnabledForAccount), + ("testStreamCRUD", testStreamCRUD), + ("testStreamConfig", testStreamConfig), + ("testStreamInfo", testStreamInfo), + ("testListStreams", testListStreams), + ("testGetMessage", testGetMessage), + ("testGetMessageDirect", testGetMessageDirect), + ("testDeleteMessage", testDeleteMessage), + ("testPurge", testPurge), + ("testPurgeSequence", testPurgeSequence), + ("testPurgeKeepm", testPurgeKeep), + ("testJetStreamContextConsumerCRUD", testJetStreamContextConsumerCRUD), + ("testStreamConsumerCRUD", testStreamConsumerCRUD), + ("testConsumerConfig", testConsumerConfig), + ("testCreateEphemeralConsumer", testCreateEphemeralConsumer), + ("testConsumerInfo", testConsumerInfo), + ("testConsumerInfoWithCustomInbox", testConsumerInfoWithCustomInbox), + ("testListConsumers", testListConsumers), + ] + + var natsServer = NatsServer() + + override func tearDown() { + super.tearDown() + natsServer.stop() + } + + func testJetStreamContext() async throws { + let bundle = Bundle.module + natsServer.start( + cfg: bundle.url(forResource: "jetstream", withExtension: "conf")!.relativePath) + logger.logLevel = .critical + + let client = NatsClientOptions().url(URL(string: natsServer.clientURL)!).build() + try await client.connect() + + _ = JetStreamContext(client: client) + _ = JetStreamContext(client: client, prefix: "$JS.API") + _ = JetStreamContext(client: client, domain: "STREAMS") + _ = JetStreamContext(client: client, timeout: 10) + let ctx = JetStreamContext(client: client) + + let stream = """ + { + "name": "FOO", + "subjects": ["foo"] + } + """ + let data = stream.data(using: .utf8)! + + _ = try await client.request(data, subject: "$JS.API.STREAM.CREATE.FOO") + let ack = try await ctx.publish("foo", message: "Hello, World!".data(using: .utf8)!) + _ = try await ack.wait() + + try await client.close() + } + + func testJetStreamContextWithPrefix() async throws { + let bundle = Bundle.module + natsServer.start( + cfg: bundle.url(forResource: "prefix", withExtension: "conf")!.relativePath) + logger.logLevel = .critical + + let clientA = NatsClientOptions() + .url(URL(string: natsServer.clientURL)!) + .usernameAndPassword("a", "a") + .build() + try await clientA.connect() + + let clientI = NatsClientOptions() + .url(URL(string: natsServer.clientURL)!) + .usernameAndPassword("i", "i") + .build() + try await clientI.connect() + + let jsA = JetStreamContext(client: clientA) + let jsI = JetStreamContext(client: clientI, prefix: "fromA") + + _ = try await jsI.createStream(cfg: StreamConfig(name: "TEST", subjects: ["foo"])) + _ = try await jsA.getStream(name: "TEST") + } + + func testJetStreamContextWithDomain() async throws { + let bundle = Bundle.module + natsServer.start( + cfg: bundle.url(forResource: "domain", withExtension: "conf")!.relativePath) + logger.logLevel = .critical + + let client = NatsClientOptions() + .url(URL(string: natsServer.clientURL)!) + .build() + try await client.connect() + + let js = JetStreamContext(client: client, domain: "ABC") + _ = try await js.createStream(cfg: StreamConfig(name: "TEST", subjects: ["foo"])) + } + + func testJetStreamNotEnabled() async throws { + natsServer.start() + logger.logLevel = .critical + let client = NatsClientOptions() + .url(URL(string: natsServer.clientURL)!) + .build() + try await client.connect() + + let ctx = JetStreamContext(client: client) + + do { + _ = try await ctx.createStream(cfg: StreamConfig(name: "test")) + } catch JetStreamError.RequestError.noResponders { + // success + return + } + } + + func testJetStreamNotEnabledForAccount() async throws { + natsServer.start() + logger.logLevel = .critical + let client = NatsClientOptions() + .url(URL(string: natsServer.clientURL)!) + .build() + try await client.connect() + + let ctx = JetStreamContext(client: client) + + do { + _ = try await ctx.createStream(cfg: StreamConfig(name: "test")) + } catch JetStreamError.RequestError.noResponders { + // success + return + } + } + + func testStreamCRUD() async throws { + let bundle = Bundle.module + natsServer.start( + cfg: bundle.url(forResource: "jetstream", withExtension: "conf")!.relativePath) + logger.logLevel = .critical + + let client = NatsClientOptions().url(URL(string: natsServer.clientURL)!).build() + try await client.connect() + + let ctx = JetStreamContext(client: client) + + // minimal config + var cfg = StreamConfig(name: "test", subjects: ["foo"]) + let stream = try await ctx.createStream(cfg: cfg) + + var expectedConfig = StreamConfig( + name: "test", description: nil, subjects: ["foo"], retention: .limits, maxConsumers: -1, + maxMsgs: -1, maxBytes: -1, discard: .old, discardNewPerSubject: nil, + maxAge: NanoTimeInterval(0), maxMsgsPerSubject: -1, maxMsgSize: -1, storage: .file, + replicas: 1, noAck: nil, duplicates: NanoTimeInterval(120), placement: nil, mirror: nil, + sources: nil, sealed: false, denyDelete: false, denyPurge: false, allowRollup: false, + compression: StoreCompression.none, firstSeq: nil, subjectTransform: nil, + rePublish: nil, allowDirect: false, mirrorDirect: false, + consumerLimits: StreamConsumerLimits(inactiveThreshold: nil, maxAckPending: nil), + metadata: nil) + + // we need to set the metadata to whatever was set by the server as it contains e.g. server version + expectedConfig.metadata = stream.info.config.metadata + + XCTAssertEqual(expectedConfig, stream.info.config) + + // attempt overwriting existing stream + var errOk = false + do { + _ = try await ctx.createStream( + cfg: StreamConfig(name: "test", description: "cannot update with create")) + } catch JetStreamError.StreamError.streamNameExist(_) { + errOk = true + // success + } + XCTAssertTrue(errOk, "Expected stream not found error") + + // get a stream + guard var stream = try await ctx.getStream(name: "test") else { + XCTFail("Expected a stream, got nil") + return + } + XCTAssertEqual(expectedConfig, stream.info.config) + + // get a non-existing stream + errOk = false + if let _ = try await ctx.getStream(name: "bad") { + XCTFail("Expected stream not found, go: \(stream)") + } + + // update the stream + cfg.description = "updated" + stream = try await ctx.updateStream(cfg: cfg) + expectedConfig.description = "updated" + + XCTAssertEqual(expectedConfig, stream.info.config) + + // attempt to update illegal stream property + cfg.storage = .memory + // attempt updating non-existing stream + errOk = false + do { + _ = try await ctx.updateStream(cfg: cfg) + } catch JetStreamError.StreamError.invalidConfig(_) { + // success + errOk = true + } + + // attempt updating non-existing stream + errOk = false + do { + _ = try await ctx.updateStream(cfg: StreamConfig(name: "bad")) + } catch JetStreamError.StreamError.streamNotFound(_) { + // success + errOk = true + } + XCTAssertTrue(errOk, "Expected stream not found error") + + // delete the stream + try await ctx.deleteStream(name: "test") + + // make sure the stream no longer exists + if let _ = try await ctx.getStream(name: "test") { + XCTFail("Expected stream not found") + } + } + + func testStreamConfig() async throws { + let bundle = Bundle.module + natsServer.start( + cfg: bundle.url(forResource: "jetstream", withExtension: "conf")!.relativePath) + logger.logLevel = .critical + + let client = NatsClientOptions().url(URL(string: natsServer.clientURL)!).build() + try await client.connect() + + let ctx = JetStreamContext(client: client) + + var cfg = StreamConfig( + name: "full", description: "desc", subjects: ["bar"], retention: .interest, + maxConsumers: 50, maxMsgs: 100, maxBytes: 1000, discard: .new, + discardNewPerSubject: true, maxAge: NanoTimeInterval(300), maxMsgsPerSubject: 50, + maxMsgSize: 100, storage: .memory, replicas: 1, noAck: true, + duplicates: NanoTimeInterval(120), placement: Placement(cluster: "cluster"), + mirror: nil, sources: [StreamSource(name: "source")], sealed: false, denyDelete: false, + denyPurge: true, allowRollup: false, compression: .s2, firstSeq: 10, + subjectTransform: nil, rePublish: nil, allowDirect: false, mirrorDirect: false, + consumerLimits: StreamConsumerLimits(inactiveThreshold: NanoTimeInterval(10)), + metadata: ["key": "value"]) + + let stream = try await ctx.createStream(cfg: cfg) + + // we need to set the metadata to whatever was set by the server as it contains e.g. server version + cfg.metadata = stream.info.config.metadata + + XCTAssertEqual(stream.info.config, cfg) + } + + func testStreamInfo() async throws { + let bundle = Bundle.module + natsServer.start( + cfg: bundle.url(forResource: "jetstream", withExtension: "conf")!.relativePath) + logger.logLevel = .critical + + let client = NatsClientOptions().url(URL(string: natsServer.clientURL)!).build() + try await client.connect() + + let ctx = JetStreamContext(client: client) + + // minimal config + let cfg = StreamConfig(name: "test", subjects: ["foo"]) + let stream = try await ctx.createStream(cfg: cfg) + + let info = try await stream.info() + XCTAssertEqual(info.config.name, "test") + + // simulate external update of stream + let updateJSON = """ + { + "name": "test", + "subjects": ["foo"], + "description": "updated" + } + """ + let data = updateJSON.data(using: .utf8)! + + _ = try await client.request(data, subject: "$JS.API.STREAM.UPDATE.test") + + XCTAssertNil(stream.info.config.description) + + let newInfo = try await stream.info() + XCTAssertEqual(newInfo.config.description, "updated") + XCTAssertEqual(stream.info.config.description, "updated") + } + + func testListStreams() async throws { + let bundle = Bundle.module + natsServer.start( + cfg: bundle.url(forResource: "jetstream", withExtension: "conf")!.relativePath) + logger.logLevel = .critical + + let client = NatsClientOptions().url(URL(string: natsServer.clientURL)!).build() + try await client.connect() + + let ctx = JetStreamContext(client: client) + + for i in 0..<260 { + let subPrefix = i % 2 == 0 ? "foo" : "bar" + let cfg = StreamConfig(name: "STREAM-\(i)", subjects: ["\(subPrefix).\(i)"]) + let _ = try await ctx.createStream(cfg: cfg) + } + + // list all streams + var streams = await ctx.streams() + + var i = 0 + for try await _ in streams { + i += 1 + } + XCTAssertEqual(i, 260) + + var names = await ctx.streamNames() + i = 0 + for try await _ in names { + i += 1 + } + XCTAssertEqual(i, 260) + + // list streams with subject foo.* + streams = await ctx.streams(subject: "foo.*") + + i = 0 + for try await stream in streams { + XCTAssert(stream.config.subjects!.first!.starts(with: "foo.")) + i += 1 + } + XCTAssertEqual(i, 130) + + names = await ctx.streamNames(subject: "foo.*") + i = 0 + for try await _ in names { + i += 1 + } + XCTAssertEqual(i, 130) + + // list streams with subject not matching any + streams = await ctx.streams(subject: "baz.*") + + i = 0 + for try await stream in streams { + XCTFail("should return 0 streams, got: \(stream.config)") + } + XCTAssertEqual(i, 0) + + names = await ctx.streamNames(subject: "baz.*") + i = 0 + for try await _ in names { + i += 1 + } + XCTAssertEqual(i, 0) + } + + func testGetMessage() async throws { + let bundle = Bundle.module + natsServer.start( + cfg: bundle.url(forResource: "jetstream", withExtension: "conf")!.relativePath) + logger.logLevel = .critical + + let client = NatsClientOptions().url(URL(string: natsServer.clientURL)!).build() + try await client.connect() + + let ctx = JetStreamContext(client: client) + + let cfg = StreamConfig(name: "STREAM", subjects: ["foo.*"]) + let stream = try await ctx.createStream(cfg: cfg) + + var hm = NatsHeaderMap() + hm[try! NatsHeaderName("foo")] = NatsHeaderValue("bar") + hm.append(try! NatsHeaderName("foo"), NatsHeaderValue("baz")) + hm[try! NatsHeaderName("key")] = NatsHeaderValue("val") + for i in 1...100 { + let msg = "\(i)".data(using: .utf8)! + let subj = i % 2 == 0 ? "foo.A" : "foo.B" + let ack = try await ctx.publish(subj, message: msg, headers: hm) + _ = try await ack.wait() + } + + // get by sequence + var msg = try await stream.getMessage(sequence: 50) + XCTAssertEqual(msg!.payload, "50".data(using: .utf8)!) + + // get by sequence and subject + msg = try await stream.getMessage(sequence: 50, subject: "foo.B") + // msg with sequence 50 is on subject foo.A, so we expect the next message which should be on foo.B + XCTAssertEqual(msg!.payload, "51".data(using: .utf8)!) + XCTAssertEqual(msg!.headers, hm) + + // get first message from a subject + msg = try await stream.getMessage(firstForSubject: "foo.A") + XCTAssertEqual(msg!.payload, "2".data(using: .utf8)!) + XCTAssertEqual(msg!.headers, hm) + + // get last message from subject + msg = try await stream.getMessage(lastForSubject: "foo.B") + XCTAssertEqual(msg!.payload, "99".data(using: .utf8)!) + XCTAssertEqual(msg!.headers, hm) + + // message not found + msg = try await stream.getMessage(sequence: 200) + XCTAssertNil(msg) + } + + func testGetMessageDirect() async throws { + let bundle = Bundle.module + natsServer.start( + cfg: bundle.url(forResource: "jetstream", withExtension: "conf")!.relativePath) + logger.logLevel = .critical + + let client = NatsClientOptions().url(URL(string: natsServer.clientURL)!).build() + try await client.connect() + + let ctx = JetStreamContext(client: client) + + let cfg = StreamConfig(name: "STREAM", subjects: ["foo.*"], allowDirect: true) + let stream = try await ctx.createStream(cfg: cfg) + + var hm = NatsHeaderMap() + hm[try! NatsHeaderName("foo")] = NatsHeaderValue("bar") + hm.append(try! NatsHeaderName("foo"), NatsHeaderValue("baz")) + hm[try! NatsHeaderName("key")] = NatsHeaderValue("val") + for i in 1...100 { + let msg = "\(i)".data(using: .utf8)! + let subj = i % 2 == 0 ? "foo.A" : "foo.B" + let ack = try await ctx.publish(subj, message: msg, headers: hm) + _ = try await ack.wait() + } + + // get by sequence + var msg = try await stream.getMessageDirect(sequence: 50) + XCTAssertEqual(msg!.payload, "50".data(using: .utf8)!) + + // get by sequence and subject + msg = try await stream.getMessageDirect(sequence: 50, subject: "foo.B") + // msg with sequence 50 is on subject foo.A, so we expect the next message which should be on foo.B + XCTAssertEqual(msg!.payload, "51".data(using: .utf8)!) + + // get first message from a subject + msg = try await stream.getMessageDirect(firstForSubject: "foo.A") + XCTAssertEqual(msg!.payload, "2".data(using: .utf8)!) + + // get last message from subject + msg = try await stream.getMessageDirect(lastForSubject: "foo.B") + XCTAssertEqual(msg!.payload, "99".data(using: .utf8)!) + + // message not found + msg = try await stream.getMessageDirect(sequence: 200) + XCTAssertNil(msg) + } + + func testDeleteMessage() async throws { + let bundle = Bundle.module + natsServer.start( + cfg: bundle.url(forResource: "jetstream", withExtension: "conf")!.relativePath) + logger.logLevel = .critical + + let client = NatsClientOptions().url(URL(string: natsServer.clientURL)!).build() + try await client.connect() + + let ctx = JetStreamContext(client: client) + + let cfg = StreamConfig(name: "STREAM", subjects: ["foo.*"]) + let stream = try await ctx.createStream(cfg: cfg) + + for i in 1...10 { + let msg = "\(i)".data(using: .utf8)! + let subj = "foo.A" + let ack = try await ctx.publish(subj, message: msg) + _ = try await ack.wait() + } + + // get by sequence to make sure the msg is available + var msg = try await stream.getMessage(sequence: 5) + XCTAssertEqual(msg!.payload, "5".data(using: .utf8)!) + + // delete + try await stream.deleteMessage(sequence: 5) + + msg = try await stream.getMessage(sequence: 5) + XCTAssertNil(msg) + + // try deleting the msg again + var errOk = false + do { + try await stream.deleteMessage(sequence: 5) + } catch JetStreamError.StreamMessageError.deleteSequenceNotFound(_) { + // success + errOk = true + } + XCTAssertTrue(errOk, "Expected sequence not found error") + + // now do the same with secure delete + // we cannot easily test whether the operation actually overwritten the value from unit test + msg = try await stream.getMessage(sequence: 7) + XCTAssertEqual(msg!.payload, "7".data(using: .utf8)!) + + // delete + try await stream.deleteMessage(sequence: 7, secure: true) + + msg = try await stream.getMessage(sequence: 7) + XCTAssertNil(msg) + } + + func testPurge() async throws { + let bundle = Bundle.module + natsServer.start( + cfg: bundle.url(forResource: "jetstream", withExtension: "conf")!.relativePath) + logger.logLevel = .critical + + let client = NatsClientOptions().url(URL(string: natsServer.clientURL)!).build() + try await client.connect() + + let ctx = JetStreamContext(client: client) + + let cfg = StreamConfig(name: "STREAM", subjects: ["foo.*"]) + let stream = try await ctx.createStream(cfg: cfg) + + let data = "hello".data(using: .utf8)! + for _ in 0..<3 { + let subj = "foo.A" + let ack = try await ctx.publish(subj, message: data) + _ = try await ack.wait() + } + for _ in 0..<4 { + let subj = "foo.B" + let ack = try await ctx.publish(subj, message: data) + _ = try await ack.wait() + } + for _ in 0..<5 { + let subj = "foo.C" + let ack = try await ctx.publish(subj, message: data) + _ = try await ack.wait() + } + + // purge foo.B + var purged = try await stream.purge(subject: "foo.B") + XCTAssertEqual(purged, 4) + var info = try await stream.info() + XCTAssertEqual(info.state.messages, 8) + + // purge rest of the messages + purged = try await stream.purge() + XCTAssertEqual(purged, 8) + info = try await stream.info() + XCTAssertEqual(info.state.messages, 0) + } + + func testPurgeSequence() async throws { + let bundle = Bundle.module + natsServer.start( + cfg: bundle.url(forResource: "jetstream", withExtension: "conf")!.relativePath) + logger.logLevel = .critical + + let client = NatsClientOptions().url(URL(string: natsServer.clientURL)!).build() + try await client.connect() + + let ctx = JetStreamContext(client: client) + + let cfg = StreamConfig(name: "STREAM", subjects: ["foo.*"]) + let stream = try await ctx.createStream(cfg: cfg) + + let data = "hello".data(using: .utf8)! + for _ in 0..<10 { + let subj = "foo.A" + let ack = try await ctx.publish(subj, message: data) + _ = try await ack.wait() + } + for _ in 0..<20 { + let subj = "foo.B" + let ack = try await ctx.publish(subj, message: data) + _ = try await ack.wait() + } + for _ in 0..<30 { + let subj = "foo.C" + let ack = try await ctx.publish(subj, message: data) + _ = try await ack.wait() + } + + // purge "foo.B" with sequence 15 + // This should remove only the first 4 messages on foo.B + var purged = try await stream.purge(sequence: 15, subject: "foo.B") + XCTAssertEqual(purged, 4) + var info = try await stream.info() + XCTAssertEqual(info.state.messages, 56) + + // purge with sequence 41, no filter + // This should remove the first 36 (after previous purge) messages + // and leave us with 20 messages + purged = try await stream.purge(sequence: 41) + XCTAssertEqual(purged, 36) + info = try await stream.info() + XCTAssertEqual(info.state.messages, 20) + } + + func testPurgeKeep() async throws { + let bundle = Bundle.module + natsServer.start( + cfg: bundle.url(forResource: "jetstream", withExtension: "conf")!.relativePath) + logger.logLevel = .critical + + let client = NatsClientOptions().url(URL(string: natsServer.clientURL)!).build() + try await client.connect() + + let ctx = JetStreamContext(client: client) + + let cfg = StreamConfig(name: "STREAM", subjects: ["foo.*"]) + let stream = try await ctx.createStream(cfg: cfg) + + let data = "hello".data(using: .utf8)! + for _ in 0..<10 { + let subj = "foo.A" + let ack = try await ctx.publish(subj, message: data) + _ = try await ack.wait() + } + for _ in 0..<20 { + let subj = "foo.B" + let ack = try await ctx.publish(subj, message: data) + _ = try await ack.wait() + } + for _ in 0..<30 { + let subj = "foo.C" + let ack = try await ctx.publish(subj, message: data) + _ = try await ack.wait() + } + + // purge "foo.B" retaining 50 messages + // This should remove 15 messages from "foo.B" + var purged = try await stream.purge(keep: 5, subject: "foo.B") + XCTAssertEqual(purged, 15) + var info = try await stream.info() + XCTAssertEqual(info.state.messages, 45) + + // purge with keep 10, no filter + // This should remove all but 10 messages from the stream + purged = try await stream.purge(keep: 10) + XCTAssertEqual(purged, 35) + info = try await stream.info() + XCTAssertEqual(info.state.messages, 10) + } + + func testStreamConsumerCRUD() async throws { + let bundle = Bundle.module + natsServer.start( + cfg: bundle.url(forResource: "jetstream", withExtension: "conf")!.relativePath) + logger.logLevel = .critical + + let client = NatsClientOptions().url(URL(string: natsServer.clientURL)!).build() + try await client.connect() + + let ctx = JetStreamContext(client: client) + + let streamCfg = StreamConfig(name: "test", subjects: ["foo"]) + let stream = try await ctx.createStream(cfg: streamCfg) + + var expectedConfig = ConsumerConfig( + name: "test", durable: nil, description: nil, deliverPolicy: .all, optStartSeq: nil, + optStartTime: nil, ackPolicy: .explicit, ackWait: NanoTimeInterval(30), maxDeliver: -1, + backOff: nil, filterSubject: nil, filterSubjects: nil, replayPolicy: .instant, + rateLimit: nil, + sampleFrequency: nil, maxWaiting: 512, maxAckPending: 1000, headersOnly: nil, + maxRequestBatch: nil, maxRequestExpires: nil, maxRequestMaxBytes: nil, + inactiveThreshold: NanoTimeInterval(5), replicas: 1, memoryStorage: nil, metadata: nil) + // minimal config + var cfg = ConsumerConfig(name: "test") + _ = try await stream.createConsumer(cfg: cfg) + + // attempt overwriting existing consumer + var errOk = false + do { + _ = try await stream.createConsumer( + cfg: ConsumerConfig(name: "test", description: "cannot update with create")) + } catch JetStreamError.ConsumerError.consumerNameExist(_) { + errOk = true + // success + } + XCTAssertTrue(errOk, "Expected consumer exists error") + + // get a consumer + guard var cons = try await stream.getConsumer(name: "test") else { + XCTFail("Expected a stream, got nil") + return + } + + // we need to set the metadata to whatever was set by the server as it contains e.g. server version + expectedConfig.metadata = cons.info.config.metadata + + XCTAssertEqual(expectedConfig, cons.info.config) + + // get a non-existing consumer + errOk = false + if let cons = try await stream.getConsumer(name: "bad") { + XCTFail("Expected consumer not found, got: \(cons)") + } + + // update the stream + cfg.description = "updated" + cons = try await stream.updateConsumer(cfg: cfg) + expectedConfig.description = "updated" + + XCTAssertEqual(expectedConfig, cons.info.config) + + // attempt to update illegal consumer property + cfg.memoryStorage = true + errOk = false + do { + _ = try await stream.updateConsumer(cfg: cfg) + } catch JetStreamError.ConsumerError.invalidConfig(_) { + // success + errOk = true + } + + // attempt updating non-existing consumer + errOk = false + do { + _ = try await stream.updateConsumer(cfg: ConsumerConfig(name: "bad")) + } catch JetStreamError.ConsumerError.consumerDoesNotExist(_) { + // success + errOk = true + } + XCTAssertTrue(errOk, "Expected consumer not found error") + + // delete the consumer + try await stream.deleteConsumer(name: "test") + + // make sure the consumer no longer exists + if let _ = try await stream.getConsumer(name: "test") { + XCTFail("Expected consumer not found") + } + } + + func testJetStreamContextConsumerCRUD() async throws { + let bundle = Bundle.module + natsServer.start( + cfg: bundle.url(forResource: "jetstream", withExtension: "conf")!.relativePath) + logger.logLevel = .critical + + let client = NatsClientOptions().url(URL(string: natsServer.clientURL)!).build() + try await client.connect() + + let ctx = JetStreamContext(client: client) + + let streamCfg = StreamConfig(name: "test", subjects: ["foo"]) + _ = try await ctx.createStream(cfg: streamCfg) + + var expectedConfig = ConsumerConfig( + name: "test", durable: nil, description: nil, deliverPolicy: .all, optStartSeq: nil, + optStartTime: nil, ackPolicy: .explicit, ackWait: NanoTimeInterval(30), maxDeliver: -1, + backOff: nil, filterSubject: nil, filterSubjects: nil, replayPolicy: .instant, + rateLimit: nil, + sampleFrequency: nil, maxWaiting: 512, maxAckPending: 1000, headersOnly: nil, + maxRequestBatch: nil, maxRequestExpires: nil, maxRequestMaxBytes: nil, + inactiveThreshold: NanoTimeInterval(5), replicas: 1, memoryStorage: nil, + metadata: nil) + // minimal config + var cfg = ConsumerConfig(name: "test") + _ = try await ctx.createConsumer(stream: "test", cfg: cfg) + + // attempt overwriting existing consumer + var errOk = false + do { + _ = try await ctx.createConsumer( + stream: "test", + cfg: ConsumerConfig(name: "test", description: "cannot update with create")) + } catch JetStreamError.ConsumerError.consumerNameExist(_) { + errOk = true + // success + } + XCTAssertTrue(errOk, "Expected consumer exists error") + + // get a consumer + guard var cons = try await ctx.getConsumer(stream: "test", name: "test") else { + XCTFail("Expected a stream, got nil") + return + } + // we need to set the metadata to whatever was set by the server as it contains e.g. server version + expectedConfig.metadata = cons.info.config.metadata + XCTAssertEqual(expectedConfig, cons.info.config) + + // get a non-existing consumer + errOk = false + if let cons = try await ctx.getConsumer(stream: "test", name: "bad") { + XCTFail("Expected consumer not found, got: \(cons)") + } + + // update the stream + cfg.description = "updated" + cons = try await ctx.updateConsumer(stream: "test", cfg: cfg) + expectedConfig.description = "updated" + + XCTAssertEqual(expectedConfig, cons.info.config) + + // attempt to update illegal consumer property + cfg.memoryStorage = true + errOk = false + do { + _ = try await ctx.updateConsumer(stream: "test", cfg: cfg) + } catch JetStreamError.ConsumerError.invalidConfig(_) { + // success + errOk = true + } + + // attempt updating non-existing consumer + errOk = false + do { + _ = try await ctx.updateConsumer(stream: "test", cfg: ConsumerConfig(name: "bad")) + } catch JetStreamError.ConsumerError.consumerDoesNotExist(_) { + // success + errOk = true + } + XCTAssertTrue(errOk, "Expected consumer not found error") + + // delete the consumer + try await ctx.deleteConsumer(stream: "test", name: "test") + + // make sure the consumer no longer exists + if let _ = try await ctx.getConsumer(stream: "test", name: "test") { + XCTFail("Expected consumer not found") + } + } + + func testConsumerConfig() async throws { + let bundle = Bundle.module + natsServer.start( + cfg: bundle.url(forResource: "jetstream", withExtension: "conf")!.relativePath) + logger.logLevel = .critical + + let client = NatsClientOptions().url(URL(string: natsServer.clientURL)!).build() + try await client.connect() + + let ctx = JetStreamContext(client: client) + + var cfg = ConsumerConfig( + name: "test", durable: nil, description: "consumer", deliverPolicy: .byStartSequence, + optStartSeq: 10, optStartTime: nil, ackPolicy: .none, ackWait: NanoTimeInterval(5), + maxDeliver: 100, backOff: [NanoTimeInterval(5), NanoTimeInterval(10)], + filterSubject: "FOO.A", filterSubjects: nil, replayPolicy: .original, rateLimit: nil, + sampleFrequency: "50", + maxWaiting: 20, maxAckPending: 20, headersOnly: true, maxRequestBatch: 5, + maxRequestExpires: NanoTimeInterval(120), maxRequestMaxBytes: 1024, + inactiveThreshold: NanoTimeInterval(30), replicas: 1, memoryStorage: true, + metadata: ["a": "b"]) + + let stream = try await ctx.createStream( + cfg: StreamConfig(name: "stream", subjects: ["FOO.*"])) + + let cons = try await stream.createConsumer(cfg: cfg) + cfg.metadata = cons.info.config.metadata + + XCTAssertEqual(cfg, cons.info.config) + } + + func testCreateEphemeralConsumer() async throws { + let bundle = Bundle.module + natsServer.start( + cfg: bundle.url(forResource: "jetstream", withExtension: "conf")!.relativePath) + logger.logLevel = .critical + + let client = NatsClientOptions().url(URL(string: natsServer.clientURL)!).build() + try await client.connect() + + let ctx = JetStreamContext(client: client) + let stream = try await ctx.createStream( + cfg: StreamConfig(name: "stream", subjects: ["FOO.*"])) + + let cons = try await stream.createConsumer(cfg: ConsumerConfig()) + + XCTAssertEqual(cons.info.name.count, 8) + } + + func testConsumerInfo() async throws { + let bundle = Bundle.module + natsServer.start( + cfg: bundle.url(forResource: "jetstream", withExtension: "conf")!.relativePath) + logger.logLevel = .critical + + let client = NatsClientOptions().url(URL(string: natsServer.clientURL)!).build() + try await client.connect() + + let ctx = JetStreamContext(client: client) + + let stream = try await ctx.createStream(cfg: StreamConfig(name: "test", subjects: ["foo"])) + + let cfg = ConsumerConfig(name: "cons") + let consumer = try await stream.createConsumer(cfg: cfg) + + let info = try await consumer.info() + XCTAssertEqual(info.config.name, "cons") + + // simulate external update of consumer + let updateJSON = """ + { + "stream_name": "test", + "config": { + "name": "cons", + "description": "updated", + "ack_policy": "explicit" + }, + "action": "update" + } + """ + let data = updateJSON.data(using: .utf8)! + + _ = try await client.request(data, subject: "$JS.API.CONSUMER.CREATE.test.cons") + + XCTAssertNil(consumer.info.config.description) + + let newInfo = try await consumer.info() + XCTAssertEqual(newInfo.config.description, "updated") + XCTAssertEqual(consumer.info.config.description, "updated") + } + + func testConsumerInfoWithCustomInbox() async throws { + let bundle = Bundle.module + natsServer.start( + cfg: bundle.url(forResource: "jetstream", withExtension: "conf")!.relativePath) + logger.logLevel = .debug + + let client = NatsClientOptions() + .url(URL(string: natsServer.clientURL)!) + .inboxPrefix("_INBOX_custom.baz") + .build() + try await client.connect() + + let ctx = JetStreamContext(client: client) + + let stream = try await ctx.createStream(cfg: StreamConfig(name: "test", subjects: ["foo"])) + + let cfg = ConsumerConfig(name: "cons") + let consumer = try await stream.createConsumer(cfg: cfg) + + let info = try await consumer.info() + XCTAssertEqual(info.config.name, "cons") + + // simulate external update of consumer + let updateJSON = """ + { + "stream_name": "test", + "config": { + "name": "cons", + "description": "updated", + "ack_policy": "explicit" + }, + "action": "update" + } + """ + let data = updateJSON.data(using: .utf8)! + + _ = try await client.request(data, subject: "$JS.API.CONSUMER.CREATE.test.cons") + + XCTAssertNil(consumer.info.config.description) + + let newInfo = try await consumer.info() + XCTAssertEqual(newInfo.config.description, "updated") + XCTAssertEqual(consumer.info.config.description, "updated") + } + + func testListConsumers() async throws { + let bundle = Bundle.module + natsServer.start( + cfg: bundle.url(forResource: "jetstream", withExtension: "conf")!.relativePath) + logger.logLevel = .critical + + let client = NatsClientOptions().url(URL(string: natsServer.clientURL)!).build() + try await client.connect() + + let ctx = JetStreamContext(client: client) + + let stream = try await ctx.createStream( + cfg: StreamConfig(name: "test", subjects: ["foo.*"])) + + for i in 0..<260 { + let cfg = ConsumerConfig(name: "CONSUMER-\(i)") + let _ = try await stream.createConsumer(cfg: cfg) + } + + // list all consumers + var consumers = await stream.consumers() + + var i = 0 + for try await _ in consumers { + i += 1 + } + XCTAssertEqual(i, 260) + + let names = await stream.consumerNames() + i = 0 + for try await _ in names { + i += 1 + } + XCTAssertEqual(i, 260) + + // list consumers on non-existing stream + consumers = await ctx.consumers(stream: "bad") + i = 0 + for try await _ in consumers { + i += 1 + } + XCTAssertEqual(i, 0) + } + +} diff --git a/Tests/JetStreamTests/Integration/RequestTests.swift b/Tests/JetStreamTests/Integration/RequestTests.swift new file mode 100644 index 0000000..4a4a01f --- /dev/null +++ b/Tests/JetStreamTests/Integration/RequestTests.swift @@ -0,0 +1,69 @@ +// +// File.swift +// +// +// Created by Piotr Piotrowski on 05/06/2024. +// + +import Logging +import NIO +import Nats +import NatsServer +import XCTest + +@testable import JetStream + +class RequestTests: XCTestCase { + + static var allTests = [ + ("testRequest", testRequest) + ] + + var natsServer = NatsServer() + + override func tearDown() { + super.tearDown() + natsServer.stop() + } + + func testRequest() async throws { + + let bundle = Bundle.module + natsServer.start( + cfg: bundle.url(forResource: "jetstream", withExtension: "conf")!.relativePath) + logger.logLevel = .critical + + let client = NatsClientOptions().url(URL(string: natsServer.clientURL)!).build() + try await client.connect() + + let ctx = JetStreamContext(client: client) + + let stream = """ + { + "name": "FOO", + "subjects": ["foo"] + } + """ + let data = stream.data(using: .utf8)! + + _ = try await client.request(data, subject: "$JS.API.STREAM.CREATE.FOO") + + let info: Response = try await ctx.request("INFO", message: Data()) + + guard case .success(let info) = info else { + XCTFail("request should be successful") + return + } + + XCTAssertEqual(info.streams, 1) + let badInfo: Response = try await ctx.request( + "STREAM.INFO.BAD", message: Data()) + guard case .error(let jetStreamAPIResponse) = badInfo else { + XCTFail("should get error") + return + } + + XCTAssertEqual(ErrorCode.streamNotFound, jetStreamAPIResponse.error.errorCode) + + } +} diff --git a/Tests/JetStreamTests/Integration/Resources/domain.conf b/Tests/JetStreamTests/Integration/Resources/domain.conf new file mode 100644 index 0000000..8ddf620 --- /dev/null +++ b/Tests/JetStreamTests/Integration/Resources/domain.conf @@ -0,0 +1,3 @@ +jetstream: { + domain: ABC +} diff --git a/Tests/JetStreamTests/Integration/Resources/jetstream.conf b/Tests/JetStreamTests/Integration/Resources/jetstream.conf new file mode 100644 index 0000000..d5843e7 --- /dev/null +++ b/Tests/JetStreamTests/Integration/Resources/jetstream.conf @@ -0,0 +1,25 @@ +jetstream: { + max_mem_store: 64MiB, + max_file_store: 10GiB +} + +no_auth_user: pp +accounts { + JS { + jetstream: enabled + users: [ + { + user: pp, + password: foo + } + ] + } + NO_JS { + users: [ + { + user: nojs, + password: foo + } + ] + } +} diff --git a/Tests/JetStreamTests/Integration/Resources/prefix.conf b/Tests/JetStreamTests/Integration/Resources/prefix.conf new file mode 100644 index 0000000..be8b62b --- /dev/null +++ b/Tests/JetStreamTests/Integration/Resources/prefix.conf @@ -0,0 +1,17 @@ +jetstream: enabled +accounts: { + A: { + users: [ {user: a, password: a} ] + jetstream: enabled + exports: [ + {service: '$JS.API.>' } + ] + }, + I: { + jetstream: disabled + users: [ {user: i, password: i} ] + imports: [ + {service: {account: A, subject: '$JS.API.>'}, to: 'fromA.>' } + ] + } +} diff --git a/Tests/JetStreamTests/Unit/MessageTests.swift b/Tests/JetStreamTests/Unit/MessageTests.swift new file mode 100644 index 0000000..e24d5ba --- /dev/null +++ b/Tests/JetStreamTests/Unit/MessageTests.swift @@ -0,0 +1,119 @@ +// Copyright 2024 The NATS 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 +// +// http://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. + +import XCTest + +@testable import JetStream +@testable import Nats + +class JetStreamMessageTests: XCTestCase { + + static var allTests = [ + ("testValidOldFormatMessage", testValidOldFormatMessage), + ("testValidNewFormatMessage", testValidNewFormatMessage), + ("testMissingTokens", testMissingTokens), + ("testInvalidTokenValues", testInvalidTokenValues), + ("testInvalidPrefix", testInvalidPrefix), + ("testNoReplySubject", testNoReplySubject), + ] + + func testValidOldFormatMessage() async throws { + let replySubject = "$JS.ACK.myStream.myConsumer.10.20.30.1234567890.5" + let natsMessage = NatsMessage( + payload: nil, subject: "", replySubject: replySubject, length: 0, headers: nil, + status: nil, description: nil) + let jetStreamMessage = JetStreamMessage(message: natsMessage, client: NatsClient()) + + let metadata = try jetStreamMessage.metadata() + + XCTAssertNil(metadata.domain) + XCTAssertNil(metadata.accountHash) + XCTAssertEqual(metadata.stream, "myStream") + XCTAssertEqual(metadata.consumer, "myConsumer") + XCTAssertEqual(metadata.delivered, 10) + XCTAssertEqual(metadata.streamSequence, 20) + XCTAssertEqual(metadata.consumerSequence, 30) + XCTAssertEqual(metadata.timestamp, "1234567890") + XCTAssertEqual(metadata.pending, 5) + } + + func testValidNewFormatMessage() async throws { + let replySubject = "$JS.ACK.domain.accountHash123.myStream.myConsumer.10.20.30.1234567890.5" + let natsMessage = NatsMessage( + payload: nil, subject: "", replySubject: replySubject, length: 0, headers: nil, + status: nil, description: nil) + let jetStreamMessage = JetStreamMessage(message: natsMessage, client: NatsClient()) + let metadata = try jetStreamMessage.metadata() + + XCTAssertEqual(metadata.domain, "domain") + XCTAssertEqual(metadata.accountHash, "accountHash123") + XCTAssertEqual(metadata.stream, "myStream") + XCTAssertEqual(metadata.consumer, "myConsumer") + XCTAssertEqual(metadata.delivered, 10) + XCTAssertEqual(metadata.streamSequence, 20) + XCTAssertEqual(metadata.consumerSequence, 30) + XCTAssertEqual(metadata.timestamp, "1234567890") + XCTAssertEqual(metadata.pending, 5) + } + + func testMissingTokens() async throws { + let replySubject = "$JS.ACK.myStream.myConsumer" + let natsMessage = NatsMessage( + payload: nil, subject: "", replySubject: replySubject, length: 0, headers: nil, + status: nil, description: nil) + let jetStreamMessage = JetStreamMessage(message: natsMessage, client: NatsClient()) + do { + _ = try jetStreamMessage.metadata() + } catch JetStreamError.MessageMetadataError.invalidTokenNum { + return + } + } + + func testInvalidTokenValues() async throws { + let replySubject = "$JS.ACK.myStream.myConsumer.invalid.20.30.1234567890.5" + let natsMessage = NatsMessage( + payload: nil, subject: "", replySubject: replySubject, length: 0, headers: nil, + status: nil, description: nil) + let jetStreamMessage = JetStreamMessage(message: natsMessage, client: NatsClient()) + do { + _ = try jetStreamMessage.metadata() + } catch JetStreamError.MessageMetadataError.invalidTokenValue { + return + } + } + + func testInvalidPrefix() async throws { + let replySubject = "$JS.WRONG.myStream.myConsumer.10.20.30.1234567890.5" + let natsMessage = NatsMessage( + payload: nil, subject: "", replySubject: replySubject, length: 0, headers: nil, + status: nil, description: nil) + let jetStreamMessage = JetStreamMessage(message: natsMessage, client: NatsClient()) + do { + _ = try jetStreamMessage.metadata() + } catch JetStreamError.MessageMetadataError.invalidPrefix { + return + } + } + + func testNoReplySubject() async throws { + let natsMessage = NatsMessage( + payload: nil, subject: "", replySubject: nil, length: 0, headers: nil, status: nil, + description: nil) + let jetStreamMessage = JetStreamMessage(message: natsMessage, client: NatsClient()) + do { + _ = try jetStreamMessage.metadata() + } catch JetStreamError.MessageMetadataError.noReplyInMessage { + return + } + } +} diff --git a/Tests/NatsTests/Integration/ConnectionTests.swift b/Tests/NatsTests/Integration/ConnectionTests.swift new file mode 100755 index 0000000..4e3aeac --- /dev/null +++ b/Tests/NatsTests/Integration/ConnectionTests.swift @@ -0,0 +1,1286 @@ +// Copyright 2024 The NATS 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 +// +// http://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. + +import Logging +import NIO +import Nats +import NatsServer +import XCTest + +class CoreNatsTests: XCTestCase { + + static var allTests = [ + ("testRtt", testRtt), + ("testPublish", testPublish), + ("testSuspendAndResume", testSuspendAndResume), + ("testForceReconnect", testForceReconnect), + ("testConnectMultipleURLsOneIsValid", testConnectMultipleURLsOneIsValid), + ("testConnectMultipleURLsRetainOrder", testConnectMultipleURLsRetainOrder), + ("testConnectDNSError", testConnectDNSError), + ("testRetryOnFailedConnect", testRetryOnFailedConnect), + ("testPublishWithReply", testPublishWithReply), + ("testPublishWithReplyOnCustomInbox", testPublishWithReplyOnCustomInbox), + ("testSubscribe", testSubscribe), + ("testUnsubscribe", testUnsubscribe), + ("testUnsubscribeAfter", testUnsubscribeAfter), + ("testConnect", testConnect), + ("testReconnect", testReconnect), + ("testUsernameAndPassword", testUsernameAndPassword), + ("testTokenAuth", testTokenAuth), + ("testCredentialsAuth", testCredentialsAuth), + ("testNkeyAuth", testNkeyAuth), + ("testNkeyAuthFile", testNkeyAuthFile), + ("testMutualTls", testMutualTls), + ("testTlsFirst", testTlsFirst), + ("testInvalidCertificate", testInvalidCertificate), + ("testWebsocket", testWebsocket), + ("testWebsocketTLS", testWebsocketTLS), + ("testLameDuckMode", testLameDuckMode), + ("testRequest", testRequest), + ("testRequestCustomInbox", testRequestCustomInbox), + ("testRequest_noResponders", testRequest_noResponders), + ("testRequest_permissionDenied", testRequest_permissionDenied), + ("testConcurrentChannelActiveAndRead", testConcurrentChannelActiveAndRead), + ("testRequest_timeout", testRequest_timeout), + ("testPublishOnClosedConnection", testPublishOnClosedConnection), + ("testCloseClosedConnection", testCloseClosedConnection), + ("testSuspendClosedConnection", testSuspendClosedConnection), + ("testReconnectOnClosedConnection", testReconnectOnClosedConnection), + ("testSubscribeMissingPermissions", testSubscribeMissingPermissions), + ("testSubscribePermissionsRevoked", testSubscribePermissionsRevoked), + + ] + var natsServer = NatsServer() + + override func tearDown() { + super.tearDown() + natsServer.stop() + } + + func testRtt() async throws { + natsServer.start() + logger.logLevel = .critical + let client = NatsClientOptions() + .url(URL(string: natsServer.clientURL)!) + .build() + try await client.connect() + + let rtt: TimeInterval = try await client.rtt() + XCTAssertGreaterThan(rtt, 0, "should have RTT") + + try await client.close() + } + + func testPublish() async throws { + natsServer.start() + logger.logLevel = .critical + let client = NatsClientOptions() + .url(URL(string: natsServer.clientURL)!) + .build() + try await client.connect() + let sub = try await client.subscribe(subject: "test") + + try await client.publish("msg".data(using: .utf8)!, subject: "test") + let expectation = XCTestExpectation(description: "Should receive message in 5 seconsd") + let iter = sub.makeAsyncIterator() + Task { + if let msg = try await iter.next() { + XCTAssertEqual(msg.subject, "test") + expectation.fulfill() + } + } + await fulfillment(of: [expectation], timeout: 5.0) + try await client.close() + } + + func testSuspendAndResume() async throws { + natsServer.start() + logger.logLevel = .critical + let client = NatsClientOptions() + .url(URL(string: natsServer.clientURL)!) + .build() + try await client.connect() + let sub = try await client.subscribe(subject: "test") + + try await client.publish("msg".data(using: .utf8)!, subject: "test") + let expectation = XCTestExpectation(description: "Should receive message in 5 seconsd") + let iter = sub.makeAsyncIterator() + Task { + if let msg = try await iter.next() { + XCTAssertEqual(msg.subject, "test") + expectation.fulfill() + } + } + await fulfillment(of: [expectation], timeout: 5.0) + let reconnectExpectation = XCTestExpectation(description: "Should reconnect in 5 seconds") + let suspendedExpectation = XCTestExpectation(description: "Should disconnect in 5 seconds") + client.on([.suspended, .connected]) { event in + if event.kind() == .suspended { + suspendedExpectation.fulfill() + } + if event.kind() == .connected { + reconnectExpectation.fulfill() + } + } + try await client.suspend() + await fulfillment(of: [suspendedExpectation], timeout: 5.0) + try await client.resume() + await fulfillment(of: [reconnectExpectation], timeout: 5.0) + try await client.publish("msg".data(using: .utf8)!, subject: "test") + let expectation1 = XCTestExpectation(description: "Should receive message in 5 seconsd") + Task { + if let msg = try await iter.next() { + XCTAssertEqual(msg.subject, "test") + expectation1.fulfill() + } + } + await fulfillment(of: [expectation1], timeout: 5.0) + try await client.close() + } + + func testForceReconnect() async throws { + natsServer.start() + logger.logLevel = .critical + let client = NatsClientOptions() + .url(URL(string: natsServer.clientURL)!) + .build() + try await client.connect() + let sub = try await client.subscribe(subject: "test") + + try await client.publish("msg".data(using: .utf8)!, subject: "test") + let expectation = XCTestExpectation(description: "Should receive message in 5 seconsd") + let iter = sub.makeAsyncIterator() + Task { + if let msg = try await iter.next() { + XCTAssertEqual(msg.subject, "test") + expectation.fulfill() + } + } + await fulfillment(of: [expectation], timeout: 5.0) + let reconnectExpectation = XCTestExpectation(description: "Should reconnect in 5 seconds") + let suspendedExpectation = XCTestExpectation(description: "Should disconnect in 5 seconds") + client.on([.suspended, .connected]) { event in + if event.kind() == .suspended { + suspendedExpectation.fulfill() + } + if event.kind() == .connected { + reconnectExpectation.fulfill() + } + } + try await client.reconnect() + await fulfillment(of: [suspendedExpectation], timeout: 5.0) + await fulfillment(of: [reconnectExpectation], timeout: 5.0) + try await client.publish("msg".data(using: .utf8)!, subject: "test") + let expectation1 = XCTestExpectation(description: "Should receive message in 5 seconsd") + Task { + if let msg = try await iter.next() { + XCTAssertEqual(msg.subject, "test") + expectation1.fulfill() + } + } + await fulfillment(of: [expectation1], timeout: 5.0) + try await client.close() + } + + func testConnectMultipleURLsOneIsValid() async throws { + natsServer.start() + logger.logLevel = .critical + let client = NatsClientOptions() + .urls([ + URL(string: natsServer.clientURL)!, URL(string: "nats://localhost:4344")!, + URL(string: "nats://localhost:4343")!, + ]) + .build() + try await client.connect() + let sub = try await client.subscribe(subject: "test") + + try await client.publish("msg".data(using: .utf8)!, subject: "test") + let expectation = XCTestExpectation(description: "Should receive message in 5 seconsd") + let iter = sub.makeAsyncIterator() + Task { + if let msg = try await iter.next() { + XCTAssertEqual(msg.subject, "test") + expectation.fulfill() + } + } + await fulfillment(of: [expectation], timeout: 5.0) + try await client.close() + } + + func testConnectMultipleURLsRetainOrder() async throws { + natsServer.start() + let natsServer2 = NatsServer() + natsServer2.start() + logger.logLevel = .critical + for _ in 0..<10 { + let client = NatsClientOptions() + .urls([URL(string: natsServer2.clientURL)!, URL(string: natsServer.clientURL)!]) + .retainServersOrder() + .build() + try await client.connect() + XCTAssertEqual(client.connectedUrl, URL(string: natsServer2.clientURL)) + try await client.close() + } + } + + func testConnectDNSError() async throws { + logger.logLevel = .critical + let client = NatsClientOptions() + .urls([URL(string: "nats://invalid:1234")!]) + .build() + do { + try await client.connect() + } catch NatsError.ConnectError.dns(_) { + return + } catch { + XCTFail("Expeted dns lookup error; got: \(error)") + } + XCTFail("Expeted dns lookup error") + } + + func testConnectNIOError() async throws { + logger.logLevel = .critical + let client = NatsClientOptions() + .urls([URL(string: "nats://localhost:4321")!]) + .build() + do { + // should be connection refused error + try await client.connect() + } catch NatsError.ConnectError.io(_) { + return + } catch { + XCTFail("Expeted IO lookup error; got: \(error)") + } + XCTFail("Expeted io lookup error") + } + + func testRetryOnFailedConnect() async throws { + let client = NatsClientOptions() + .url(URL(string: "nats://localhost:4321")!) + .reconnectWait(1) + .retryOnfailedConnect() + .build() + + let expectation = XCTestExpectation( + description: "client was not notified of connection established event") + client.on(.connected) { event in + expectation.fulfill() + } + + try await client.connect() + natsServer.start(port: 4321) + + await fulfillment(of: [expectation], timeout: 5.0) + + } + + func testPublishWithReply() async throws { + natsServer.start() + logger.logLevel = .critical + let client = NatsClientOptions() + .url(URL(string: natsServer.clientURL)!) + .build() + try await client.connect() + let sub = try await client.subscribe(subject: "test") + + try await client.publish("msg".data(using: .utf8)!, subject: "test", reply: "reply") + let expectation = XCTestExpectation(description: "Should receive message in 5 seconsd") + let iter = sub.makeAsyncIterator() + Task { + if let msg = try await iter.next() { + XCTAssertEqual(msg.subject, "test") + XCTAssertEqual(msg.replySubject, "reply") + expectation.fulfill() + } + } + await fulfillment(of: [expectation], timeout: 5.0) + } + + func testPublishWithReplyOnCustomInbox() async throws { + natsServer.start() + logger.logLevel = .debug + let client = NatsClientOptions() + .url(URL(string: natsServer.clientURL)!) + .inboxPrefix("_INBOX_foo") + .build() + try await client.connect() + let sub = try await client.subscribe(subject: "test") + + try await client.publish( + "msg".data(using: .utf8)!, subject: "test", reply: client.newInbox()) + let expectation = XCTestExpectation(description: "Should receive message in 5 seconds") + let iter = sub.makeAsyncIterator() + Task { + if let msg = try await iter.next() { + XCTAssertEqual(msg.subject, "test") + XCTAssertTrue(msg.replySubject?.starts(with: "_INBOX_foo.") == true) + expectation.fulfill() + } + } + await fulfillment(of: [expectation], timeout: 5.0) + } + + func testSubscribe() async throws { + natsServer.start() + logger.logLevel = .critical + let client = NatsClientOptions().url(URL(string: natsServer.clientURL)!).build() + try await client.connect() + let sub = try await client.subscribe(subject: "test") + try await client.publish("msg".data(using: .utf8)!, subject: "test") + let iter = sub.makeAsyncIterator() + let message = try await iter.next() + XCTAssertEqual(message?.payload, "msg".data(using: .utf8)!) + } + + func testQueueGroupSubscribe() async throws { + natsServer.start() + logger.logLevel = .critical + let client = NatsClientOptions().url(URL(string: natsServer.clientURL)!).build() + try await client.connect() + + let sub1 = try await client.subscribe(subject: "test", queue: "queueGroup") + let sub2 = try await client.subscribe(subject: "test", queue: "queueGroup") + + try await client.publish("msg".data(using: .utf8)!, subject: "test") + + try await withThrowingTaskGroup(of: NatsMessage?.self) { group in + group.addTask { try await sub1.makeAsyncIterator().next() } + group.addTask { try await sub2.makeAsyncIterator().next() } + group.addTask { + try await Task.sleep(nanoseconds: UInt64(1_000_000_000)) + return nil + } + + var msgReceived = false + var timeoutReceived = false + for try await result in group { + if let _ = result { + if msgReceived == true { + XCTFail("received 2 messages") + return + } + msgReceived = true + } else { + if !msgReceived { + XCTFail("timeout received before getting any messages") + return + } + timeoutReceived = true + } + if msgReceived && timeoutReceived { + break + } + } + group.cancelAll() + try await sub1.unsubscribe() + try await sub2.unsubscribe() + return + } + } + + func testUnsubscribe() async throws { + natsServer.start() + logger.logLevel = .critical + let client = NatsClientOptions().url(URL(string: natsServer.clientURL)!).build() + try await client.connect() + let sub = try await client.subscribe(subject: "test") + try await client.publish("msg".data(using: .utf8)!, subject: "test") + let iter = sub.makeAsyncIterator() + var message = try await iter.next() + XCTAssertEqual(message?.payload, "msg".data(using: .utf8)!) + + try await client.publish("msg".data(using: .utf8)!, subject: "test") + try await sub.unsubscribe() + + message = try await iter.next() + XCTAssertNil(message) + + do { + try await sub.unsubscribe() + } catch NatsError.SubscriptionError.subscriptionClosed { + return + } + XCTFail("Expected subscription closed error") + } + + func testUnsubscribeAfter() async throws { + natsServer.start() + logger.logLevel = .critical + let client = NatsClientOptions().url(URL(string: natsServer.clientURL)!).build() + try await client.connect() + let sub = try await client.subscribe(subject: "test") + try await sub.unsubscribe(after: 3) + for _ in 0..<5 { + try await client.publish("msg".data(using: .utf8)!, subject: "test") + } + + var i = 0 + for try await _ in sub { + i += 1 + } + XCTAssertEqual(i, 3, "Expected 3 messages to be delivered") + try await client.close() + } + + func testConnect() async throws { + natsServer.start() + logger.logLevel = .critical + let client = NatsClientOptions() + .url(URL(string: natsServer.clientURL)!) + .build() + try await client.connect() + XCTAssertNotNil(client, "Client should not be nil") + } + + func testReconnect() async throws { + natsServer.start() + let port = natsServer.port! + logger.logLevel = .critical + + let client = NatsClientOptions() + .url(URL(string: natsServer.clientURL)!) + .reconnectWait(1) + .build() + + try await client.connect() + + // Payload to publish + let payload = "hello".data(using: .utf8)! + + var messagesReceived = 0 + let sub = try! await client.subscribe(subject: "foo") + + // publish some messages + Task { + for _ in 0..<10 { + try await client.publish(payload, subject: "foo") + } + } + + // make sure sub receives messages + for try await _ in sub { + messagesReceived += 1 + if messagesReceived == 10 { + break + } + } + let expectation = XCTestExpectation( + description: "client was not notified of connection established event") + client.on(.connected) { event in + expectation.fulfill() + } + + // restart the server + natsServer.stop() + sleep(1) + natsServer.start(port: port) + await fulfillment(of: [expectation], timeout: 10.0) + + // publish more messages, sub should receive them + Task { + for _ in 0..<10 { + try await client.publish(payload, subject: "foo") + } + } + + for try await _ in sub { + messagesReceived += 1 + if messagesReceived == 20 { + break + } + } + + // Check if the total number of messages received matches the number sent + XCTAssertEqual(20, messagesReceived, "Mismatch in the number of messages sent and received") + try await client.close() + } + + func testUsernameAndPassword() async throws { + logger.logLevel = .critical + let bundle = Bundle.module + natsServer.start(cfg: bundle.url(forResource: "creds", withExtension: "conf")!.relativePath) + + let client = NatsClientOptions() + .url(URL(string: natsServer.clientURL)!) + .usernameAndPassword("derek", "s3cr3t") + .maxReconnects(5) + .build() + try await client.connect() + try await client.publish("msg".data(using: .utf8)!, subject: "test") + try await client.flush() + _ = try await client.subscribe(subject: "test") + XCTAssertNotNil(client, "Client should not be nil") + + // Test if client with bad credentials throws an error + let badCertsClient = NatsClientOptions() + .url(URL(string: natsServer.clientURL)!) + .usernameAndPassword("derek", "badpassword") + .maxReconnects(5) + .build() + + do { + try await badCertsClient.connect() + XCTFail("Should have thrown an error") + } catch NatsError.ServerError.authorizationViolation { + // success + return + } catch { + XCTFail("Expected auth error; got: \(error)") + } + XCTFail("Expected error from connect") + } + + func testTokenAuth() async throws { + logger.logLevel = .critical + let bundle = Bundle.module + natsServer.start(cfg: bundle.url(forResource: "token", withExtension: "conf")!.relativePath) + + let client = NatsClientOptions() + .url(URL(string: natsServer.clientURL)!) + .token("s3cr3t") + .maxReconnects(5) + .build() + try await client.connect() + try await client.publish("msg".data(using: .utf8)!, subject: "test") + try await client.flush() + _ = try await client.subscribe(subject: "test") + XCTAssertNotNil(client, "Client should not be nil") + + // Test if client with bad credentials throws an error + let badCertsClient = NatsClientOptions() + .url(URL(string: natsServer.clientURL)!) + .token("badtoken") + .maxReconnects(5) + .build() + + do { + try await badCertsClient.connect() + XCTFail("Should have thrown an error") + } catch NatsError.ServerError.authorizationViolation { + return + } catch { + XCTFail("Expected auth error; got: \(error)") + } + XCTFail("Expected error from connect") + } + + func testCredentialsAuth() async throws { + logger.logLevel = .critical + let bundle = Bundle.module + natsServer.start(cfg: bundle.url(forResource: "jwt", withExtension: "conf")!.relativePath) + + let creds = bundle.url(forResource: "TestUser", withExtension: "creds")! + + let client = NatsClientOptions().url(URL(string: natsServer.clientURL)!).credentialsFile( + creds + ).build() + try await client.connect() + let subscribe = try await client.subscribe(subject: "foo").makeAsyncIterator() + try await client.publish("data".data(using: .utf8)!, subject: "foo") + _ = try await subscribe.next() + } + + func testNkeyAuth() async throws { + logger.logLevel = .critical + let bundle = Bundle.module + natsServer.start(cfg: bundle.url(forResource: "nkey", withExtension: "conf")!.relativePath) + + let client = NatsClientOptions() + .url(URL(string: natsServer.clientURL)!) + .nkey("SUACH75SWCM5D2JMJM6EKLR2WDARVGZT4QC6LX3AGHSWOMVAKERABBBRWM") + .build() + try await client.connect() + let subscribe = try await client.subscribe(subject: "foo").makeAsyncIterator() + try await client.publish("data".data(using: .utf8)!, subject: "foo") + _ = try await subscribe.next() + } + + func testNkeyAuthFile() async throws { + logger.logLevel = .critical + let bundle = Bundle.module + natsServer.start(cfg: bundle.url(forResource: "nkey", withExtension: "conf")!.relativePath) + + let client = NatsClientOptions() + .url(URL(string: natsServer.clientURL)!) + .nkeyFile(bundle.url(forResource: "nkey", withExtension: "")!) + .build() + try await client.connect() + let subscribe = try await client.subscribe(subject: "foo").makeAsyncIterator() + try await client.publish("data".data(using: .utf8)!, subject: "foo") + _ = try await subscribe.next() + + // Test if passing both nkey and nkeyPath throws an error + let badClient = NatsClientOptions() + .url(URL(string: natsServer.clientURL)!) + .nkeyFile(bundle.url(forResource: "nkey", withExtension: "")!) + .nkey("SUACH75SWCM5D2JMJM6EKLR2WDARVGZT4QC6LX3AGHSWOMVAKERABBBRWM") + .build() + + do { + try await badClient.connect() + XCTFail("Should have thrown an error") + } catch let error as NatsError.ConnectError { + if case .invalidConfig(_) = error { + XCTAssertEqual( + error.description, + "nats: invalid client configuration: cannot use both nkey and nkeyPath") + return + } + XCTFail("Expected auth error; got: \(error)") + } catch { + XCTFail("Expected auth error; got: \(error)") + } + XCTFail("Expected error from connect") + } + + func testMutualTls() async throws { + let bundle = Bundle.module + logger.logLevel = .critical + let serverCert = bundle.url(forResource: "server-cert", withExtension: "pem")!.relativePath + let serverKey = bundle.url(forResource: "server-key", withExtension: "pem")!.relativePath + let rootCA = bundle.url(forResource: "rootCA", withExtension: "pem")!.relativePath + let cfgFile = try createConfigFileFromTemplate( + templateURL: bundle.url(forResource: "tls", withExtension: "conf")!, + args: [serverCert, serverKey, rootCA]) + natsServer.start(cfg: cfgFile.relativePath) + + let certsURL = bundle.url(forResource: "rootCA", withExtension: "pem")! + let clientCert = bundle.url(forResource: "client-cert", withExtension: "pem")! + let clientKey = bundle.url(forResource: "client-key", withExtension: "pem")! + + let client = NatsClientOptions() + .url(URL(string: natsServer.clientURL)!) + .requireTls() + .rootCertificates(certsURL) + .clientCertificate( + clientCert, + clientKey + ) + .build() + try await client.connect() + try await client.publish("msg".data(using: .utf8)!, subject: "test") + try await client.flush() + _ = try await client.subscribe(subject: "test") + XCTAssertNotNil(client, "Client should not be nil") + } + + func testTlsFirst() async throws { + let bundle = Bundle.module + logger.logLevel = .critical + let serverCert = bundle.url(forResource: "server-cert", withExtension: "pem")!.relativePath + let serverKey = bundle.url(forResource: "server-key", withExtension: "pem")!.relativePath + let rootCA = bundle.url(forResource: "rootCA", withExtension: "pem")!.relativePath + let cfgFile = try createConfigFileFromTemplate( + templateURL: bundle.url(forResource: "tls_first", withExtension: "conf")!, + args: [serverCert, serverKey, rootCA]) + natsServer.start(cfg: cfgFile.relativePath) + + let certsURL = bundle.url(forResource: "rootCA", withExtension: "pem")! + let clientCert = bundle.url(forResource: "client-cert", withExtension: "pem")! + let clientKey = bundle.url(forResource: "client-key", withExtension: "pem")! + + let client = NatsClientOptions() + .url(URL(string: natsServer.clientURL)!) + .requireTls() + .rootCertificates(certsURL) + .clientCertificate( + clientCert, + clientKey + ) + .withTlsFirst() + .build() + try await client.connect() + try await client.publish("msg".data(using: .utf8)!, subject: "test") + try await client.flush() + _ = try await client.subscribe(subject: "test") + XCTAssertNotNil(client, "Client should not be nil") + } + + func testInvalidCertificate() async throws { + let bundle = Bundle.module + logger.logLevel = .critical + let serverCert = bundle.url(forResource: "server-cert", withExtension: "pem")!.relativePath + let serverKey = bundle.url(forResource: "server-key", withExtension: "pem")!.relativePath + let rootCA = bundle.url(forResource: "rootCA", withExtension: "pem")!.relativePath + let cfgFile = try createConfigFileFromTemplate( + templateURL: bundle.url(forResource: "tls", withExtension: "conf")!, + args: [serverCert, serverKey, rootCA]) + natsServer.start(cfg: cfgFile.relativePath) + + let certsURL = bundle.url(forResource: "rootCA", withExtension: "pem")! + let invalidCert = bundle.url(forResource: "client-cert-invalid", withExtension: "pem")! + let invalidKey = bundle.url(forResource: "client-key-invalid", withExtension: "pem")! + + let client = NatsClientOptions() + .url(URL(string: natsServer.clientURL)!) + .requireTls() + .rootCertificates(certsURL) + .clientCertificate( + invalidCert, + invalidKey + ) + .build() + do { + try await client.connect() + } catch NatsError.ConnectError.tlsFailure(_) { + return + } catch { + XCTFail("Expected tls error; got: \(error)") + } + XCTFail("Expected error from connect") + } + + func testWebsocket() async throws { + logger.logLevel = .critical + let bundle = Bundle.module + natsServer.start(cfg: bundle.url(forResource: "ws", withExtension: "conf")!.relativePath) + + let client = NatsClientOptions().url(URL(string: natsServer.clientWebsocketURL)!).build() + + try await client.connect() + let sub = try await client.subscribe(subject: "test") + try await client.publish("msg".data(using: .utf8)!, subject: "test") + let iter = sub.makeAsyncIterator() + let message = try await iter.next() + XCTAssertEqual(message?.payload, "msg".data(using: .utf8)!) + + try await client.close() + } + + func testWebsocketTLS() async throws { + logger.logLevel = .critical + let bundle = Bundle.module + let serverCert = bundle.url(forResource: "server-cert", withExtension: "pem")!.relativePath + let serverKey = bundle.url(forResource: "server-key", withExtension: "pem")!.relativePath + let rootCA = bundle.url(forResource: "rootCA", withExtension: "pem")!.relativePath + let cfgFile = try createConfigFileFromTemplate( + templateURL: bundle.url(forResource: "wss", withExtension: "conf")!, + args: [serverCert, serverKey, rootCA]) + natsServer.start(cfg: cfgFile.relativePath) + + let certsURL = bundle.url(forResource: "rootCA", withExtension: "pem")! + let clientCert = bundle.url(forResource: "client-cert", withExtension: "pem")! + let clientKey = bundle.url(forResource: "client-key", withExtension: "pem")! + + let client = NatsClientOptions() + .url(URL(string: natsServer.clientWebsocketURL)!) + .rootCertificates(certsURL) + .clientCertificate( + clientCert, + clientKey + ) + .build() + + try await client.connect() + let sub = try await client.subscribe(subject: "test") + try await client.publish("msg".data(using: .utf8)!, subject: "test") + let iter = sub.makeAsyncIterator() + let message = try await iter.next() + XCTAssertEqual(message?.payload, "msg".data(using: .utf8)!) + + try await client.close() + } + + func testLameDuckMode() async throws { + natsServer.start() + logger.logLevel = .critical + + let client = NatsClientOptions().url(URL(string: natsServer.clientURL)!).build() + + let expectation = XCTestExpectation( + description: "client was not notified of connection established event") + client.on(.lameDuckMode) { event in + XCTAssertEqual(event.kind(), NatsEventKind.lameDuckMode) + expectation.fulfill() + } + try await client.connect() + + natsServer.sendSignal(.lameDuckMode) + await fulfillment(of: [expectation], timeout: 1.0) + try await client.close() + } + + func testRequest() async throws { + natsServer.start() + logger.logLevel = .critical + + let client = NatsClientOptions().url(URL(string: natsServer.clientURL)!).build() + try await client.connect() + + let service = try await client.subscribe(subject: "service") + Task { + for try await msg in service { + try await client.publish( + "reply".data(using: .utf8)!, subject: msg.replySubject!, reply: "reply") + } + } + let response = try await client.request("request".data(using: .utf8)!, subject: "service") + XCTAssertEqual(response.payload, "reply".data(using: .utf8)!) + + try await client.close() + } + + func testRequestCustomInbox() async throws { + natsServer.start() + logger.logLevel = .debug + + let client = NatsClientOptions() + .url(URL(string: natsServer.clientURL)!) + .inboxPrefix("_INBOX_bar.foo") + .build() + try await client.connect() + + let service = try await client.subscribe(subject: "service") + Task { + for try await msg in service { + try await client.publish( + "reply".data(using: .utf8)!, subject: msg.replySubject!, reply: "reply") + } + } + let response = try await client.request("request".data(using: .utf8)!, subject: "service") + XCTAssertEqual(response.payload, "reply".data(using: .utf8)!) + + try await client.close() + } + + func testRequest_noResponders() async throws { + natsServer.start() + logger.logLevel = .critical + + let client = NatsClientOptions().url(URL(string: natsServer.clientURL)!).build() + try await client.connect() + + do { + _ = try await client.request("request".data(using: .utf8)!, subject: "service") + } catch NatsError.RequestError.noResponders { + try await client.close() + return + } + + XCTFail("Expected no responders") + } + + func testRequest_timeout() async throws { + natsServer.start() + logger.logLevel = .critical + + let client = NatsClientOptions().url(URL(string: natsServer.clientURL)!).build() + try await client.connect() + + let service = try await client.subscribe(subject: "service") + Task { + for try await msg in service { + sleep(2) + try await client.publish( + "reply".data(using: .utf8)!, subject: msg.replySubject!, reply: "reply") + } + } + do { + _ = try await client.request( + "request".data(using: .utf8)!, subject: "service", timeout: 1) + } catch NatsError.RequestError.timeout { + try await service.unsubscribe() + try await client.close() + return + } + + XCTFail("Expected timeout") + } + + func testRequest_permissionDenied() async throws { + logger.logLevel = .critical + let bundle = Bundle.module + let templateURL = bundle.url(forResource: "permissions", withExtension: "conf")! + let cfgFile = try createConfigFileFromTemplate( + templateURL: templateURL, + args: ["deny", "_INBOX.*"]) + natsServer.start(cfg: cfgFile.relativePath) + + let client = NatsClientOptions().url(URL(string: natsServer.clientURL)!).build() + try await client.connect() + + do { + _ = try await client.request("request".data(using: .utf8)!, subject: "service") + } catch NatsError.RequestError.permissionDenied { + try await client.close() + return + } + + XCTFail("Expected permission denied") + } + + func testPublishOnClosedConnection() async throws { + natsServer.start() + logger.logLevel = .critical + let client = NatsClientOptions() + .url(URL(string: natsServer.clientURL)!) + .build() + try await client.connect() + + let rtt: TimeInterval = try await client.rtt() + XCTAssertGreaterThan(rtt, 0, "should have RTT") + + try await client.close() + do { + try await client.publish("msg".data(using: .utf8)!, subject: "test") + } catch NatsError.ClientError.connectionClosed { + return + } catch { + XCTFail("Expected connection closed error; got: \(error)") + } + XCTFail("Expected connection closed error") + } + + func testCloseClosedConnection() async throws { + natsServer.start() + logger.logLevel = .critical + let client = NatsClientOptions() + .url(URL(string: natsServer.clientURL)!) + .build() + try await client.connect() + + let rtt: TimeInterval = try await client.rtt() + XCTAssertGreaterThan(rtt, 0, "should have RTT") + + try await client.close() + do { + try await client.close() + } catch NatsError.ClientError.connectionClosed { + return + } catch { + XCTFail("Expected connection closed error; got: \(error)") + } + XCTFail("Expected connection closed error") + } + + func testSuspendClosedConnection() async throws { + natsServer.start() + logger.logLevel = .critical + let client = NatsClientOptions() + .url(URL(string: natsServer.clientURL)!) + .build() + try await client.connect() + + let rtt: TimeInterval = try await client.rtt() + XCTAssertGreaterThan(rtt, 0, "should have RTT") + + try await client.close() + do { + try await client.suspend() + } catch NatsError.ClientError.connectionClosed { + return + } catch { + XCTFail("Expected connection closed error; got: \(error)") + } + XCTFail("Expected connection closed error") + } + + func testReconnectOnClosedConnection() async throws { + natsServer.start() + logger.logLevel = .critical + let client = NatsClientOptions() + .url(URL(string: natsServer.clientURL)!) + .build() + try await client.connect() + + let rtt: TimeInterval = try await client.rtt() + XCTAssertGreaterThan(rtt, 0, "should have RTT") + + try await client.close() + do { + try await client.reconnect() + } catch NatsError.ClientError.connectionClosed { + return + } catch { + XCTFail("Expected connection closed error; got: \(error)") + } + XCTFail("Expected connection closed error") + } + + func testSubscribeMissingPermissions() async throws { + logger.logLevel = .critical + let bundle = Bundle.module + let cfgFile = try createConfigFileFromTemplate( + templateURL: bundle.url(forResource: "permissions", withExtension: "conf")!, + args: ["deny", "events.>"]) + natsServer.start(cfg: cfgFile.relativePath) + + let client = NatsClientOptions() + .url(URL(string: natsServer.clientURL)!) + .build() + + try await client.connect() + var sub = try await client.subscribe(subject: "events.A") + var isError = false + do { + for try await _ in sub { + XCTFail("Expected no message)") + } + } catch NatsError.SubscriptionError.permissionDenied { + // success + isError = true + } + if !isError { + XCTFail("Expected missing permissions error") + } + + sub = try await client.subscribe(subject: "events.*") + isError = false + do { + for try await _ in sub { + XCTFail("Expected no message)") + } + } catch NatsError.SubscriptionError.permissionDenied { + // success + isError = true + } + if !isError { + XCTFail("Expected missing permissions error") + } + } + + func testSubscribePermissionsRevoked() async throws { + logger.logLevel = .critical + let bundle = Bundle.module + let templateURL = bundle.url(forResource: "permissions", withExtension: "conf")! + var cfgFile = try createConfigFileFromTemplate( + templateURL: templateURL, + args: ["allow", "events.>"]) + natsServer.start(cfg: cfgFile.relativePath) + + let client = NatsClientOptions() + .url(URL(string: natsServer.clientURL)!) + .build() + + try await client.connect() + let sub = try await client.subscribe(subject: "events.A") + + let iter = sub.makeAsyncIterator() + try await client.publish("msg".data(using: .utf8)!, subject: "events.A") + + _ = try await iter.next() + + cfgFile = try createConfigFileFromTemplate( + templateURL: templateURL, args: ["deny", "events.>"], destination: cfgFile) + + // reload config with + natsServer.sendSignal(.reload) + + do { + _ = try await iter.next() + } catch NatsError.SubscriptionError.permissionDenied { + // success + return + } + XCTFail("Expected permission denied error") + } + + /// Test race condition in concurrent subscriptions + /// This test ensures that creating multiple subscriptions concurrently doesn't cause + /// segmentation faults due to race conditions in the subscription map. + func testConcurrentSubscriptionCreation() async throws { + natsServer.start() + + let client = NatsClientOptions() + .url(URL(string: natsServer.clientURL)!) + .build() + + try await client.connect() + + // Create 10 subscriptions concurrently + let tasks = (0..<10).map { i in + Task { + try await client.subscribe(subject: "concurrent.test.\(i)") + } + } + + // Wait for all subscriptions to complete + for task in tasks { + _ = try await task.value + } + + try await client.close() + } + + /// Test that multiple connect() calls on the same client throw an error + func testMultipleConnectCallsThrowError() async throws { + natsServer.start() + + let client = NatsClientOptions() + .url(URL(string: natsServer.clientURL)!) + .build() + + // First connect should succeed + try await client.connect() + + // Second connect should throw alreadyConnected error + do { + try await client.connect() + XCTFail("Second connect() should have thrown an error") + } catch NatsError.ClientError.alreadyConnected { + // Expected behavior + } catch { + XCTFail("Expected alreadyConnected error, got: \(error)") + } + + try await client.close() + } + + /// Test ByteBuffer reinitialization + func testByteBufferReinitialization() async throws { + natsServer.start() + + let client = NatsClientOptions() + .url(URL(string: natsServer.clientURL)!) + .reconnectWait(0.01) // Very short reconnect wait + .maxReconnects(100) + .build() + + try await client.connect() + + let sub = try await client.subscribe(subject: "test.buffer.race") + + // Create concurrent tasks that will stress the buffer + let publishTask = Task { + for i in 0..<1000 { + // Send messages with varying sizes to stress buffer + let payload = String(repeating: "X", count: i % 1000 + 1) + try? await client.publish(payload.data(using: .utf8)!, subject: "test.buffer.race") + if i % 10 == 0 { + // Add small delays occasionally to change timing + try? await Task.sleep(nanoseconds: 1000) + } + } + } + + let reconnectTask = Task { + for _ in 0..<20 { + // Force reconnects while messages are being processed + try? await Task.sleep(nanoseconds: 50_000_000) // 50ms + try? await client.reconnect() + } + } + + // Try to consume messages during reconnect + let consumeTask = Task { + var count = 0 + for try await _ in sub { + count += 1 + if count > 100 { + break + } + } + } + + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { await publishTask.value } + group.addTask { await reconnectTask.value } + group.addTask { try await consumeTask.value } + + _ = try await group.waitForAll() + group.cancelAll() + } + + try await client.close() + } + + /// Test concurrent channelActive and channelReadComplete + func testConcurrentChannelActiveAndRead() async throws { + natsServer.start() + + let client = NatsClientOptions() + .url(URL(string: natsServer.clientURL)!) + .reconnectWait(0.01) + .maxReconnects(50) + .build() + + try await client.connect() + + let sub = try await client.subscribe(subject: "test.concurrent.>") + + // Task 1: Rapid publishing + let publishTask = Task { + for i in 0..<500 { + let subjects = ["test.concurrent.a", "test.concurrent.b", "test.concurrent.c"] + let subject = subjects[i % subjects.count] + let payload = String(repeating: "D", count: (i * 7) % 2048 + 100) + try? await client.publish(payload.data(using: .utf8)!, subject: subject) + } + } + + // Task 2: Force disconnections/reconnections + let reconnectTask = Task { + for i in 0..<10 { + try await Task.sleep(nanoseconds: 100_000_000) // 100ms + try await client.reconnect() + + for j in 0..<10 { + try? await client.publish( + "RECONNECT-\(i)-\(j)".data(using: .utf8)!, + subject: "test.concurrent.reconnect") + } + } + } + + // Task 3: Consume messages + let consumeTask = Task { + var count = 0 + for try await _ in sub { + count += 1 + if count > 200 { + break + } + // Add occasional small delays to vary timing + if count % 50 == 0 { + try await Task.sleep(nanoseconds: 1_000_000) + } + } + } + + // Wait for all tasks + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { await publishTask.value } + group.addTask { try await reconnectTask.value } + group.addTask { try await consumeTask.value } + + _ = try await group.waitForAll() + group.cancelAll() + } + + try await client.close() + } + + func createConfigFileFromTemplate( + templateURL: URL, args: [String], destination: URL? = nil + ) throws -> URL { + let templateContent = try String(contentsOf: templateURL, encoding: .utf8) + let config = String(format: templateContent, arguments: args.map { $0 as CVarArg }) + + let tempDirectoryURL = FileManager.default.temporaryDirectory + + let tempFileURL: URL + + if let destination { + tempFileURL = destination + } else { + tempFileURL = tempDirectoryURL.appendingPathComponent(UUID().uuidString) + .appendingPathExtension("conf") + } + + // Write the filled content to the temp file + try config.write(to: tempFileURL, atomically: true, encoding: .utf8) + + // Return the URL of the newly created temp file + return tempFileURL + } +} diff --git a/Tests/NatsTests/Integration/EventsTests.swift b/Tests/NatsTests/Integration/EventsTests.swift new file mode 100644 index 0000000..f417fc2 --- /dev/null +++ b/Tests/NatsTests/Integration/EventsTests.swift @@ -0,0 +1,103 @@ +// Copyright 2024 The NATS 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 +// +// http://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. + +import Foundation +import Logging +import Nats +import NatsServer +import XCTest + +class TestNatsEvents: XCTestCase { + + static var allTests = [ + ("testClientConnectedEvent", testClientConnectedEvent), + ("testClientClosedEvent", testClientClosedEvent), + ("testClientReconnectEvent", testClientReconnectEvent), + ] + + var natsServer = NatsServer() + + override func tearDown() { + super.tearDown() + natsServer.stop() + } + + func testClientConnectedEvent() async throws { + natsServer.start() + logger.logLevel = .critical + + let client = NatsClientOptions().url(URL(string: natsServer.clientURL)!).build() + + let expectation = XCTestExpectation( + description: "client was not notified of connection established event") + client.on(.connected) { event in + XCTAssertEqual(event.kind(), NatsEventKind.connected) + expectation.fulfill() + } + try await client.connect() + + await fulfillment(of: [expectation], timeout: 1.0) + try await client.close() + } + + func testClientClosedEvent() async throws { + natsServer.start() + logger.logLevel = .critical + + let client = NatsClientOptions().url(URL(string: natsServer.clientURL)!).build() + + let expectation = XCTestExpectation( + description: "client was not notified of connection closed event") + client.on(.closed) { event in + XCTAssertEqual(event.kind(), NatsEventKind.closed) + expectation.fulfill() + } + try await client.connect() + + try await client.close() + await fulfillment(of: [expectation], timeout: 1.0) + } + + func testClientReconnectEvent() async throws { + natsServer.start() + let port = natsServer.port! + logger.logLevel = .critical + + let client = NatsClientOptions() + .url(URL(string: natsServer.clientURL)!) + .reconnectWait(1) + .build() + + let disconnected = XCTestExpectation( + description: "client was not notified of disconnection event") + client.on(.disconnected) { event in + XCTAssertEqual(event.kind(), NatsEventKind.disconnected) + disconnected.fulfill() + } + try await client.connect() + natsServer.stop() + + let reconnected = XCTestExpectation( + description: "client was not notified of reconnection event") + client.on(.connected) { event in + XCTAssertEqual(event.kind(), NatsEventKind.connected) + reconnected.fulfill() + } + await fulfillment(of: [disconnected], timeout: 5.0) + + natsServer.start(port: port) + await fulfillment(of: [reconnected], timeout: 5.0) + + try await client.close() + } +} diff --git a/Tests/NatsTests/Integration/MessageWithHeadersTests.swift b/Tests/NatsTests/Integration/MessageWithHeadersTests.swift new file mode 100644 index 0000000..d5cceda --- /dev/null +++ b/Tests/NatsTests/Integration/MessageWithHeadersTests.swift @@ -0,0 +1,55 @@ +// Copyright 2024 The NATS 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 +// +// http://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. + +import Foundation +import Logging +import Nats +import NatsServer +import XCTest + +class TestMessageWithHeadersTests: XCTestCase { + + static var allTests = [ + ("testMessageWithHeaders", testMessageWithHeaders) + ] + + var natsServer = NatsServer() + + override func tearDown() { + super.tearDown() + natsServer.stop() + } + + func testMessageWithHeaders() async throws { + natsServer.start() + logger.logLevel = .critical + + let client = NatsClientOptions().url(URL(string: natsServer.clientURL)!).build() + try await client.connect() + + let sub = try await client.subscribe(subject: "foo") + + var hm = NatsHeaderMap() + hm.append(try! NatsHeaderName("foo"), NatsHeaderValue("bar")) + hm.append(try! NatsHeaderName("foo"), NatsHeaderValue("baz")) + hm.insert(try! NatsHeaderName("another"), NatsHeaderValue("one")) + + try await client.publish( + "hello".data(using: .utf8)!, subject: "foo", reply: nil, headers: hm) + + let iter = sub.makeAsyncIterator() + let msg = try await iter.next() + XCTAssertEqual(msg!.headers, hm) + + } +} diff --git a/Tests/NatsTests/Integration/Resources/TestUser.creds b/Tests/NatsTests/Integration/Resources/TestUser.creds new file mode 100644 index 0000000..adb61b8 --- /dev/null +++ b/Tests/NatsTests/Integration/Resources/TestUser.creds @@ -0,0 +1,13 @@ +-----BEGIN NATS USER JWT----- +eyJ0eXAiOiJKV1QiLCJhbGciOiJlZDI1NTE5LW5rZXkifQ.eyJqdGkiOiJMN1dBT1hJU0tPSUZNM1QyNEhMQ09ENzJRT1czQkNVWEdETjRKVU1SSUtHTlQ3RzdZVFRRIiwiaWF0IjoxNjUxNzkwOTgyLCJpc3MiOiJBRFRRUzdaQ0ZWSk5XNTcyNkdPWVhXNVRTQ1pGTklRU0hLMlpHWVVCQ0Q1RDc3T1ROTE9PS1pPWiIsIm5hbWUiOiJUZXN0VXNlciIsInN1YiI6IlVBRkhHNkZVRDJVVTRTREZWQUZVTDVMREZPMlhNNFdZTTc2VU5YVFBKWUpLN0VFTVlSQkhUMlZFIiwibmF0cyI6eyJwdWIiOnt9LCJzdWIiOnt9LCJzdWJzIjotMSwiZGF0YSI6LTEsInBheWxvYWQiOi0xLCJ0eXBlIjoidXNlciIsInZlcnNpb24iOjJ9fQ.bp2-Jsy33l4ayF7Ku1MNdJby4WiMKUrG-rSVYGBusAtV3xP4EdCa-zhSNUaBVIL3uYPPCQYCEoM1pCUdOnoJBg +------END NATS USER JWT------ + +************************* IMPORTANT ************************* +NKEY Seed printed below can be used to sign and prove identity. +NKEYs are sensitive and should be treated as secrets. + +-----BEGIN USER NKEY SEED----- +SUACH75SWCM5D2JMJM6EKLR2WDARVGZT4QC6LX3AGHSWOMVAKERABBBRWM +------END USER NKEY SEED------ + +************************************************************* diff --git a/Tests/NatsTests/Integration/Resources/certs/client-all.pem b/Tests/NatsTests/Integration/Resources/certs/client-all.pem new file mode 100644 index 0000000..7352a67 --- /dev/null +++ b/Tests/NatsTests/Integration/Resources/certs/client-all.pem @@ -0,0 +1,86 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCznnzpbfhgAUBe +NtyUVTdHJZ93vWx7WeuO3q8zMePpxFhmCGmdtaO/rkdunQk5WGZpnO6wB3Ricn/O +k9CZs+6RU3X4b17mUjljO+k/CHjXHbiZsh3KzE9bHVlcMIBru5dv6gKhbp5NsYaC +5iOjpUtiFY2fkYdMbgtWlCEkvQJboQUwtKuNl5rbYX6blIYGjwq8w8ZqyGsiTzqs +Tw5mNVh4q0rW963LGjGJzsUBfw6YQ2bKEzysatDJMRRk2jJHrCQOzWdrxEh+wxle +Y4HTg2pdK/E0THhoIpFza4lAC05Ql5SQlzzLyWa9CJwUa+eDMUqJVbpMt2kyq0Bd +9txX54YxAgMBAAECggEAQ9rdqXmH2Qzf+jeTgN3ochI+egevUbIYkPKDET4Jsagh +FPqcm52g7Kq0BY+Bio5gsgk9CnbmesJykeG5bjdRKslyyZWZLj1lvJ1Hci6LKAjs +UfO92XzxhaRCu9b+zLQjc33d3Ipjd0pXXGAAmrO5FKa7x8o8aJ0x31U6aByXJXG+ +CtMJXuCdk4a7FnsiBIQxhC2Ihb+1whBH1mdv14+FqWPo9Vb+ioSIEAVO4thbD4jy +sKmDSnjrEDtjzKAEIrvZf9Hq56HJdu0RrpREIv9lmUOKze8GkAzIkDj55R2G10db +vRsWsjB/iqI5an3+LDfUtNadR0h21udCRaZOJX+/GQKBgQDAy34fMvIQhRl63f9A +GOT/bQ5HjOrXpmYFZw6062pGgfywj4ln5BYH+3s3orPeiExHjF5Lw4SG6u44nNmA +6RhPRsLfIfhg7OHfa8VYhQP+hexz0JlMwGMmid30aySnyIrXn/WsDpICCbPZR4Lz +OUIr23p/20WqbmPYdf2cW+PvfwKBgQDugTPwxyfcubVddZfVFIpxzGuZGLgL8s0K +7lxZE5B4d0FTMDUX9MHq7j9hMiHhSuyn0SeHyuP03FqZWzoSjv1r+keCwCmoPWAC +ocJC9SoGz7AdBnRmqlbqEHpY+3RvLtVYkwDcmfxVYq5I8gQu5O6X4PbQf+cFb/xI +hmC8Vh5iTwKBgQC6pYbxf2nX0nOLftY5YKB6JEM5w9QreH22Z0JWpr6ZighvilaV +TLyDd9SfVRXbr4phjiRQJvXrhA+ioT70zTVqsm/Ag2upskst+HDytLvcMh1rNhzj +sDGNQtWtZfjzsnOwMr0tmGGENY53IQNGoz1Lpkze8RJt4DcrfXdMY6200wKBgHv5 +aRhVTWEsnxuvjnbSMIyqp5ty/+gmE3MFJ7edtdEInEozmsWTEmGd6hAJ0RacrZsl +2xh43DlheS6R/wO6k/xWomlSndS34no7vxCzA197AZ50xni/PmJ4okAypPlOLNPX +xfDlkgaIPvPn6Ui+8067P1Bty5ZF+atxPkNnuG99AoGAO++bI6twiF2yFrqp8bJ5 +6zjkwVYVDds/acnierTnAVF3yM+eqyq1BYjSCTSupqnd7KT0EMX77BBbGHAQuUZM +fO3L7vgo10S18VWNiAt5yyZUKhN0lLwQeQeP0mIDcRKiLn7BwnNz8Wxo4p0KoKLS +QflLqbILbuspjBXQvQ1Asb0= +-----END PRIVATE KEY----- + + +-----BEGIN CERTIFICATE----- +MIIEnjCCAwagAwIBAgIQciuy77HHsdMG7UD/WPT1gzANBgkqhkiG9w0BAQsFADCB +gzEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMSwwKgYDVQQLDCNzdGpl +cGFuQGxvY2FsaG9zdCAoU3RqZXBhbiBHbGF2aW5hKTEzMDEGA1UEAwwqbWtjZXJ0 +IHN0amVwYW5AbG9jYWxob3N0IChTdGplcGFuIEdsYXZpbmEpMB4XDTE5MDYwMTAw +MDAwMFoXDTMwMTEwNTEyMDk1N1owYTEnMCUGA1UEChMebWtjZXJ0IGRldmVsb3Bt +ZW50IGNlcnRpZmljYXRlMTYwNAYDVQQLDC1zdGplcGFuQE1hY0Jvb2stUHJvLTIu +bG9jYWwgKFN0amVwYW4gR2xhdmluYSkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw +ggEKAoIBAQCznnzpbfhgAUBeNtyUVTdHJZ93vWx7WeuO3q8zMePpxFhmCGmdtaO/ +rkdunQk5WGZpnO6wB3Ricn/Ok9CZs+6RU3X4b17mUjljO+k/CHjXHbiZsh3KzE9b +HVlcMIBru5dv6gKhbp5NsYaC5iOjpUtiFY2fkYdMbgtWlCEkvQJboQUwtKuNl5rb +YX6blIYGjwq8w8ZqyGsiTzqsTw5mNVh4q0rW963LGjGJzsUBfw6YQ2bKEzysatDJ +MRRk2jJHrCQOzWdrxEh+wxleY4HTg2pdK/E0THhoIpFza4lAC05Ql5SQlzzLyWa9 +CJwUa+eDMUqJVbpMt2kyq0Bd9txX54YxAgMBAAGjga4wgaswDgYDVR0PAQH/BAQD +AgWgMDEGA1UdJQQqMCgGCCsGAQUFBwMCBggrBgEFBQcDAQYIKwYBBQUHAwMGCCsG +AQUFBwMEMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUaDvaviAqw6RnyuBf3BI7 +S2uUmfIwNwYDVR0RBDAwLoIJbG9jYWxob3N0gQ9lbWFpbEBsb2NhbGhvc3SHEAAA +AAAAAAAAAAAAAAAAAAEwDQYJKoZIhvcNAQELBQADggGBAFc5p6LYY6zIBvJbVNXL +pofEM/ky999eHmLacKAAs08bBEpRLvho7Maf/YsRfFEB9tzE/Nhrc/9yC2hM1Jsy +9I3jM5OPErEHAqzCpi5L0ykuKcmvLCAVLrBiEmBcAvY4snirIGZ0YMW5fqcrcy4B +84Ptg9efUnV/XC3VQTLRX0pWp8t1T98P1ZFYK7dK1ejkC61/kO5WjTlIOkT+oVXf +A/juQubq2QKkROTUze5pmA0jBTgFtszXfryuh++NsBb9vHWHV3JFgW2k5brw0ozs +0F3snFOafLrw3xJcI8j2gYi+4sI6mmZTRWKhWF6TbWPl4Ysk+K/BkcEcDebYFCJz +BNmqVjzKLv3qEDV+XaTYa96qlEJRTI96rofL3zzXC381XYhNRLFS5ExHchuETUKW +S70tKNsyzpd3V0X4RTgopFuXzSfvaQycu3OwM74Ks0xmTgZL396ZlbtJfuT8v/2d +c+t8Ei6H4a0r/nyip1df3keRL/otvkP4sby7D4xi0CJ2dw== +-----END CERTIFICATE----- + +-----BEGIN CERTIFICATE----- +MIIE2DCCA0CgAwIBAgIRAIW/i8Ryvk+oZGg+/FvDpW8wDQYJKoZIhvcNAQELBQAw +gYMxHjAcBgNVBAoTFW1rY2VydCBkZXZlbG9wbWVudCBDQTEsMCoGA1UECwwjc3Rq +ZXBhbkBsb2NhbGhvc3QgKFN0amVwYW4gR2xhdmluYSkxMzAxBgNVBAMMKm1rY2Vy +dCBzdGplcGFuQGxvY2FsaG9zdCAoU3RqZXBhbiBHbGF2aW5hKTAeFw0yMDA3MDcx +NTU4NTlaFw0zMDA3MDcxNTU4NTlaMIGDMR4wHAYDVQQKExVta2NlcnQgZGV2ZWxv +cG1lbnQgQ0ExLDAqBgNVBAsMI3N0amVwYW5AbG9jYWxob3N0IChTdGplcGFuIEds +YXZpbmEpMTMwMQYDVQQDDCpta2NlcnQgc3RqZXBhbkBsb2NhbGhvc3QgKFN0amVw +YW4gR2xhdmluYSkwggGiMA0GCSqGSIb3DQEBAQUAA4IBjwAwggGKAoIBgQCyRbze +lNj5QkxgMxYIr4yEqLtTz/jSiX1W6uUymI+e1GQzl3BpP3fS/SyB9HZv1bn4PoiW +B+BggsFrOsmVF4aXbikRz74DQ7FCqVMU/QGvlIbHkH5TShcLGzngG8DR3+Url3S8 +rlvYQBf2AXYXWcmSl1bFYzVxWSt67NzS29mvus40aAPXTpB7vq6nNs6F+7sARhDi +OPRqniPqV29khMmxndwExAEMJBVZbeTNuwfehCud8dOj0kzk5ESX+3upIwOrnoye +2tRNW34WWaxQrV65KOeGxdgIN+PeO7WL1jbCitCaGnGitTbtPGMCdf6LmRhA30Wy +IZ3ZkxOmvXGkpAR6mxz3pqQWTZYieTA92s63LeVSNeYUof0tNu0SMTYWuAas0Ob3 +A/lu7PTCjTag5vVU5RwkfBmcNrbNNy9NbKgQB7TafZn7sfPpZT4EpAcFMgRb4KfR +HfPiaxlDu2LKmBS9+i7x79nYAlB5IGgLyQ1cldwjDYqAAizBoigM2PI3tEMCAwEA +AaNFMEMwDgYDVR0PAQH/BAQDAgIEMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0O +BBYEFGg72r4gKsOkZ8rgX9wSO0trlJnyMA0GCSqGSIb3DQEBCwUAA4IBgQBzxbH6 +s7dhvyOGK5HnQmc/vPiTbOk4QhXX12PPekL+UrhMyiMzWET2wkpHJXxtes0dGNU3 +tmQwVQTvg/BhyxxGcafvQe/o/5rS9lrrpdQriZg3gqqeUoi8UX+5/ef9EB7Z32Ho +qUSOd6hPLLI+3UrnmWP+u5PRDmsHQYKc2YqyqQisjRVwLtGtCmGYfuBncP145Yyi +qNlwI6jeZTAtRSkcKy6fnyJcjOCYKFWHpTGmBTMtO4LiTGadxnmbAq9mRBiKJJp6 +wrSz1JvbVXVY4caxpbDfkaT8RiP+k1Fbd6uMWnZTJLHPTNbzCl4aXcuHgoRhCLeq +SdF3L7m0tM7lsTP3tddRY6zb+1u0II0Gu6umDsdyL6JOV4vv9Qb7xdy2jTU231+o +TXLHaypw4Amp267EyvvWmU3VOl8BeUkJ/7LOqzZfKxTECwnxWywx6NV9ONQt8mNC +ATAQAyYXklJsZkX6VLMPE0Lv4Qbt/GnGUejER09zQi433e9jUF+vwQGwj/g= +-----END CERTIFICATE----- diff --git a/Tests/NatsTests/Integration/Resources/certs/client-cert-invalid.pem b/Tests/NatsTests/Integration/Resources/certs/client-cert-invalid.pem new file mode 100644 index 0000000..e9d2c82 --- /dev/null +++ b/Tests/NatsTests/Integration/Resources/certs/client-cert-invalid.pem @@ -0,0 +1,28 @@ +-----BEGIN CERTIFICATE----- +MIIExDCCAyygAwIBAgIQEdLeZgsrEsLe37gR/voylTANBgkqhkiG9w0BAQsFADCB +szEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMUQwQgYDVQQLDDtwaW90 +cnBpb3Ryb3dza2lAUGlvdHJzLU1hY0Jvb2stUHJvLmxvY2FsIChQaW90ciBQaW90 +cm93c2tpKTFLMEkGA1UEAwxCbWtjZXJ0IHBpb3RycGlvdHJvd3NraUBQaW90cnMt +TWFjQm9vay1Qcm8ubG9jYWwgKFBpb3RyIFBpb3Ryb3dza2kpMB4XDTIzMDUxMjEw +MTYyOFoXDTI1MDgxMjEwMTYyOFowbzEnMCUGA1UEChMebWtjZXJ0IGRldmVsb3Bt +ZW50IGNlcnRpZmljYXRlMUQwQgYDVQQLDDtwaW90cnBpb3Ryb3dza2lAUGlvdHJz +LU1hY0Jvb2stUHJvLmxvY2FsIChQaW90ciBQaW90cm93c2tpKTCCASIwDQYJKoZI +hvcNAQEBBQADggEPADCCAQoCggEBAJqlzdmIcPNu8ad7TlrJA2SdAtaxYJJK8lRs +oAdq+PO7JeWE8NyEPSEFpXclWsEvG49gS6AueLPRuVT4WqbIDVqm5Rvcx/a1K39i +6Ik3qpLerGY8vPngIhXoU4CUjNrEyJy22bhCPidJtPRnfkVv/eI6LSA2jWsikGNH +Iqnbu6KtUIKbnLuuH0NR8ycYaqeiOdaCMV6STSXmM5S96qH7h7NexGC08b+aersw +CLelFR04J0RV/cax7U1pgsWKKv8icnjiB4tq5IbYuEZE0g/uJZ3BsB5DXKq+WjNI +uRDJJUWyzrWhBPNIW/1I2zesEXSKCvDcUVAuUceYxiEokR4+pv0CAwEAAaOBljCB +kzAOBgNVHQ8BAf8EBAMCBaAwJwYDVR0lBCAwHgYIKwYBBQUHAwIGCCsGAQUFBwMB +BggrBgEFBQcDBDAfBgNVHSMEGDAWgBQbD6YymnmaX19FroClM52B8doDIDA3BgNV +HREEMDAugglsb2NhbGhvc3SBD2VtYWlsQGxvY2FsaG9zdIcQAAAAAAAAAAAAAAAA +AAAAATANBgkqhkiG9w0BAQsFAAOCAYEAJuFrQ0KdmwEc7UyaoTygW59f1JSJGbZa +Ii5EuMtpSon5DX5NaI5aRE350UtimNrQIu8LAPx1UGwSRuPkzvuNAA/l0HAJrqh3 +gEorH6fbsRkqkDUvmNiqTfs+So6R0s2+6yVG6t8+NT1OBH616eQ9efvthwRO0AAL +L8LGJJdYMveEJv+GB/+Zs75MQUxniJ+ip/YxF8bcaRjVS/tb3J52yZ1Eb2UU18kN +uAlFOxiKnwvb2csFcZ6zc4Fpm0LfCrpzPCwGF5y6bsjzpqVej87ea6roG9BJ7vbX +xjbwGfchJZmDsG/g9MeoQoIifYqupQmtaQtlKUUD5MRjDhpOVUEJ4tsXDoZEz9DB +kviE+VlIGU2QJ5l9KU2rIdxfh95rrIaqCt5xsT6wUjNtv0wAfbhMannUhjLv+h+G +tIbMIEo0GFA/uY1eXLO4PTgF+EojqFfpUUM17Z3kubsOSvepxkwyipA5eI2fkThu +Yu5Oyyq9X9Y3vnDMvHKJfkzA56Sp19Oy +-----END CERTIFICATE----- diff --git a/Tests/NatsTests/Integration/Resources/certs/client-cert.pem b/Tests/NatsTests/Integration/Resources/certs/client-cert.pem new file mode 100644 index 0000000..e2cc0cf --- /dev/null +++ b/Tests/NatsTests/Integration/Resources/certs/client-cert.pem @@ -0,0 +1,27 @@ +-----BEGIN CERTIFICATE----- +MIIEnjCCAwagAwIBAgIQciuy77HHsdMG7UD/WPT1gzANBgkqhkiG9w0BAQsFADCB +gzEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMSwwKgYDVQQLDCNzdGpl +cGFuQGxvY2FsaG9zdCAoU3RqZXBhbiBHbGF2aW5hKTEzMDEGA1UEAwwqbWtjZXJ0 +IHN0amVwYW5AbG9jYWxob3N0IChTdGplcGFuIEdsYXZpbmEpMB4XDTE5MDYwMTAw +MDAwMFoXDTMwMTEwNTEyMDk1N1owYTEnMCUGA1UEChMebWtjZXJ0IGRldmVsb3Bt +ZW50IGNlcnRpZmljYXRlMTYwNAYDVQQLDC1zdGplcGFuQE1hY0Jvb2stUHJvLTIu +bG9jYWwgKFN0amVwYW4gR2xhdmluYSkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw +ggEKAoIBAQCznnzpbfhgAUBeNtyUVTdHJZ93vWx7WeuO3q8zMePpxFhmCGmdtaO/ +rkdunQk5WGZpnO6wB3Ricn/Ok9CZs+6RU3X4b17mUjljO+k/CHjXHbiZsh3KzE9b +HVlcMIBru5dv6gKhbp5NsYaC5iOjpUtiFY2fkYdMbgtWlCEkvQJboQUwtKuNl5rb +YX6blIYGjwq8w8ZqyGsiTzqsTw5mNVh4q0rW963LGjGJzsUBfw6YQ2bKEzysatDJ +MRRk2jJHrCQOzWdrxEh+wxleY4HTg2pdK/E0THhoIpFza4lAC05Ql5SQlzzLyWa9 +CJwUa+eDMUqJVbpMt2kyq0Bd9txX54YxAgMBAAGjga4wgaswDgYDVR0PAQH/BAQD +AgWgMDEGA1UdJQQqMCgGCCsGAQUFBwMCBggrBgEFBQcDAQYIKwYBBQUHAwMGCCsG +AQUFBwMEMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUaDvaviAqw6RnyuBf3BI7 +S2uUmfIwNwYDVR0RBDAwLoIJbG9jYWxob3N0gQ9lbWFpbEBsb2NhbGhvc3SHEAAA +AAAAAAAAAAAAAAAAAAEwDQYJKoZIhvcNAQELBQADggGBAFc5p6LYY6zIBvJbVNXL +pofEM/ky999eHmLacKAAs08bBEpRLvho7Maf/YsRfFEB9tzE/Nhrc/9yC2hM1Jsy +9I3jM5OPErEHAqzCpi5L0ykuKcmvLCAVLrBiEmBcAvY4snirIGZ0YMW5fqcrcy4B +84Ptg9efUnV/XC3VQTLRX0pWp8t1T98P1ZFYK7dK1ejkC61/kO5WjTlIOkT+oVXf +A/juQubq2QKkROTUze5pmA0jBTgFtszXfryuh++NsBb9vHWHV3JFgW2k5brw0ozs +0F3snFOafLrw3xJcI8j2gYi+4sI6mmZTRWKhWF6TbWPl4Ysk+K/BkcEcDebYFCJz +BNmqVjzKLv3qEDV+XaTYa96qlEJRTI96rofL3zzXC381XYhNRLFS5ExHchuETUKW +S70tKNsyzpd3V0X4RTgopFuXzSfvaQycu3OwM74Ks0xmTgZL396ZlbtJfuT8v/2d +c+t8Ei6H4a0r/nyip1df3keRL/otvkP4sby7D4xi0CJ2dw== +-----END CERTIFICATE----- diff --git a/Tests/NatsTests/Integration/Resources/certs/client-key-invalid.pem b/Tests/NatsTests/Integration/Resources/certs/client-key-invalid.pem new file mode 100644 index 0000000..9fd784e --- /dev/null +++ b/Tests/NatsTests/Integration/Resources/certs/client-key-invalid.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCapc3ZiHDzbvGn +e05ayQNknQLWsWCSSvJUbKAHavjzuyXlhPDchD0hBaV3JVrBLxuPYEugLniz0blU ++FqmyA1apuUb3Mf2tSt/YuiJN6qS3qxmPLz54CIV6FOAlIzaxMicttm4Qj4nSbT0 +Z35Fb/3iOi0gNo1rIpBjRyKp27uirVCCm5y7rh9DUfMnGGqnojnWgjFekk0l5jOU +veqh+4ezXsRgtPG/mnq7MAi3pRUdOCdEVf3Gse1NaYLFiir/InJ44geLauSG2LhG +RNIP7iWdwbAeQ1yqvlozSLkQySVFss61oQTzSFv9SNs3rBF0igrw3FFQLlHHmMYh +KJEePqb9AgMBAAECggEAMypWT/mPfUsgksv+IZVOFRTJoqSvEdfQE1SZIbsnwOQT +Zru0QRFTdEB8/U2TmETwtmAixU16y+vAiLderr2ThYGgXbaPRjWsvYnI69VKDyuz +GGRSFc4tGNh0AB+l9p+SzB7HK+pmy/Lb9tzi7zBdbGLZGUZTRbX61Y3sjwxPKUPw +mrTindOvSH6FbVevAC6UCl92R9vk4ugS/oDZWKPntHeJX8NzXM6MfZ68+oPKeGXE +DTQ98nZDfZMRDvyyClaAVfstsPV1pxYNYIWaB1w0saL2NZz08zZeGnkqFt621Q4V +gsE9t9Gjg1o5paq0MEm5vBoJo7VyCoR7w/sfEzQaAQKBgQDAk/yOY2yxGcjZjE8q +ozXq/EtbC/ldKcgngm1KYtA7ZyzRt7gGuAuv6sbmri4wpHL1D2UeKS8QPsywkXvM +Oto3NdJraXbC26ObCP+njwWHWD1Zh3BD2O2mYOFUsTWzsxaZJbsy6Sh4cLg+0gVc +UYzqOnUY5EJh6hCnGSZnK4v4fQKBgQDNk/QbK4RrRH33qaAly/Ihw5ZI76s3hc4Y +RcsGi05iAV/jiE9HVF8vWytp1EdoLsO0BPrA9RPP8SYZdHCWh0KFYJtFzU+o8+1W +ThtCIPdmOmAtQnoj52TMmwc+x/WbNIBvBrKQHIbTX9JHUiGFM9NqSuVjNhVDOzvM +/o2D38swgQKBgBRzou68QF7OjjYMYJv2mVNLV/VjYCg0t7z6bQDpXZPxcSEUkcak +5RjZpiX5eY5Q6KR97g818HmZMcPOr4cQ+PvEC4S8vpATI1zjp8LzvXKSPHG1oIaU +EykIQOXtq/ZZnpzFFQxjFpkz311MkKUtQ/ncG3N5SlN7uCkG0r1CMqtBAoGBAL/z +myVXb9Bc5qW+a7t+/7oJDyVRK/Su6m39lQGqR2j5UZh5qVS38hycqx+ox3f+2lsX +ny9WZsZtq55u+8WBzFoPh0wY1X2zLXO9gHQxpe99KFp6TOODZropMw2q1aiy0A1b +GpW3HSj2urg/du8SIiCIiEEnuZjKER9qu6Zb6zSBAoGBALxqe9jb7WLArV/eMEtx +zg7V/FZfFyqEGEbLMM9njM6uiSq0u17H5bsvmgi+dAot16BbDPKWdOw01zQDhphe +GbchPMuNOPNyBm3MIJ5zXi4pQcc5W+Z5z54X9BCBJwIHEp+Tt9VJ6J9/RkSoTXp9 +iq9elhb5bfMSA/KliX3cBTge +-----END PRIVATE KEY----- diff --git a/Tests/NatsTests/Integration/Resources/certs/client-key.pem b/Tests/NatsTests/Integration/Resources/certs/client-key.pem new file mode 100644 index 0000000..620e6b7 --- /dev/null +++ b/Tests/NatsTests/Integration/Resources/certs/client-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCznnzpbfhgAUBe +NtyUVTdHJZ93vWx7WeuO3q8zMePpxFhmCGmdtaO/rkdunQk5WGZpnO6wB3Ricn/O +k9CZs+6RU3X4b17mUjljO+k/CHjXHbiZsh3KzE9bHVlcMIBru5dv6gKhbp5NsYaC +5iOjpUtiFY2fkYdMbgtWlCEkvQJboQUwtKuNl5rbYX6blIYGjwq8w8ZqyGsiTzqs +Tw5mNVh4q0rW963LGjGJzsUBfw6YQ2bKEzysatDJMRRk2jJHrCQOzWdrxEh+wxle +Y4HTg2pdK/E0THhoIpFza4lAC05Ql5SQlzzLyWa9CJwUa+eDMUqJVbpMt2kyq0Bd +9txX54YxAgMBAAECggEAQ9rdqXmH2Qzf+jeTgN3ochI+egevUbIYkPKDET4Jsagh +FPqcm52g7Kq0BY+Bio5gsgk9CnbmesJykeG5bjdRKslyyZWZLj1lvJ1Hci6LKAjs +UfO92XzxhaRCu9b+zLQjc33d3Ipjd0pXXGAAmrO5FKa7x8o8aJ0x31U6aByXJXG+ +CtMJXuCdk4a7FnsiBIQxhC2Ihb+1whBH1mdv14+FqWPo9Vb+ioSIEAVO4thbD4jy +sKmDSnjrEDtjzKAEIrvZf9Hq56HJdu0RrpREIv9lmUOKze8GkAzIkDj55R2G10db +vRsWsjB/iqI5an3+LDfUtNadR0h21udCRaZOJX+/GQKBgQDAy34fMvIQhRl63f9A +GOT/bQ5HjOrXpmYFZw6062pGgfywj4ln5BYH+3s3orPeiExHjF5Lw4SG6u44nNmA +6RhPRsLfIfhg7OHfa8VYhQP+hexz0JlMwGMmid30aySnyIrXn/WsDpICCbPZR4Lz +OUIr23p/20WqbmPYdf2cW+PvfwKBgQDugTPwxyfcubVddZfVFIpxzGuZGLgL8s0K +7lxZE5B4d0FTMDUX9MHq7j9hMiHhSuyn0SeHyuP03FqZWzoSjv1r+keCwCmoPWAC +ocJC9SoGz7AdBnRmqlbqEHpY+3RvLtVYkwDcmfxVYq5I8gQu5O6X4PbQf+cFb/xI +hmC8Vh5iTwKBgQC6pYbxf2nX0nOLftY5YKB6JEM5w9QreH22Z0JWpr6ZighvilaV +TLyDd9SfVRXbr4phjiRQJvXrhA+ioT70zTVqsm/Ag2upskst+HDytLvcMh1rNhzj +sDGNQtWtZfjzsnOwMr0tmGGENY53IQNGoz1Lpkze8RJt4DcrfXdMY6200wKBgHv5 +aRhVTWEsnxuvjnbSMIyqp5ty/+gmE3MFJ7edtdEInEozmsWTEmGd6hAJ0RacrZsl +2xh43DlheS6R/wO6k/xWomlSndS34no7vxCzA197AZ50xni/PmJ4okAypPlOLNPX +xfDlkgaIPvPn6Ui+8067P1Bty5ZF+atxPkNnuG99AoGAO++bI6twiF2yFrqp8bJ5 +6zjkwVYVDds/acnierTnAVF3yM+eqyq1BYjSCTSupqnd7KT0EMX77BBbGHAQuUZM +fO3L7vgo10S18VWNiAt5yyZUKhN0lLwQeQeP0mIDcRKiLn7BwnNz8Wxo4p0KoKLS +QflLqbILbuspjBXQvQ1Asb0= +-----END PRIVATE KEY----- diff --git a/Tests/NatsTests/Integration/Resources/certs/ip-ca.pem b/Tests/NatsTests/Integration/Resources/certs/ip-ca.pem new file mode 100644 index 0000000..911c486 --- /dev/null +++ b/Tests/NatsTests/Integration/Resources/certs/ip-ca.pem @@ -0,0 +1,27 @@ +-----BEGIN CERTIFICATE----- +MIIEkDCCA3igAwIBAgIUSZwW7btc9EUbrMWtjHpbM0C2bSEwDQYJKoZIhvcNAQEL +BQAwcTELMAkGA1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExEDAOBgNVBAoM +B1N5bmFkaWExEDAOBgNVBAsMB25hdHMuaW8xKTAnBgNVBAMMIENlcnRpZmljYXRl +IEF1dGhvcml0eSAyMDIyLTA4LTI3MB4XDTIyMDgyNzIwMjMwMloXDTMyMDgyNDIw +MjMwMlowcTELMAkGA1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExEDAOBgNV +BAoMB1N5bmFkaWExEDAOBgNVBAsMB25hdHMuaW8xKTAnBgNVBAMMIENlcnRpZmlj +YXRlIEF1dGhvcml0eSAyMDIyLTA4LTI3MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEAqilVqyY8rmCpTwAsLF7DEtWEq37KbljBWVjmlp2Wo6TgMd3b537t +6iO8+SbI8KH75i63RcxV3Uzt1/L9Yb6enDXF52A/U5ugmDhaa+Vsoo2HBTbCczmp +qndp7znllQqn7wNLv6aGSvaeIUeYS5Dmlh3kt7Vqbn4YRANkOUTDYGSpMv7jYKSu +1ee05Rco3H674zdwToYto8L8V7nVMrky42qZnGrJTaze+Cm9tmaIyHCwUq362CxS +dkmaEuWx11MOIFZvL80n7ci6pveDxe5MIfwMC3/oGn7mbsSqidPMcTtjw6ey5NEu +Z0UrC/2lL1FtF4gnVMKUSaEhU2oKjj0ZAQIDAQABo4IBHjCCARowHQYDVR0OBBYE +FP7Pfz4u7sSt6ltviEVsx4hIFIs6MIGuBgNVHSMEgaYwgaOAFP7Pfz4u7sSt6ltv +iEVsx4hIFIs6oXWkczBxMQswCQYDVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5p +YTEQMA4GA1UECgwHU3luYWRpYTEQMA4GA1UECwwHbmF0cy5pbzEpMCcGA1UEAwwg +Q2VydGlmaWNhdGUgQXV0aG9yaXR5IDIwMjItMDgtMjeCFEmcFu27XPRFG6zFrYx6 +WzNAtm0hMAwGA1UdEwQFMAMBAf8wOgYJYIZIAYb4QgENBC0WK25hdHMuaW8gbmF0 +cy1zZXJ2ZXIgdGVzdC1zdWl0ZSB0cmFuc2llbnQgQ0EwDQYJKoZIhvcNAQELBQAD +ggEBAHDCHLQklYZlnzHDaSwxgGSiPUrCf2zhk2DNIYSDyBgdzrIapmaVYQRrCBtA +j/4jVFesgw5WDoe4TKsyha0QeVwJDIN8qg2pvpbmD8nOtLApfl0P966vcucxDwqO +dQWrIgNsaUdHdwdo0OfvAlTfG0v/y2X0kbL7h/el5W9kWpxM/rfbX4IHseZL2sLq +FH69SN3FhMbdIm1ldrcLBQVz8vJAGI+6B9hSSFQWljssE0JfAX+8VW/foJgMSx7A +vBTq58rLkAko56Jlzqh/4QT+ckayg9I73v1Q5/44jP1mHw35s5ZrzpDQt2sVv4l5 +lwRPJFXMwe64flUs9sM+/vqJaIY= +-----END CERTIFICATE----- diff --git a/Tests/NatsTests/Integration/Resources/certs/ip-cert.pem b/Tests/NatsTests/Integration/Resources/certs/ip-cert.pem new file mode 100644 index 0000000..80a9d8f --- /dev/null +++ b/Tests/NatsTests/Integration/Resources/certs/ip-cert.pem @@ -0,0 +1,99 @@ +Certificate: + Data: + Version: 3 (0x2) + Serial Number: + 1d:d9:1f:06:dd:fd:90:26:4e:27:ea:2e:01:4b:31:e6:d2:49:31:1f + Signature Algorithm: sha256WithRSAEncryption + Issuer: C=US, ST=California, O=Synadia, OU=nats.io, CN=Certificate Authority 2022-08-27 + Validity + Not Before: Aug 27 20:23:02 2022 GMT + Not After : Aug 24 20:23:02 2032 GMT + Subject: C=US, ST=California, O=Synadia, OU=nats.io, CN=localhost + Subject Public Key Info: + Public Key Algorithm: rsaEncryption + RSA Public-Key: (2048 bit) + Modulus: + 00:e6:fb:47:65:cd:c9:a2:2d:af:8b:cd:d5:6a:79: + 54:3c:07:5f:eb:5a:71:2b:2b:e5:6f:be:31:fb:16: + 65:68:76:0e:59:e7:e4:57:ca:88:e9:77:d6:41:ad: + 57:7a:42:b2:d2:54:c4:0f:7c:5b:c1:bc:61:97:e3: + 22:3a:3e:1e:4a:5d:47:9f:6b:7d:6f:34:e3:8c:86: + 9d:85:19:29:9a:11:58:44:4c:a1:90:d3:14:61:e1: + 57:da:01:ea:ce:3f:90:ae:9e:5d:13:6d:2c:89:ca: + 39:15:6b:b6:9e:32:d7:2a:4c:48:85:2f:b0:1e:d8: + 4b:62:32:14:eb:32:b6:29:04:34:3c:af:39:b6:8b: + 52:32:4d:bf:43:5f:9b:fb:0d:43:a6:ad:2c:a7:41: + 29:55:c9:70:b3:b5:15:46:34:bf:e4:1e:52:2d:a4: + 49:2e:d5:21:ed:fc:00:f7:a2:0b:bc:12:0a:90:64: + 50:7c:c5:14:70:f5:fb:9b:62:08:78:43:49:31:f3: + 47:b8:93:d4:2d:4c:a9:dc:17:70:76:34:66:ff:65: + c1:39:67:e9:a6:1c:80:6a:f0:9d:b3:28:c8:a3:3a: + b7:5d:de:6e:53:6d:09:b3:0d:b1:13:10:e8:ec:e0: + bd:5e:a1:94:4b:70:bf:dc:bd:8b:b9:82:65:dd:af: + 81:7b + Exponent: 65537 (0x10001) + X509v3 extensions: + X509v3 Basic Constraints: + CA:FALSE + Netscape Comment: + nats.io nats-server test-suite certificate + X509v3 Subject Key Identifier: + 2B:8C:A3:8B:DB:DB:5C:CE:18:DB:F6:A8:31:4E:C2:3E:EE:D3:40:7E + X509v3 Authority Key Identifier: + keyid:FE:CF:7F:3E:2E:EE:C4:AD:EA:5B:6F:88:45:6C:C7:88:48:14:8B:3A + DirName:/C=US/ST=California/O=Synadia/OU=nats.io/CN=Certificate Authority 2022-08-27 + serial:49:9C:16:ED:BB:5C:F4:45:1B:AC:C5:AD:8C:7A:5B:33:40:B6:6D:21 + + X509v3 Subject Alternative Name: + DNS:localhost, IP Address:127.0.0.1, IP Address:0:0:0:0:0:0:0:1 + Netscape Cert Type: + SSL Client, SSL Server + X509v3 Key Usage: + Digital Signature, Key Encipherment + X509v3 Extended Key Usage: + TLS Web Server Authentication, Netscape Server Gated Crypto, Microsoft Server Gated Crypto, TLS Web Client Authentication + Signature Algorithm: sha256WithRSAEncryption + 54:49:34:2b:38:d1:aa:3b:43:60:4c:3f:6a:f8:74:ca:49:53: + a1:af:12:d3:a8:17:90:7b:9d:a3:69:13:6e:da:2c:b7:61:31: + ac:eb:00:93:92:fc:0c:10:d4:18:a0:16:61:94:4b:42:cb:eb: + 7a:f6:80:c6:45:c0:9c:09:aa:a9:48:e8:36:e3:c5:be:36:e0: + e9:78:2a:bb:ab:64:9b:20:eb:e6:0f:63:2b:59:c3:58:0b:3a: + 84:15:04:c1:7e:12:03:1b:09:25:8d:4c:03:e8:18:26:c0:6c: + b7:90:b1:fd:bc:f1:cf:d0:d5:4a:03:15:71:0c:7d:c1:76:87: + 92:f1:3e:bc:75:51:5a:c4:36:a4:ff:91:98:df:33:5d:a7:38: + de:50:29:fd:0f:c8:55:e6:8f:24:c2:2e:98:ab:d9:5d:65:2f: + 50:cc:25:f6:84:f2:21:2e:5e:76:d0:86:1e:69:8b:cb:8a:3a: + 2d:79:21:5e:e7:f7:2d:06:18:a1:13:cb:01:c3:46:91:2a:de: + b4:82:d7:c3:62:6f:08:a1:d5:90:19:30:9d:64:8e:e4:f8:ba: + 4f:2f:ba:13:b4:a3:9f:d1:d5:77:64:8a:3e:eb:53:c5:47:ac: + ab:3e:0e:7a:9b:a6:f4:48:25:66:eb:c7:4c:f9:50:24:eb:71: + e0:75:ae:e6 +-----BEGIN CERTIFICATE----- +MIIE+TCCA+GgAwIBAgIUHdkfBt39kCZOJ+ouAUsx5tJJMR8wDQYJKoZIhvcNAQEL +BQAwcTELMAkGA1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExEDAOBgNVBAoM +B1N5bmFkaWExEDAOBgNVBAsMB25hdHMuaW8xKTAnBgNVBAMMIENlcnRpZmljYXRl +IEF1dGhvcml0eSAyMDIyLTA4LTI3MB4XDTIyMDgyNzIwMjMwMloXDTMyMDgyNDIw +MjMwMlowWjELMAkGA1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExEDAOBgNV +BAoMB1N5bmFkaWExEDAOBgNVBAsMB25hdHMuaW8xEjAQBgNVBAMMCWxvY2FsaG9z +dDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAOb7R2XNyaItr4vN1Wp5 +VDwHX+tacSsr5W++MfsWZWh2Dlnn5FfKiOl31kGtV3pCstJUxA98W8G8YZfjIjo+ +HkpdR59rfW8044yGnYUZKZoRWERMoZDTFGHhV9oB6s4/kK6eXRNtLInKORVrtp4y +1ypMSIUvsB7YS2IyFOsytikENDyvObaLUjJNv0Nfm/sNQ6atLKdBKVXJcLO1FUY0 +v+QeUi2kSS7VIe38APeiC7wSCpBkUHzFFHD1+5tiCHhDSTHzR7iT1C1MqdwXcHY0 +Zv9lwTln6aYcgGrwnbMoyKM6t13eblNtCbMNsRMQ6OzgvV6hlEtwv9y9i7mCZd2v +gXsCAwEAAaOCAZ4wggGaMAkGA1UdEwQCMAAwOQYJYIZIAYb4QgENBCwWKm5hdHMu +aW8gbmF0cy1zZXJ2ZXIgdGVzdC1zdWl0ZSBjZXJ0aWZpY2F0ZTAdBgNVHQ4EFgQU +K4yji9vbXM4Y2/aoMU7CPu7TQH4wga4GA1UdIwSBpjCBo4AU/s9/Pi7uxK3qW2+I +RWzHiEgUizqhdaRzMHExCzAJBgNVBAYTAlVTMRMwEQYDVQQIDApDYWxpZm9ybmlh +MRAwDgYDVQQKDAdTeW5hZGlhMRAwDgYDVQQLDAduYXRzLmlvMSkwJwYDVQQDDCBD +ZXJ0aWZpY2F0ZSBBdXRob3JpdHkgMjAyMi0wOC0yN4IUSZwW7btc9EUbrMWtjHpb +M0C2bSEwLAYDVR0RBCUwI4IJbG9jYWxob3N0hwR/AAABhxAAAAAAAAAAAAAAAAAA +AAABMBEGCWCGSAGG+EIBAQQEAwIGwDALBgNVHQ8EBAMCBaAwNAYDVR0lBC0wKwYI +KwYBBQUHAwEGCWCGSAGG+EIEAQYKKwYBBAGCNwoDAwYIKwYBBQUHAwIwDQYJKoZI +hvcNAQELBQADggEBAFRJNCs40ao7Q2BMP2r4dMpJU6GvEtOoF5B7naNpE27aLLdh +MazrAJOS/AwQ1BigFmGUS0LL63r2gMZFwJwJqqlI6Dbjxb424Ol4KrurZJsg6+YP +YytZw1gLOoQVBMF+EgMbCSWNTAPoGCbAbLeQsf288c/Q1UoDFXEMfcF2h5LxPrx1 +UVrENqT/kZjfM12nON5QKf0PyFXmjyTCLpir2V1lL1DMJfaE8iEuXnbQhh5pi8uK +Oi15IV7n9y0GGKETywHDRpEq3rSC18Nibwih1ZAZMJ1kjuT4uk8vuhO0o5/R1Xdk +ij7rU8VHrKs+DnqbpvRIJWbrx0z5UCTrceB1ruY= +-----END CERTIFICATE----- diff --git a/Tests/NatsTests/Integration/Resources/certs/ip-key.pem b/Tests/NatsTests/Integration/Resources/certs/ip-key.pem new file mode 100644 index 0000000..f2c2c6c --- /dev/null +++ b/Tests/NatsTests/Integration/Resources/certs/ip-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEuwIBADANBgkqhkiG9w0BAQEFAASCBKUwggShAgEAAoIBAQDm+0dlzcmiLa+L +zdVqeVQ8B1/rWnErK+VvvjH7FmVodg5Z5+RXyojpd9ZBrVd6QrLSVMQPfFvBvGGX +4yI6Ph5KXUefa31vNOOMhp2FGSmaEVhETKGQ0xRh4VfaAerOP5Cunl0TbSyJyjkV +a7aeMtcqTEiFL7Ae2EtiMhTrMrYpBDQ8rzm2i1IyTb9DX5v7DUOmrSynQSlVyXCz +tRVGNL/kHlItpEku1SHt/AD3ogu8EgqQZFB8xRRw9fubYgh4Q0kx80e4k9QtTKnc +F3B2NGb/ZcE5Z+mmHIBq8J2zKMijOrdd3m5TbQmzDbETEOjs4L1eoZRLcL/cvYu5 +gmXdr4F7AgMBAAECggEBAK4sr3MiEbjcsHJAvXyzjwRRH1Bu+8VtLW7swe2vvrpd +w4aiKXrV/BXpSsRtvPgxkXyvdMSkpuBZeFI7cVTwAJFc86RQPt77x9bwr5ltFwTZ +rXCbRH3b3ZPNhByds3zhS+2Q92itu5cPyanQdn2mor9/lHPyOOGZgobCcynELL6R +wRElkeDyf5ODuWEd7ADC5IFyZuwb3azNVexIK+0yqnMmv+QzEW3hsycFmFGAeB7v +MIMjb2BhLrRr6Y5Nh+k58yM5DCf9h/OJhDpeXwLkxyK4BFg+aZffEbUX0wHDMR7f +/nMv1g6cKvDWiLU8xLzez4t2qNIBNdxw5ZSLyQRRolECgYEA+ySTKrBAqI0Uwn8H +sUFH95WhWUXryeRyGyQsnWAjZGF1+d67sSY2un2W6gfZrxRgiNLWEFq9AaUs0MuH +6syF4Xwx/aZgU/gvsGtkgzuKw1bgvekT9pS/+opmHRCZyQAFEHj0IEpzyB6rW1u/ +LdlR3ShEENnmXilFv/uF/uXP5tMCgYEA63LiT0w46aGPA/E+aLRWU10c1eZ7KdhR +c3En6zfgIxgFs8J38oLdkOR0CF6T53DSuvGR/OprVKdlnUhhDxBgT1oQjK2GlhPx +JV5uMvarJDJxAwsF+7T4H2QtZ00BtEfpyp790+TlypSG1jo/BnSMmX2uEbV722lY +hzINLY49obkCgYBEpN2YyG4T4+PtuXznxRkfogVk+kiVeVx68KtFJLbnw//UGT4i +EHjbBmLOevDT+vTb0QzzkWmh3nzeYRM4aUiatjCPzP79VJPsW54whIDMHZ32KpPr +TQMgPt3kSdpO5zN7KiRIAzGcXE2n/e7GYGUQ1uWr2XMu/4byD5SzdCscQwJ/Ymii +LoKtRvk/zWYHr7uwWSeR5dVvpQ3E/XtONAImrIRd3cRqXfJUqTrTRKxDJXkCmyBc +5FkWg0t0LUkTSDiQCJqcUDA3EINFR1kwthxja72pfpwc5Be/nV9BmuuUysVD8myB +qw8A/KsXsHKn5QrRuVXOa5hvLEXbuqYw29mX6QKBgDGDzIzpR9uPtBCqzWJmc+IJ +z4m/1NFlEz0N0QNwZ/TlhyT60ytJNcmW8qkgOSTHG7RDueEIzjQ8LKJYH7kXjfcF +6AJczUG5PQo9cdJKo9JP3e1037P/58JpLcLe8xxQ4ce03zZpzhsxR2G/tz8DstJs +b8jpnLyqfGrcV2feUtIZ +-----END PRIVATE KEY----- diff --git a/Tests/NatsTests/Integration/Resources/certs/rootCA-key.pem b/Tests/NatsTests/Integration/Resources/certs/rootCA-key.pem new file mode 100644 index 0000000..142de2b --- /dev/null +++ b/Tests/NatsTests/Integration/Resources/certs/rootCA-key.pem @@ -0,0 +1,40 @@ +-----BEGIN PRIVATE KEY----- +MIIG/gIBADANBgkqhkiG9w0BAQEFAASCBugwggbkAgEAAoIBgQCyRbzelNj5Qkxg +MxYIr4yEqLtTz/jSiX1W6uUymI+e1GQzl3BpP3fS/SyB9HZv1bn4PoiWB+BggsFr +OsmVF4aXbikRz74DQ7FCqVMU/QGvlIbHkH5TShcLGzngG8DR3+Url3S8rlvYQBf2 +AXYXWcmSl1bFYzVxWSt67NzS29mvus40aAPXTpB7vq6nNs6F+7sARhDiOPRqniPq +V29khMmxndwExAEMJBVZbeTNuwfehCud8dOj0kzk5ESX+3upIwOrnoye2tRNW34W +WaxQrV65KOeGxdgIN+PeO7WL1jbCitCaGnGitTbtPGMCdf6LmRhA30WyIZ3ZkxOm +vXGkpAR6mxz3pqQWTZYieTA92s63LeVSNeYUof0tNu0SMTYWuAas0Ob3A/lu7PTC +jTag5vVU5RwkfBmcNrbNNy9NbKgQB7TafZn7sfPpZT4EpAcFMgRb4KfRHfPiaxlD +u2LKmBS9+i7x79nYAlB5IGgLyQ1cldwjDYqAAizBoigM2PI3tEMCAwEAAQKCAYBp +K5kj2r4yNrmmGx1JnH8SmBSDenL5ieEm0MbMVZKNChHfGd1YSfgfwfpq5FSm33iq +CgI8OINXjGwdHX5k9Y8ScQvLlTos5NeDUy9Pd39yHPZybz0HV/NGOxamrtjPN/4T +/HMDCP3oEs/P8sa/OdogICYxpriVmRx8lZYk00yWTmduJVr2v0OfrTuOLFgkVQDa +RXuaai1PZOIdUt3FeE0g+tcc/KD9j6AEtT9BW7BlxqWQtWS9BckVU9FftB4dBykc +EIqo4wqWAVNulV6m6zSlzG1YioN5ZYvW3T5IMsRf2s0t6hmkRU56t5Uqp1MYSzZY +biDHyAkHLPFvmnJIIbcj7oBVdE0iM8VEmlYChiPHsEP3WhQKyN+AELTx+pr3DTuR +4y7r6eAH7kJldo09xanRBE9aBDmblEBIvPCV0SKapWe41y2NGUfrDVvc8lVVXLe8 +1DVo+VOJGMXS9fSx+KOFDho7gNyQYs7K+9lhXa9nRov44+GXL4VdzAEs8msi0yEC +gcEA2Yu56/fYZ1bvkw+bxE8cafs7Q0e8vpf/GRubaCgK0kRHh8Ssf+reqN1laCOj +KgZd7b6V0UJsH62tLPzoqccWK3JHLRTUfSCsfR/uygp5oc6azpasyEzqWFLoUnkB +A4q3aABau0s9At8OJs//lAXyI1QE4bjwgOeDRPp/ynPfYVcGHDoyyd8lAyDUG7cc +f8G+o64oPxMEnIYNmLkag36k7fP0szx6kEbrp3H+5h/c7uy0JFW9PwZAHp+fgRWU +allxAoHBANHI1OadRzqbEB5+iyxXFZok/BqMHE7QyKxNMTtKqaWUS8RH9f+vPSBK +hK7I8kiTXaloLQ4VW54qjJ4NCd3eR6PGlGLecTHj4F3wu+84jncHnSaV5f1akvD2 +teC61Gmd8oeje/9G5xdVJpDEl9hfXqtTSPiy96IsXhFNr97q0sm35Alc6ZYeICXA +J0i9wdk3vtimELH/ul9daI25asFi4kA36ymawqNXk4b3/bOaBXFqVpECpV3YfXMR +sJQpYxqu8wKBwQCdtNOFotj4oWdwPwJ3H7rDgeOGdLz5lorSEtdofI7Lu7/3Rrae +zQ+5bzaSdjNUxeTV8zH8z6A+ntNKJ9YrLi5+NIwwvEcGpucklj+vrERc7r//P+/m +DQxeF0xgbWQ0wx0OgiNEX9jM+hLyRBtNnbnZrpETadTAPhVFrityAupPUJ0XXYFw +Ixpb2DKsHOTGIRgo5Jo8j3bqWawFqTr1VJwP/KjKPu/DJAa2Dsfw3+x0MJivNpDI +3akiCinBlHlRV6ECgcEApG3tsfSE6AKyV7SIEXEQlYl3sLcxWPV81NCMThTvc8EQ +wgBFaOtJ1g2Sgg0vGoOnXikxZ2CGNyrSnO9LVIPtUwlLNVN1Fc2vBvKx24dQ4yss +mhnT8wkTM5usY0ENTNtoRbh2cFh6uWccm0v8WLQn19Gn2IcuYga0lIt31hnorgNc +0Znp3KgwOmaqY/GYB1ISXG2NmHcA9c6ZLLywWHPRMtShljKfbLgwAhJO4H9Q1Nys +jWytgSk26wJqjTcDXt7RAoHAd0geRKlPbumvnHqs6e9Aq+0q63ofskEoCDDmE1nT +GTlcjLfEe3PB6KBlU/f2UGT5JwAeBMxlB0+XzS9CQnHKU2f6N4qn4nXGK0ZhXzu9 ++WqQhKzfQb3D1x0BTpRfVNtFSHvCjWjhZgXgAZDCtArVZ1dnZZPiOpnym+CrBun+ +cw4IOH2QbK6TNGpFW2Rtcs/CginusGcXUIAjQZGjrEIkkX73QjLQ5hU39CCe/PvI +4ptoUsi0eFG0TZN3kB99ERCJ +-----END PRIVATE KEY----- diff --git a/Tests/NatsTests/Integration/Resources/certs/rootCA.pem b/Tests/NatsTests/Integration/Resources/certs/rootCA.pem new file mode 100644 index 0000000..9fcb7c8 --- /dev/null +++ b/Tests/NatsTests/Integration/Resources/certs/rootCA.pem @@ -0,0 +1,28 @@ +-----BEGIN CERTIFICATE----- +MIIE2DCCA0CgAwIBAgIRAIW/i8Ryvk+oZGg+/FvDpW8wDQYJKoZIhvcNAQELBQAw +gYMxHjAcBgNVBAoTFW1rY2VydCBkZXZlbG9wbWVudCBDQTEsMCoGA1UECwwjc3Rq +ZXBhbkBsb2NhbGhvc3QgKFN0amVwYW4gR2xhdmluYSkxMzAxBgNVBAMMKm1rY2Vy +dCBzdGplcGFuQGxvY2FsaG9zdCAoU3RqZXBhbiBHbGF2aW5hKTAeFw0yMDA3MDcx +NTU4NTlaFw0zMDA3MDcxNTU4NTlaMIGDMR4wHAYDVQQKExVta2NlcnQgZGV2ZWxv +cG1lbnQgQ0ExLDAqBgNVBAsMI3N0amVwYW5AbG9jYWxob3N0IChTdGplcGFuIEds +YXZpbmEpMTMwMQYDVQQDDCpta2NlcnQgc3RqZXBhbkBsb2NhbGhvc3QgKFN0amVw +YW4gR2xhdmluYSkwggGiMA0GCSqGSIb3DQEBAQUAA4IBjwAwggGKAoIBgQCyRbze +lNj5QkxgMxYIr4yEqLtTz/jSiX1W6uUymI+e1GQzl3BpP3fS/SyB9HZv1bn4PoiW +B+BggsFrOsmVF4aXbikRz74DQ7FCqVMU/QGvlIbHkH5TShcLGzngG8DR3+Url3S8 +rlvYQBf2AXYXWcmSl1bFYzVxWSt67NzS29mvus40aAPXTpB7vq6nNs6F+7sARhDi +OPRqniPqV29khMmxndwExAEMJBVZbeTNuwfehCud8dOj0kzk5ESX+3upIwOrnoye +2tRNW34WWaxQrV65KOeGxdgIN+PeO7WL1jbCitCaGnGitTbtPGMCdf6LmRhA30Wy +IZ3ZkxOmvXGkpAR6mxz3pqQWTZYieTA92s63LeVSNeYUof0tNu0SMTYWuAas0Ob3 +A/lu7PTCjTag5vVU5RwkfBmcNrbNNy9NbKgQB7TafZn7sfPpZT4EpAcFMgRb4KfR +HfPiaxlDu2LKmBS9+i7x79nYAlB5IGgLyQ1cldwjDYqAAizBoigM2PI3tEMCAwEA +AaNFMEMwDgYDVR0PAQH/BAQDAgIEMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0O +BBYEFGg72r4gKsOkZ8rgX9wSO0trlJnyMA0GCSqGSIb3DQEBCwUAA4IBgQBzxbH6 +s7dhvyOGK5HnQmc/vPiTbOk4QhXX12PPekL+UrhMyiMzWET2wkpHJXxtes0dGNU3 +tmQwVQTvg/BhyxxGcafvQe/o/5rS9lrrpdQriZg3gqqeUoi8UX+5/ef9EB7Z32Ho +qUSOd6hPLLI+3UrnmWP+u5PRDmsHQYKc2YqyqQisjRVwLtGtCmGYfuBncP145Yyi +qNlwI6jeZTAtRSkcKy6fnyJcjOCYKFWHpTGmBTMtO4LiTGadxnmbAq9mRBiKJJp6 +wrSz1JvbVXVY4caxpbDfkaT8RiP+k1Fbd6uMWnZTJLHPTNbzCl4aXcuHgoRhCLeq +SdF3L7m0tM7lsTP3tddRY6zb+1u0II0Gu6umDsdyL6JOV4vv9Qb7xdy2jTU231+o +TXLHaypw4Amp267EyvvWmU3VOl8BeUkJ/7LOqzZfKxTECwnxWywx6NV9ONQt8mNC +ATAQAyYXklJsZkX6VLMPE0Lv4Qbt/GnGUejER09zQi433e9jUF+vwQGwj/g= +-----END CERTIFICATE----- diff --git a/Tests/NatsTests/Integration/Resources/certs/server-cert.pem b/Tests/NatsTests/Integration/Resources/certs/server-cert.pem new file mode 100644 index 0000000..4526c62 --- /dev/null +++ b/Tests/NatsTests/Integration/Resources/certs/server-cert.pem @@ -0,0 +1,26 @@ +-----BEGIN CERTIFICATE----- +MIIEbjCCAtagAwIBAgIRAI5awGA99MSpuYlAyXOE32AwDQYJKoZIhvcNAQELBQAw +gYMxHjAcBgNVBAoTFW1rY2VydCBkZXZlbG9wbWVudCBDQTEsMCoGA1UECwwjc3Rq +ZXBhbkBsb2NhbGhvc3QgKFN0amVwYW4gR2xhdmluYSkxMzAxBgNVBAMMKm1rY2Vy +dCBzdGplcGFuQGxvY2FsaG9zdCAoU3RqZXBhbiBHbGF2aW5hKTAeFw0xOTA2MDEw +MDAwMDBaFw0zMDExMDUxMjA5NDRaMGExJzAlBgNVBAoTHm1rY2VydCBkZXZlbG9w +bWVudCBjZXJ0aWZpY2F0ZTE2MDQGA1UECwwtc3RqZXBhbkBNYWNCb29rLVByby0y +LmxvY2FsIChTdGplcGFuIEdsYXZpbmEpMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A +MIIBCgKCAQEAvH8A6SClj/Vwhp3QImnI5mUg5cQKZy6/2Gz+5abTRNW4Je4RKK66 +3zfIDBdDerXBtArRjMLrou1K6XxB0/yz8fQAkvgRR+xpWap30GjMgd7qIewIex79 +yW2U3BrKLa0FZC7b5H3NK07Bwz/RVlILycZA/hJ1/ApBDJ+mA30D7jMHMopFMSvG +FHuAY6TZvdQyDdin3Yw4ciIsDHf8JUNYrMsqGjYukj49B3g0n7161lLn/Fo6s0Pv +ltqUfhwXyXdGU+2OELTvVCLlPLj8CBxTcfpENnXQYGUI1l/B+TnKlu+Iz3D/k3Nm +vmSJMGPnL37B9X3RqNnkw7TDMsmRk8WduwIDAQABo34wfDAOBgNVHQ8BAf8EBAMC +BaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwDAYDVR0TAQH/BAIwADAfBgNVHSMEGDAW +gBRoO9q+ICrDpGfK4F/cEjtLa5SZ8jAmBgNVHREEHzAdgglsb2NhbGhvc3SHEAAA +AAAAAAAAAAAAAAAAAAEwDQYJKoZIhvcNAQELBQADggGBAG6T+h8wRElEzt8NOC7A +SFUTT60RaImz6/nu1LYG0uxY5hSITUSEAUUXpeeP+o133CWrhIpNkUr/bmN5tpQX +vyCv33zJHuaRZ+icwrNu0MmSZ2QVc2ovW0lDr6rWJHrsnH4vk7YBnSN8u2J2Q5u3 +nJLX7NkU0KPoDyCpolci0wgrCEgwR1CSak0M25cch2X2TQylsoeROfsIpWLT1VKf +H9cZzasj1TzSoo02YuyHhuZP28NGFZvZZy0/aVHmwo6s2fbHVBvaAyDmjRIJI3Ep +0OVwv/FFmubygoLdI0EU40bfkxNoCpf+MR5rwu1lJFfbNuY2ojjeHWjXxj48Y6fN +wc6OztjNO9Wjj+l4XKP/tUhuCX/aXVVZP6DVB74pAlOODoEu/4XKCyJSrmRIuzfA +xPyNDb/ABXF2JMV9zbVsDmp5/QrBIovvM25pWS4oG8L9wBgKF7fbenCR3iy+rgUe +wN0bqTGWrB9k0mFtBbDDg+VbMPXo7mOcCmNYz8uJMPnrFA== +-----END CERTIFICATE----- diff --git a/Tests/NatsTests/Integration/Resources/certs/server-key.pem b/Tests/NatsTests/Integration/Resources/certs/server-key.pem new file mode 100644 index 0000000..4c60fed --- /dev/null +++ b/Tests/NatsTests/Integration/Resources/certs/server-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC8fwDpIKWP9XCG +ndAiacjmZSDlxApnLr/YbP7lptNE1bgl7hEorrrfN8gMF0N6tcG0CtGMwuui7Urp +fEHT/LPx9ACS+BFH7GlZqnfQaMyB3uoh7Ah7Hv3JbZTcGsotrQVkLtvkfc0rTsHD +P9FWUgvJxkD+EnX8CkEMn6YDfQPuMwcyikUxK8YUe4BjpNm91DIN2KfdjDhyIiwM +d/wlQ1isyyoaNi6SPj0HeDSfvXrWUuf8WjqzQ++W2pR+HBfJd0ZT7Y4QtO9UIuU8 +uPwIHFNx+kQ2ddBgZQjWX8H5OcqW74jPcP+Tc2a+ZIkwY+cvfsH1fdGo2eTDtMMy +yZGTxZ27AgMBAAECggEAXJZJnTlC+Y5Ggmj79htd6gVcfl+n+HzXEPigz6788T/F +HyRr2z7QXZppsb6vj5O9nLD/sxN/aN0DweId94GV5c/DhG1DF8ABE2EPTxha86PJ +/3WPyOI1KH6h8udZzcvB7S6zJe3BHHen5z7ulWbhkW/HNsVcnLtwrkGw6t+6UYJ3 +a1yceXFAKFlv8f9M8g/PaP0zKyuprKtGupdTUhoekZ4M87jjsKQQ33oNdF5GBl56 +nIEAXK+rrR3ep2xeFtQ2/tdzAGfwB0NfrX4q7BEjPwpnt7lzLx/pIY4+ZSZc4GsZ +foxzTsAZ+5SYkhu2m5hLGUPrD4FIDedv2UEEMv74oQKBgQD6bDSuNqzRZNQC/8Yk +6BLpiMTJVqHQi8etGsFy/608ahRz6BHdmtf3Jz63OYdxOL3nSa4Eh0lKzCyU9Ryw +qvQIGdMnT6Bqpp7gNI596jzlLDhw1XD16XS+lFCvJ4DFjn9ZqxGo7dw62RA3LV/L +C75zdrUqyO5XlfxmKghEC2YnawKBgQDAsbibqYBl/TPfn9J3x87m97t90j5OatG5 +nHAFdRmp7+QfxH5cCkHESPfNbiJL1tIDxIuNNvon3ffa7WPpKlf3fybE0WQq2jKi +R0VD+U56OUZA0hpHOhG2aKZIix02oniVLQZ0Qq59jAiiiJWkvu4FLSirC+EGnL/h +ah54nIoG8QKBgQCMNv/8N8Ll75XCJCJ20badKiY9MZOi6FEiPKPqVvxRonfXOi6e +rS+VRFUaVEzg+UtjcF7OTE2eYtnngaLRzLacvpD7JtuEO80jbmoGWJxGGU905h28 +oz3p47OVjwHMG/B0bZOSybQRAy7QJkjHsMivb90amqzRP7q2HXzJVLSbBwKBgBXv +5alrCaASzGYIBuj2CVsIFwNC/S7mQEwWQDaO10YedmUbdJs727Lh77wmbqcdpLkj +FhQUjzQctAvrfLVdybf2dM5xXCr4vkz1OjB74HBPtuzIPo+fT8bpcQzPMZs3seyh +vJtdwAmw+IawcADab7SNKJUYfBzJmZqq/x8SCzCxAoGARA5w5I7xVJpseMmBit7y +GsID7iFQoYCScQI3vYpDyS52hYK5F0NetUVqd5Ph3/uwHK5cCPK6k2n38zrXUxE3 +Y406bA1cwl6+M0npkJnBt3c8IV50fJwoYtUP5Yr/JogSQJadIQhZg2cch3yiZbc9 +DW01ooYHlL6XeSk5nn1Jc7s= +-----END PRIVATE KEY----- diff --git a/Tests/NatsTests/Integration/Resources/creds.conf b/Tests/NatsTests/Integration/Resources/creds.conf new file mode 100644 index 0000000..db158eb --- /dev/null +++ b/Tests/NatsTests/Integration/Resources/creds.conf @@ -0,0 +1,5 @@ +authorization { + user: derek + password: s3cr3t +} + diff --git a/Tests/NatsTests/Integration/Resources/jwt.conf b/Tests/NatsTests/Integration/Resources/jwt.conf new file mode 100644 index 0000000..468aadd --- /dev/null +++ b/Tests/NatsTests/Integration/Resources/jwt.conf @@ -0,0 +1,15 @@ +// Operator "TestOperator" +operator: eyJ0eXAiOiJKV1QiLCJhbGciOiJlZDI1NTE5LW5rZXkifQ.eyJqdGkiOiJVWlhRWjVXR1VGSUY0S0ZKS1dFRUhSTU5PM0RQVUdZMzUyM1QyNk1IQU1KTENDVzVZVjdRIiwiaWF0IjoxNjUxNzkwOTcwLCJpc3MiOiJPQjVYQ0U0NVdFVkYyRjVWWUZOM1Q3NEpJRVpFT1JaVzYzSEJFSVdVWEpHWU9HS0VBSVVLM0FMTSIsIm5hbWUiOiJUZXN0T3BlcmF0b3IiLCJzdWIiOiJPQjVYQ0U0NVdFVkYyRjVWWUZOM1Q3NEpJRVpFT1JaVzYzSEJFSVdVWEpHWU9HS0VBSVVLM0FMTSIsIm5hdHMiOnsic3lzdGVtX2FjY291bnQiOiJBQlJFSk5ZQVNXR1MyQTVFVlhQVlhHR0NPSzJMSlhUN0taTllCQlpBWFVUVUJJMlZTTUFWN0RITiIsInR5cGUiOiJvcGVyYXRvciIsInZlcnNpb24iOjJ9fQ.RN7AkgTATcx9E_ykTQHI0wM3OE8BwKPb3aPj7ojLGiNpjIRP-ehvSiUUkfWPh6rcO709TKspfQgTxcRxLoq5Bg + +// system_account: ADTQS7ZCFVJNW5726GOYXW5TSCZFNIQSHK2ZGYUBCD5D77OTNLOOKZOZ + +resolver: MEMORY + +resolver_preload: { + // Account "SYS" + ABREJNYASWGS2A5EVXPVXGGCOK2LJXT7KZNYBBZAXUTUBI2VSMAV7DHN: eyJ0eXAiOiJKV1QiLCJhbGciOiJlZDI1NTE5LW5rZXkifQ.eyJqdGkiOiI1RDMzSVBJRFJCTUg1VTdWTFlOMlk0VEpTV09aUzJaSTNPMlBMR1dORDdWN0MyQjJDNjZRIiwiaWF0IjoxNjUxNzkwOTcwLCJpc3MiOiJPQjVYQ0U0NVdFVkYyRjVWWUZOM1Q3NEpJRVpFT1JaVzYzSEJFSVdVWEpHWU9HS0VBSVVLM0FMTSIsIm5hbWUiOiJTWVMiLCJzdWIiOiJBQlJFSk5ZQVNXR1MyQTVFVlhQVlhHR0NPSzJMSlhUN0taTllCQlpBWFVUVUJJMlZTTUFWN0RITiIsIm5hdHMiOnsiZXhwb3J0cyI6W3sibmFtZSI6ImFjY291bnQtbW9uaXRvcmluZy1zdHJlYW1zIiwic3ViamVjdCI6IiRTWVMuQUNDT1VOVC4qLlx1MDAzZSIsInR5cGUiOiJzdHJlYW0iLCJhY2NvdW50X3Rva2VuX3Bvc2l0aW9uIjozLCJkZXNjcmlwdGlvbiI6IkFjY291bnQgc3BlY2lmaWMgbW9uaXRvcmluZyBzdHJlYW0iLCJpbmZvX3VybCI6Imh0dHBzOi8vZG9jcy5uYXRzLmlvL25hdHMtc2VydmVyL2NvbmZpZ3VyYXRpb24vc3lzX2FjY291bnRzIn0seyJuYW1lIjoiYWNjb3VudC1tb25pdG9yaW5nLXNlcnZpY2VzIiwic3ViamVjdCI6IiRTWVMuUkVRLkFDQ09VTlQuKi4qIiwidHlwZSI6InNlcnZpY2UiLCJyZXNwb25zZV90eXBlIjoiU3RyZWFtIiwiYWNjb3VudF90b2tlbl9wb3NpdGlvbiI6NCwiZGVzY3JpcHRpb24iOiJSZXF1ZXN0IGFjY291bnQgc3BlY2lmaWMgbW9uaXRvcmluZyBzZXJ2aWNlcyBmb3I6IFNVQlNaLCBDT05OWiwgTEVBRlosIEpTWiBhbmQgSU5GTyIsImluZm9fdXJsIjoiaHR0cHM6Ly9kb2NzLm5hdHMuaW8vbmF0cy1zZXJ2ZXIvY29uZmlndXJhdGlvbi9zeXNfYWNjb3VudHMifV0sImxpbWl0cyI6eyJzdWJzIjotMSwiZGF0YSI6LTEsInBheWxvYWQiOi0xLCJpbXBvcnRzIjotMSwiZXhwb3J0cyI6LTEsIndpbGRjYXJkcyI6dHJ1ZSwiY29ubiI6LTEsImxlYWYiOi0xfSwic2lnbmluZ19rZXlzIjpbIkFDUEpPQ0xZTTJHQzU0Nk1EMzRGVzVPMkRVQTYzTERIV0ZHU0JJSVpTSUZJQjJUWlVGREQ0TlBVIl0sImRlZmF1bHRfcGVybWlzc2lvbnMiOnsicHViIjp7fSwic3ViIjp7fX0sInR5cGUiOiJhY2NvdW50IiwidmVyc2lvbiI6Mn19.I_ybyL7Gm9gTH1IVrulNB596y-YmdYQ9QoyGEez3SviPJNFFD1vkmtl2wpzesUB1zaVYVyAhhN_jsEWElmUnBQ + + // Account "TestAccount" + ADTQS7ZCFVJNW5726GOYXW5TSCZFNIQSHK2ZGYUBCD5D77OTNLOOKZOZ: eyJ0eXAiOiJKV1QiLCJhbGciOiJlZDI1NTE5LW5rZXkifQ.eyJqdGkiOiJSWDRVUzZWUURCTFNCSVNaRjRCWFBJREUyMlZRREJWWUsyTkYzUUNBRTdZWkE2NjNOSVBBIiwiaWF0IjoxNjUxNzkwOTc3LCJpc3MiOiJPQjVYQ0U0NVdFVkYyRjVWWUZOM1Q3NEpJRVpFT1JaVzYzSEJFSVdVWEpHWU9HS0VBSVVLM0FMTSIsIm5hbWUiOiJUZXN0QWNjb3VudCIsInN1YiI6IkFEVFFTN1pDRlZKTlc1NzI2R09ZWFc1VFNDWkZOSVFTSEsyWkdZVUJDRDVENzdPVE5MT09LWk9aIiwibmF0cyI6eyJsaW1pdHMiOnsic3VicyI6LTEsImRhdGEiOi0xLCJwYXlsb2FkIjotMSwiaW1wb3J0cyI6LTEsImV4cG9ydHMiOi0xLCJ3aWxkY2FyZHMiOnRydWUsImNvbm4iOi0xLCJsZWFmIjotMX0sImRlZmF1bHRfcGVybWlzc2lvbnMiOnsicHViIjp7fSwic3ViIjp7fX0sInR5cGUiOiJhY2NvdW50IiwidmVyc2lvbiI6Mn19.R_SRlgJhdLFFmG0E_dScqrrKsCmVzTitB8-3HfKbo6gbcqu647O7SPGixH5BXHVZpOaOZJ0gzN36OebU5E5LAw + +} diff --git a/Tests/NatsTests/Integration/Resources/nkey b/Tests/NatsTests/Integration/Resources/nkey new file mode 100644 index 0000000..543aecc --- /dev/null +++ b/Tests/NatsTests/Integration/Resources/nkey @@ -0,0 +1 @@ +SUACH75SWCM5D2JMJM6EKLR2WDARVGZT4QC6LX3AGHSWOMVAKERABBBRWM diff --git a/Tests/NatsTests/Integration/Resources/nkey.conf b/Tests/NatsTests/Integration/Resources/nkey.conf new file mode 100644 index 0000000..f142369 --- /dev/null +++ b/Tests/NatsTests/Integration/Resources/nkey.conf @@ -0,0 +1,5 @@ +authorization: { + users: [ + { nkey: UAFHG6FUD2UU4SDFVAFUL5LDFO2XM4WYM76UNXTPJYJK7EEMYRBHT2VE } + ] +} diff --git a/Tests/NatsTests/Integration/Resources/permissions.conf b/Tests/NatsTests/Integration/Resources/permissions.conf new file mode 100644 index 0000000..c2723ee --- /dev/null +++ b/Tests/NatsTests/Integration/Resources/permissions.conf @@ -0,0 +1,15 @@ +no_auth_user: pp + +accounts { + ACC { + users: [ + { + user: pp, + password: foo + permissions: { + subscribe: { %@: ["%@"] } + } + } + ] + } +} diff --git a/Tests/NatsTests/Integration/Resources/tls.conf b/Tests/NatsTests/Integration/Resources/tls.conf new file mode 100644 index 0000000..ad9fd44 --- /dev/null +++ b/Tests/NatsTests/Integration/Resources/tls.conf @@ -0,0 +1,10 @@ +# this needs to be here for testing localhost tls. +listen: localhost:4222 + +tls { + cert_file: "%@" + key_file: "%@" + ca_file: "%@" + verify : true + timeout: 2 +} diff --git a/Tests/NatsTests/Integration/Resources/tls_first.conf b/Tests/NatsTests/Integration/Resources/tls_first.conf new file mode 100644 index 0000000..6b269f2 --- /dev/null +++ b/Tests/NatsTests/Integration/Resources/tls_first.conf @@ -0,0 +1,11 @@ +# this needs to be here for testing localhost tls. +listen: localhost:4222 + +tls { + cert_file: "%@" + key_file: "%@" + ca_file: "%@" + verify : true + timeout: 2 + handshake_first: true +} diff --git a/Tests/NatsTests/Integration/Resources/tls_first_auto.conf b/Tests/NatsTests/Integration/Resources/tls_first_auto.conf new file mode 100644 index 0000000..467211f --- /dev/null +++ b/Tests/NatsTests/Integration/Resources/tls_first_auto.conf @@ -0,0 +1,11 @@ +# this needs to be here for testing localhost tls. +listen: localhost:4545 + +tls { + cert_file: "./Tests/NatsTests/Integration/Resources/certs/server-cert.pem" + key_file: "./Tests/NatsTests/Integration/Resources/certs/server-key.pem" + ca_file: "./Tests/NatsTests/Integration/Resources/certs/rootCA.pem" + verify : true + timeout: 2 + handshake_first: 300ms +} diff --git a/Tests/NatsTests/Integration/Resources/token.conf b/Tests/NatsTests/Integration/Resources/token.conf new file mode 100644 index 0000000..8257cb9 --- /dev/null +++ b/Tests/NatsTests/Integration/Resources/token.conf @@ -0,0 +1,4 @@ +authorization { + token: s3cr3t +} + diff --git a/Tests/NatsTests/Integration/Resources/ws.conf b/Tests/NatsTests/Integration/Resources/ws.conf new file mode 100644 index 0000000..c23c658 --- /dev/null +++ b/Tests/NatsTests/Integration/Resources/ws.conf @@ -0,0 +1,4 @@ +websocket { + port: -1 + no_tls: true +} diff --git a/Tests/NatsTests/Integration/Resources/wss.conf b/Tests/NatsTests/Integration/Resources/wss.conf new file mode 100644 index 0000000..a44bb5f --- /dev/null +++ b/Tests/NatsTests/Integration/Resources/wss.conf @@ -0,0 +1,10 @@ +websocket { + port: -1 + tls { + cert_file: "%@" + key_file: "%@" + ca_file: "%@" + verify : true + timeout: 2 + } +} diff --git a/Tests/NatsTests/Unit/ErrorsTests.swift b/Tests/NatsTests/Unit/ErrorsTests.swift new file mode 100644 index 0000000..a6a0772 --- /dev/null +++ b/Tests/NatsTests/Unit/ErrorsTests.swift @@ -0,0 +1,42 @@ +// Copyright 2024 The NATS 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 +// +// http://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. + +import XCTest + +@testable import Nats + +class ErrorsTests: XCTestCase { + + static var allTests = [ + ("testServerErrorPermissionsDenied", testServerErrorPermissionsDenied) + ] + + func testServerErrorPermissionsDenied() { + var err = NatsError.ServerError( + "Permissions Violation for Subscription to \"events.A.B.*\"]") + XCTAssertEqual( + err, NatsError.ServerError.permissionsViolation(.subscribe, "events.A.B.*", nil)) + + err = NatsError.ServerError("Permissions Violation for Publish to \"events.A.B.*\"") + XCTAssertEqual( + err, NatsError.ServerError.permissionsViolation(.publish, "events.A.B.*", nil)) + + err = NatsError.ServerError( + "Permissions Violation for Publish to \"events.A.B.*\" using queue \"q\"") + XCTAssertEqual( + err, NatsError.ServerError.permissionsViolation(.publish, "events.A.B.*", "q")) + + err = NatsError.ServerError("Some other error") + XCTAssertEqual(err, NatsError.ServerError.proto("Some other error")) + } +} diff --git a/Tests/NatsTests/Unit/HeadersTests.swift b/Tests/NatsTests/Unit/HeadersTests.swift new file mode 100644 index 0000000..76e5e9f --- /dev/null +++ b/Tests/NatsTests/Unit/HeadersTests.swift @@ -0,0 +1,97 @@ +// Copyright 2024 The NATS 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 +// +// http://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. + +import XCTest + +@testable import Nats + +class HeadersTests: XCTestCase { + + static var allTests = [ + ("testAppend", testAppend), + ("testSubscript", testSubscript), + ("testInsert", testInsert), + ("testSerialize", testSerialize), + ("testValidNatsHeaderName", testValidNatsHeaderName), + ("testDollarNatsHeaderName", testDollarNatsHeaderName), + ("testInvalidNatsHeaderName", testInvalidNatsHeaderName), + ( + "testInvalidNatsHeaderNameWithSpecialCharacters", + testInvalidNatsHeaderNameWithSpecialCharacters + ), + + ] + + func testAppend() { + var hm = NatsHeaderMap() + hm.append(try! NatsHeaderName("foo"), NatsHeaderValue("bar")) + hm.append(try! NatsHeaderName("foo"), NatsHeaderValue("baz")) + XCTAssertEqual( + hm.getAll(try! NatsHeaderName("foo")), [NatsHeaderValue("bar"), NatsHeaderValue("baz")]) + } + + func testInsert() { + var hm = NatsHeaderMap() + hm.insert(try! NatsHeaderName("foo"), NatsHeaderValue("bar")) + XCTAssertEqual(hm.getAll(try! NatsHeaderName("foo")), [NatsHeaderValue("bar")]) + } + + func testSerialize() { + var hm = NatsHeaderMap() + hm.append(try! NatsHeaderName("foo"), NatsHeaderValue("bar")) + hm.append(try! NatsHeaderName("foo"), NatsHeaderValue("baz")) + hm.insert(try! NatsHeaderName("bar"), NatsHeaderValue("foo")) + + let expected = [ + "NATS/1.0\r\nfoo:bar\r\nfoo:baz\r\nbar:foo\r\n\r\n", + "NATS/1.0\r\nbar:foo\r\nfoo:bar\r\nfoo:baz\r\n\r\n", + ] + + XCTAssertTrue(expected.contains(String(bytes: hm.toBytes(), encoding: .utf8)!)) + } + + func testValidNatsHeaderName() { + XCTAssertNoThrow(try NatsHeaderName("X-Custom-Header")) + } + + func testDollarNatsHeaderName() { + XCTAssertNoThrow(try NatsHeaderName("$Dollar")) + } + + func testInvalidNatsHeaderName() { + XCTAssertThrowsError(try NatsHeaderName("Invalid Header Name")) + } + + func testInvalidNatsHeaderNameWithSpecialCharacters() { + XCTAssertThrowsError(try NatsHeaderName("Invalid:Header:Name")) + } + + func testSubscript() { + var hm = NatsHeaderMap() + + // Test setting a value + hm[try! NatsHeaderName("foo")] = NatsHeaderValue("bar") + XCTAssertEqual(hm[try! NatsHeaderName("foo")], NatsHeaderValue("bar")) + + // Test updating existing value + hm[try! NatsHeaderName("foo")] = NatsHeaderValue("baz") + XCTAssertEqual(hm[try! NatsHeaderName("foo")], NatsHeaderValue("baz")) + + // Test retrieving non-existing value (should be nil or default) + XCTAssertNil(hm[try! NatsHeaderName("non-existing")]) + + // Test removal of a value + hm[try! NatsHeaderName("foo")] = nil + XCTAssertNil(hm[try! NatsHeaderName("foo")]) + } +} diff --git a/Tests/NatsTests/Unit/JwtTests.swift b/Tests/NatsTests/Unit/JwtTests.swift new file mode 100644 index 0000000..a2f5ffa --- /dev/null +++ b/Tests/NatsTests/Unit/JwtTests.swift @@ -0,0 +1,43 @@ +// Copyright 2024 The NATS 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 +// +// http://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. + +import Foundation +import XCTest + +@testable import Nats + +class JwtTests: XCTestCase { + + static var allTests = [ + ("testParseCredentialsFile", testParseCredentialsFile) + ] + + func testParseCredentialsFile() async throws { + logger.logLevel = .critical + let currentFile = URL(fileURLWithPath: #file) + let testDir = currentFile.deletingLastPathComponent().deletingLastPathComponent() + let resourceURL = testDir.appendingPathComponent("Integration/Resources/TestUser.creds") + let credsData = try await URLSession.shared.data(from: resourceURL).0 + + let nkey = String(data: JwtUtils.parseDecoratedNKey(contents: credsData)!, encoding: .utf8) + let expectedNkey = "SUACH75SWCM5D2JMJM6EKLR2WDARVGZT4QC6LX3AGHSWOMVAKERABBBRWM" + + XCTAssertEqual(nkey, expectedNkey) + + let jwt = String(data: JwtUtils.parseDecoratedJWT(contents: credsData)!, encoding: .utf8) + let expectedJWT = + "eyJ0eXAiOiJKV1QiLCJhbGciOiJlZDI1NTE5LW5rZXkifQ.eyJqdGkiOiJMN1dBT1hJU0tPSUZNM1QyNEhMQ09ENzJRT1czQkNVWEdETjRKVU1SSUtHTlQ3RzdZVFRRIiwiaWF0IjoxNjUxNzkwOTgyLCJpc3MiOiJBRFRRUzdaQ0ZWSk5XNTcyNkdPWVhXNVRTQ1pGTklRU0hLMlpHWVVCQ0Q1RDc3T1ROTE9PS1pPWiIsIm5hbWUiOiJUZXN0VXNlciIsInN1YiI6IlVBRkhHNkZVRDJVVTRTREZWQUZVTDVMREZPMlhNNFdZTTc2VU5YVFBKWUpLN0VFTVlSQkhUMlZFIiwibmF0cyI6eyJwdWIiOnt9LCJzdWIiOnt9LCJzdWJzIjotMSwiZGF0YSI6LTEsInBheWxvYWQiOi0xLCJ0eXBlIjoidXNlciIsInZlcnNpb24iOjJ9fQ.bp2-Jsy33l4ayF7Ku1MNdJby4WiMKUrG-rSVYGBusAtV3xP4EdCa-zhSNUaBVIL3uYPPCQYCEoM1pCUdOnoJBg" + + XCTAssertEqual(jwt, expectedJWT) + } +} diff --git a/Tests/NatsTests/Unit/NatsClientOptionsTests.swift b/Tests/NatsTests/Unit/NatsClientOptionsTests.swift new file mode 100644 index 0000000..e61f2e8 --- /dev/null +++ b/Tests/NatsTests/Unit/NatsClientOptionsTests.swift @@ -0,0 +1,39 @@ +// Copyright 2024 The NATS 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 +// +// http://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. + +import XCTest + +@testable import Nats + +class NatsClientOptionsTests: XCTestCase { + static var allTests = [ + ("testDefaultInboxPrefix", testDefaultInboxPrefix), + ("testCustomInboxPrefix", testCustomInboxPrefix), + ] + + func testDefaultInboxPrefix() { + let client = NatsClientOptions().build() + let inbox = client.newInbox() + XCTAssertTrue(inbox.hasPrefix("_INBOX."), "Default inbox prefix should be '_INBOX.'") + XCTAssertEqual(inbox.count, "_INBOX.".count + 22, "Inbox should have prefix plus NUID") + } + + func testCustomInboxPrefix() { + let customPrefix = "_INBOX_abc123." + let client = NatsClientOptions().inboxPrefix(customPrefix).build() + let inbox = client.newInbox() + XCTAssertTrue(inbox.hasPrefix(customPrefix), "Inbox should use custom prefix") + XCTAssertEqual( + inbox.count, customPrefix.count + 22, "Inbox should have custom prefix plus NUID") + } +} diff --git a/Tests/NatsTests/Unit/ParserTests.swift b/Tests/NatsTests/Unit/ParserTests.swift new file mode 100644 index 0000000..f8da06b --- /dev/null +++ b/Tests/NatsTests/Unit/ParserTests.swift @@ -0,0 +1,253 @@ +// Copyright 2024 The NATS 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 +// +// http://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. + +import XCTest + +@testable import Nats + +class ParserTests: XCTestCase { + + static var allTests = [ + ("testParseOutMessages", testParseOutMessages) + ] + + override func setUp() { + super.setUp() + } + + override func tearDown() { + super.tearDown() + } + + func testParseOutMessages() { + struct TestCase { + let name: String + let givenChunks: [String] + let expectedOps: [ServerOp] + } + + let fail: ((Int, String) -> String) = { index, name in + return "Test case: \(index)\n Input: \(name)" + } + var hm = NatsHeaderMap() + hm.append(try! NatsHeaderName("h1"), NatsHeaderValue("X")) + hm.append(try! NatsHeaderName("h1"), NatsHeaderValue("Y")) + hm.append(try! NatsHeaderName("h2"), NatsHeaderValue("Z")) + + let testCases = [ + TestCase( + name: "Single chunk, different operations", + givenChunks: ["MSG foo 1 5\r\nhello\r\n+OK\r\nPONG\r\n"], + expectedOps: [ + .message( + MessageInbound( + subject: "foo", sid: 1, payload: "hello".data(using: .utf8)!, length: 5) + ), + .ok, + .pong, + ] + ), + TestCase( + name: "Chunked messages", + givenChunks: [ + "MSG foo 1 5\r\nhello\r\nMSG foo 1 5\r\nwo", + "rld\r\nMSG f", + "oo 1 5\r\nhello\r\nMSG foo 1 5\r\nworld", + "\r\nMSG foo 1 5\r\nhello\r", + "\nMSG foo 1 5\r\nworld\r\nMSG foo 1 5\r\n", + "hello\r\n", + ], + expectedOps: [ + .message( + MessageInbound( + subject: "foo", sid: 1, payload: "hello".data(using: .utf8)!, length: 5) + ), + .message( + MessageInbound( + subject: "foo", sid: 1, payload: "world".data(using: .utf8)!, length: 5) + ), + .message( + MessageInbound( + subject: "foo", sid: 1, payload: "hello".data(using: .utf8)!, length: 5) + ), + .message( + MessageInbound( + subject: "foo", sid: 1, payload: "world".data(using: .utf8)!, length: 5) + ), + .message( + MessageInbound( + subject: "foo", sid: 1, payload: "hello".data(using: .utf8)!, length: 5) + ), + .message( + MessageInbound( + subject: "foo", sid: 1, payload: "world".data(using: .utf8)!, length: 5) + ), + .message( + MessageInbound( + subject: "foo", sid: 1, payload: "hello".data(using: .utf8)!, length: 5) + ), + ] + ), + TestCase( + name: "Message with headers only", + givenChunks: [ + "HMSG foo 1 30 30\r\nNATS/1.0\r\nh1:X\r\nh1:Y\r\nh2:Z\r\n\r\n\r\n" + ], + expectedOps: [ + .hMessage( + HMessageInbound( + subject: "foo", sid: 1, payload: nil, headers: hm, headersLength: 30, + length: 30) + ) + ] + ), + TestCase( + name: "Message with headers and payload", + givenChunks: [ + "HMSG foo 1 30 35\r\nNATS/1.0\r\nh1:X\r\nh1:Y\r\nh2:Z\r\n\r\nhello\r\n" + ], + expectedOps: [ + .hMessage( + HMessageInbound( + subject: "foo", sid: 1, payload: "hello".data(using: .utf8)!, + headers: hm, headersLength: 30, length: 35) + ) + ] + ), + TestCase( + name: "Message with status and no other headers", + givenChunks: [ + "HMSG foo 1 30 30\r\nNATS/1.0 503 no responders\r\n\r\n\r\n" + ], + expectedOps: [ + .hMessage( + HMessageInbound( + subject: "foo", sid: 1, payload: nil, headers: NatsHeaderMap(), + headersLength: 30, length: 30, status: StatusCode.noResponders, + description: "no responders" + ) + ) + ] + ), + TestCase( + name: "Message with status, headers and payload", + givenChunks: [ + "HMSG foo 1 48 53\r\nNATS/1.0 503 no responders\r\nh1:X\r\nh1:Y\r\nh2:Z\r\n\r\nhello\r\n" + ], + expectedOps: [ + .hMessage( + HMessageInbound( + subject: "foo", sid: 1, payload: "hello".data(using: .utf8)!, + headers: hm, headersLength: 48, length: 53, + status: StatusCode.noResponders, + description: "no responders") + ) + ] + ), + TestCase( + name: "With empty lines", + givenChunks: [ + "MSG foo 1 5\r\nhello\r\nMSG foo 1 5\r\nwo", + "", + "", + "rld\r\nMSG f", + "oo 1 5\r\nhello\r\n", + ], + expectedOps: [ + .message( + MessageInbound( + subject: "foo", sid: 1, payload: "hello".data(using: .utf8)!, length: 5) + ), + .message( + MessageInbound( + subject: "foo", sid: 1, payload: "world".data(using: .utf8)!, length: 5) + ), + .message( + MessageInbound( + subject: "foo", sid: 1, payload: "hello".data(using: .utf8)!, length: 5) + ), + ] + ), + TestCase( + name: "With crlf in payload", + givenChunks: [ + "MSG foo 1 7\r\nhe\r\nllo\r\n" + ], + expectedOps: [ + .message( + MessageInbound( + subject: "foo", sid: 1, payload: Data("he\r\nllo".utf8), length: 7)) + ] + ), + ] + + for (tn, tc) in testCases.enumerated() { + logger.logLevel = .critical + var ops = [ServerOp]() + var prevRemainder: Data? + for chunk in tc.givenChunks { + var chunkData = Data(chunk.utf8) + if let prevRemainder { + chunkData.prepend(prevRemainder) + } + let res = try! chunkData.parseOutMessages() + prevRemainder = res.remainder + ops.append(contentsOf: res.ops) + } + XCTAssertEqual(ops.count, tc.expectedOps.count) + for (i, op) in ops.enumerated() { + switch op { + case .ok: + if case .ok = tc.expectedOps[i] { + } else { + XCTFail(fail(tn, tc.name)) + } + case .info(let info): + if case .info(let expectedInfo) = tc.expectedOps[i] { + XCTAssertEqual(info, expectedInfo, fail(tn, tc.name)) + } else { + XCTFail(fail(tn, tc.name)) + } + + case .ping: + if case .ping = tc.expectedOps[i] { + } else { + XCTFail(fail(tn, tc.name)) + } + case .pong: + if case .pong = tc.expectedOps[i] { + } else { + XCTFail(fail(tn, tc.name)) + } + case .error(_): + if case .error(_) = tc.expectedOps[i] { + } else { + XCTFail(fail(tn, tc.name)) + } + case .message(let msg): + if case .message(let expectedMessage) = tc.expectedOps[i] { + XCTAssertEqual(msg, expectedMessage, fail(tn, tc.name)) + } else { + XCTFail(fail(tn, tc.name)) + } + case .hMessage(let msg): + if case .hMessage(let expectedMessage) = tc.expectedOps[i] { + XCTAssertEqual(msg, expectedMessage, fail(tn, tc.name)) + } else { + XCTFail(fail(tn, tc.name)) + } + } + } + } + } +}