From d7bdb4f37887bdf44e75abe7a0654666c2a1565b Mon Sep 17 00:00:00 2001 From: wenzuhuai Date: Mon, 12 Jan 2026 18:29:52 +0800 Subject: [PATCH] init --- .dockerignore | 5 + .github/ISSUE_TEMPLATE/defect.yml | 47 + .github/ISSUE_TEMPLATE/proposal.yml | 28 + .github/workflows/ci.yml | 40 + .gitignore | 72 + .swift-format | 61 + CODE-OF-CONDUCT.md | 3 + CONTRIBUTING.md | 38 + LICENSE | 201 +++ Package.swift | 68 + README.md | 198 +++ Resources/Logo.afdesign | Bin 0 -> 36553 bytes Resources/Logo@256.png | Bin 0 -> 21326 bytes Sources/Benchmark/main.swift | 40 + Sources/BenchmarkPubSub/main.swift | 82 ++ Sources/BenchmarkSub/main.swift | 60 + Sources/Example/main.swift | 76 + Sources/JetStream/Consumer+Pull.swift | 220 +++ Sources/JetStream/Consumer.swift | 379 +++++ .../JetStream/JetStreamContext+Consumer.swift | 322 +++++ .../JetStream/JetStreamContext+Stream.swift | 291 ++++ Sources/JetStream/JetStreamContext.swift | 234 +++ Sources/JetStream/JetStreamError.swift | 863 +++++++++++ Sources/JetStream/JetStreamMessage.swift | 193 +++ Sources/JetStream/NanoTimeInterval.swift | 39 + Sources/JetStream/Stream+Consumer.swift | 118 ++ Sources/JetStream/Stream.swift | 1031 +++++++++++++ Sources/Nats/BatchBuffer.swift | 128 ++ Sources/Nats/ConcurrentQueue.swift | 32 + .../Nats/Extensions/ByteBuffer+Writer.swift | 87 ++ Sources/Nats/Extensions/Data+Base64.swift | 24 + Sources/Nats/Extensions/Data+Parser.swift | 184 +++ Sources/Nats/Extensions/Data+String.swift | 24 + .../Nats/Extensions/String+Utilities.swift | 38 + Sources/Nats/HTTPUpgradeRequestHandler.swift | 182 +++ .../Nats/NatsClient/NatsClient+Events.swift | 57 + Sources/Nats/NatsClient/NatsClient.swift | 352 +++++ .../Nats/NatsClient/NatsClientOptions.swift | 202 +++ Sources/Nats/NatsConnection.swift | 1098 ++++++++++++++ Sources/Nats/NatsError.swift | 244 ++++ Sources/Nats/NatsHeaders.swift | 159 ++ Sources/Nats/NatsJwtUtils.swift | 69 + Sources/Nats/NatsMessage.swift | 60 + Sources/Nats/NatsProto.swift | 341 +++++ Sources/Nats/NatsSubscription.swift | 185 +++ Sources/Nats/RttCommand.swift | 39 + Sources/NatsServer/NatsServer.swift | 206 +++ Sources/bench/main.swift | 65 + .../Integration/ConsumerTests.swift | 418 ++++++ .../Integration/JetStreamTests.swift | 1033 +++++++++++++ .../Integration/RequestTests.swift | 69 + .../Integration/Resources/domain.conf | 3 + .../Integration/Resources/jetstream.conf | 25 + .../Integration/Resources/prefix.conf | 17 + Tests/JetStreamTests/Unit/MessageTests.swift | 119 ++ .../Integration/ConnectionTests.swift | 1286 +++++++++++++++++ Tests/NatsTests/Integration/EventsTests.swift | 103 ++ .../Integration/MessageWithHeadersTests.swift | 55 + .../Integration/Resources/TestUser.creds | 13 + .../Resources/certs/client-all.pem | 86 ++ .../Resources/certs/client-cert-invalid.pem | 28 + .../Resources/certs/client-cert.pem | 27 + .../Resources/certs/client-key-invalid.pem | 28 + .../Resources/certs/client-key.pem | 28 + .../Integration/Resources/certs/ip-ca.pem | 27 + .../Integration/Resources/certs/ip-cert.pem | 99 ++ .../Integration/Resources/certs/ip-key.pem | 28 + .../Resources/certs/rootCA-key.pem | 40 + .../Integration/Resources/certs/rootCA.pem | 28 + .../Resources/certs/server-cert.pem | 26 + .../Resources/certs/server-key.pem | 28 + .../Integration/Resources/creds.conf | 5 + .../NatsTests/Integration/Resources/jwt.conf | 15 + Tests/NatsTests/Integration/Resources/nkey | 1 + .../NatsTests/Integration/Resources/nkey.conf | 5 + .../Integration/Resources/permissions.conf | 15 + .../NatsTests/Integration/Resources/tls.conf | 10 + .../Integration/Resources/tls_first.conf | 11 + .../Integration/Resources/tls_first_auto.conf | 11 + .../Integration/Resources/token.conf | 4 + Tests/NatsTests/Integration/Resources/ws.conf | 4 + .../NatsTests/Integration/Resources/wss.conf | 10 + Tests/NatsTests/Unit/ErrorsTests.swift | 42 + Tests/NatsTests/Unit/HeadersTests.swift | 97 ++ Tests/NatsTests/Unit/JwtTests.swift | 43 + .../Unit/NatsClientOptionsTests.swift | 39 + Tests/NatsTests/Unit/ParserTests.swift | 253 ++++ 87 files changed, 12664 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/ISSUE_TEMPLATE/defect.yml create mode 100644 .github/ISSUE_TEMPLATE/proposal.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .swift-format create mode 100644 CODE-OF-CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 Package.swift create mode 100644 README.md create mode 100644 Resources/Logo.afdesign create mode 100644 Resources/Logo@256.png create mode 100644 Sources/Benchmark/main.swift create mode 100644 Sources/BenchmarkPubSub/main.swift create mode 100644 Sources/BenchmarkSub/main.swift create mode 100755 Sources/Example/main.swift create mode 100644 Sources/JetStream/Consumer+Pull.swift create mode 100644 Sources/JetStream/Consumer.swift create mode 100644 Sources/JetStream/JetStreamContext+Consumer.swift create mode 100644 Sources/JetStream/JetStreamContext+Stream.swift create mode 100644 Sources/JetStream/JetStreamContext.swift create mode 100644 Sources/JetStream/JetStreamError.swift create mode 100644 Sources/JetStream/JetStreamMessage.swift create mode 100644 Sources/JetStream/NanoTimeInterval.swift create mode 100644 Sources/JetStream/Stream+Consumer.swift create mode 100644 Sources/JetStream/Stream.swift create mode 100644 Sources/Nats/BatchBuffer.swift create mode 100644 Sources/Nats/ConcurrentQueue.swift create mode 100644 Sources/Nats/Extensions/ByteBuffer+Writer.swift create mode 100644 Sources/Nats/Extensions/Data+Base64.swift create mode 100644 Sources/Nats/Extensions/Data+Parser.swift create mode 100644 Sources/Nats/Extensions/Data+String.swift create mode 100644 Sources/Nats/Extensions/String+Utilities.swift create mode 100644 Sources/Nats/HTTPUpgradeRequestHandler.swift create mode 100644 Sources/Nats/NatsClient/NatsClient+Events.swift create mode 100755 Sources/Nats/NatsClient/NatsClient.swift create mode 100644 Sources/Nats/NatsClient/NatsClientOptions.swift create mode 100644 Sources/Nats/NatsConnection.swift create mode 100644 Sources/Nats/NatsError.swift create mode 100644 Sources/Nats/NatsHeaders.swift create mode 100644 Sources/Nats/NatsJwtUtils.swift create mode 100644 Sources/Nats/NatsMessage.swift create mode 100644 Sources/Nats/NatsProto.swift create mode 100644 Sources/Nats/NatsSubscription.swift create mode 100644 Sources/Nats/RttCommand.swift create mode 100644 Sources/NatsServer/NatsServer.swift create mode 100644 Sources/bench/main.swift create mode 100644 Tests/JetStreamTests/Integration/ConsumerTests.swift create mode 100644 Tests/JetStreamTests/Integration/JetStreamTests.swift create mode 100644 Tests/JetStreamTests/Integration/RequestTests.swift create mode 100644 Tests/JetStreamTests/Integration/Resources/domain.conf create mode 100644 Tests/JetStreamTests/Integration/Resources/jetstream.conf create mode 100644 Tests/JetStreamTests/Integration/Resources/prefix.conf create mode 100644 Tests/JetStreamTests/Unit/MessageTests.swift create mode 100755 Tests/NatsTests/Integration/ConnectionTests.swift create mode 100644 Tests/NatsTests/Integration/EventsTests.swift create mode 100644 Tests/NatsTests/Integration/MessageWithHeadersTests.swift create mode 100644 Tests/NatsTests/Integration/Resources/TestUser.creds create mode 100644 Tests/NatsTests/Integration/Resources/certs/client-all.pem create mode 100644 Tests/NatsTests/Integration/Resources/certs/client-cert-invalid.pem create mode 100644 Tests/NatsTests/Integration/Resources/certs/client-cert.pem create mode 100644 Tests/NatsTests/Integration/Resources/certs/client-key-invalid.pem create mode 100644 Tests/NatsTests/Integration/Resources/certs/client-key.pem create mode 100644 Tests/NatsTests/Integration/Resources/certs/ip-ca.pem create mode 100644 Tests/NatsTests/Integration/Resources/certs/ip-cert.pem create mode 100644 Tests/NatsTests/Integration/Resources/certs/ip-key.pem create mode 100644 Tests/NatsTests/Integration/Resources/certs/rootCA-key.pem create mode 100644 Tests/NatsTests/Integration/Resources/certs/rootCA.pem create mode 100644 Tests/NatsTests/Integration/Resources/certs/server-cert.pem create mode 100644 Tests/NatsTests/Integration/Resources/certs/server-key.pem create mode 100644 Tests/NatsTests/Integration/Resources/creds.conf create mode 100644 Tests/NatsTests/Integration/Resources/jwt.conf create mode 100644 Tests/NatsTests/Integration/Resources/nkey create mode 100644 Tests/NatsTests/Integration/Resources/nkey.conf create mode 100644 Tests/NatsTests/Integration/Resources/permissions.conf create mode 100644 Tests/NatsTests/Integration/Resources/tls.conf create mode 100644 Tests/NatsTests/Integration/Resources/tls_first.conf create mode 100644 Tests/NatsTests/Integration/Resources/tls_first_auto.conf create mode 100644 Tests/NatsTests/Integration/Resources/token.conf create mode 100644 Tests/NatsTests/Integration/Resources/ws.conf create mode 100644 Tests/NatsTests/Integration/Resources/wss.conf create mode 100644 Tests/NatsTests/Unit/ErrorsTests.swift create mode 100644 Tests/NatsTests/Unit/HeadersTests.swift create mode 100644 Tests/NatsTests/Unit/JwtTests.swift create mode 100644 Tests/NatsTests/Unit/NatsClientOptionsTests.swift create mode 100644 Tests/NatsTests/Unit/ParserTests.swift 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 0000000000000000000000000000000000000000..62e5f51b0a0a3531f26d65591bec35e5d4d089bb GIT binary patch literal 36553 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
jNjuMT(}EJRqw`FOLc}_plf~@G;8>^ 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 M07*qoM6N<$f}N=8VgLXD literal 0 HcmV?d00001 diff --git a/Resources/Logo@256.png b/Resources/Logo@256.png new file mode 100644 index 0000000000000000000000000000000000000000..7aad82f176b9d1a707cc5d4bd62ca121b949d1ff GIT binary patch literal 21326 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 _oyvkM5;tvkE-fxCj_KLP5f8xE?m!eBuB^ZKpp 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 literal 0 HcmV?d00001 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)) + } + } + } + } + } +}