diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..d1ba664 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,36 @@ +--- +name: Bug report +about: Report a problem with StompClientLib +title: "[Bug] " +labels: bug +assignees: '' +--- + +## Describe the bug + +A clear and concise description of what the bug is. + +## To reproduce + +Steps (or a minimal code snippet) that triggers the issue. + +```swift +// your setup +``` + +## Expected behaviour + +What you expected to happen. + +## Environment + +- StompClientLib version: +- Installation: CocoaPods / Carthage +- iOS deployment target: +- Xcode / Swift version: +- STOMP broker (e.g. Spring Boot, ActiveMQ, RabbitMQ): +- `ws://` or `wss://`, and whether `certificateCheckEnabled` is on/off: + +## Logs + +Set `StompClientLib.isLoggingEnabled = true` and paste any relevant console output. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..b5c0e81 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Question / usage help + url: https://github.com/kuraydev/StompClientLib/discussions + about: Ask questions about using StompClientLib here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..6982bc5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,23 @@ +--- +name: Feature request +about: Suggest an idea for StompClientLib +title: "[Feature] " +labels: enhancement +assignees: '' +--- + +## Is your feature request related to a problem? + +A clear and concise description of the problem. + +## Describe the solution you'd like + +What you want to happen. + +## Describe alternatives you've considered + +Any alternative solutions or features you've considered. + +## Additional context + +Add any other context or references here. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..92af34c --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,23 @@ +# Description + + + +## Type of change + +- [ ] Bug fix (non-breaking change that fixes an issue) +- [ ] New feature (non-breaking change that adds functionality) +- [ ] Breaking change (fix or feature that changes the public API or default behaviour) +- [ ] Documentation / tooling + +## Checklist + +- [ ] Public API (type names, delegate signatures, public methods, default values) is unchanged, **or** the breaking change is documented and a major version bump is recommended. +- [ ] Objective-C run-time compatibility is preserved. +- [ ] Tests added/updated and passing (`xcodebuild test`). +- [ ] `swiftlint lint --strict` passes. +- [ ] `pod lib lint StompClientLib.podspec --allow-warnings` passes. +- [ ] `CHANGELOG.md` updated under `## [Unreleased]`. + +## Related issues + + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..761c66f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,57 @@ +name: CI + +on: + push: + branches: [master, main] + pull_request: + branches: [master, main] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + swiftlint: + name: SwiftLint + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + - name: Install SwiftLint + run: brew install swiftlint + - name: Run SwiftLint + run: swiftlint lint --strict --reporter github-actions-logging + + podspec-lint: + name: pod lib lint + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + - name: Lint podspec + run: pod lib lint StompClientLib.podspec --allow-warnings + + test: + name: Build & Test (example) + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + - name: Install pods + working-directory: StompClientLibExample + run: pod install --repo-update + - name: Select an available iOS simulator + id: sim + run: | + UDID=$(xcrun simctl list devices available | grep -m1 -E "iPhone" | grep -oE "[0-9A-Fa-f-]{36}") + if [ -z "$UDID" ]; then echo "No iOS simulator available"; xcrun simctl list devices; exit 1; fi + echo "Using simulator $UDID" + echo "udid=$UDID" >> "$GITHUB_OUTPUT" + - name: Build & test + working-directory: StompClientLibExample + run: | + set -o pipefail + xcodebuild test \ + -workspace StompClientLibExample.xcworkspace \ + -scheme StompClientLibExample \ + -sdk iphonesimulator \ + -destination "id=${{ steps.sim.outputs.udid }}" \ + -configuration Debug \ + CODE_SIGNING_ALLOWED=NO diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..99ba924 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,49 @@ +# SwiftLint configuration for StompClientLib +# https://github.com/realm/SwiftLint + +included: + - StompClientLib/Classes + +excluded: + - StompClientLibExample/Pods + - StompClientLibExample/StompClientLib.swift # stray duplicate of the library source + +# The public Obj-C-bridged API intentionally uses Cocoa-style names +# (e.g. AutoMode, ClientMode) that predate Swift naming guidelines and +# cannot be renamed without breaking the published contract. +disabled_rules: + - identifier_name + - type_name + - todo + +opt_in_rules: + - empty_count + - closure_spacing + - redundant_nil_coalescing + +line_length: + warning: 200 + error: 300 + ignores_comments: true + +file_length: + warning: 600 + error: 1000 + +function_body_length: + warning: 80 + error: 150 + +type_body_length: + warning: 500 + error: 700 + +# StompFrame.decode returns a labelled (command, headers, body) tuple. +large_tuple: + warning: 3 + error: 4 + +# receiveFrame is a flat dispatch over STOMP frame commands. +cyclomatic_complexity: + warning: 15 + error: 25 diff --git a/.travis.yml b/.travis.yml deleted file mode 100755 index c79be57..0000000 --- a/.travis.yml +++ /dev/null @@ -1,14 +0,0 @@ -# references: -# * http://www.objc.io/issue-6/travis-ci.html -# * https://github.com/supermarin/xcpretty#usage - -osx_image: xcode7.3 -language: swift -# cache: cocoapods -# podfile: Example/Podfile -# before_install: -# - gem install cocoapods # Since Travis is not always on latest version -# - pod install --project-directory=Example -script: -- set -o pipefail && xcodebuild test -workspace Example/StompClientLib.xcworkspace -scheme StompClientLib-Example -sdk iphonesimulator9.3 ONLY_ACTIVE_ARCH=NO | xcpretty -- pod lib lint \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 66f0738..ab4eeb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,59 @@ # Changelog +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Public `StompFrame` helpers (`encode`, `decode`, `ackHeaderValue`) — the pure, + transport-agnostic STOMP wire-format core that backs the client and is now unit + tested independently of a live WebSocket. +- `StompClientLib.isLoggingEnabled` toggle (default `false`) so the library no + longer prints diagnostics to consumer consoles unless opted in. +- Real XCTest suite covering frame encode/decode (including the `:`-in-header-value + case), ack-mode mapping, and client defaults. +- GitHub Actions CI (SwiftLint, `pod lib lint`, and `xcodebuild test` of the + example) replacing the dead Travis configuration. +- SwiftLint configuration (`.swiftlint.yml`). +- `CONTRIBUTING.md`, issue templates, and a pull-request template. + +### Changed + +- The pod now ships `PrivacyInfo.xcprivacy` via `resource_bundles` (previously the + manifest was committed but never packaged because it sat outside `source_files`). +- Podspec `homepage`/`source` repointed to `github.com/kuraydev/StompClientLib`; + Swift version pinned to 5.0; `SocketRocket` dependency pinned to `~> 0.5`. +- `protocol StompClientLibDelegate` now refines `AnyObject` instead of the + deprecated `class` keyword. +- Reconnect now honours the `exponentialBackoff` parameter (previously ignored): + with the default `true`, the interval grows geometrically (capped at 60s) and + resets on reconnect. Pass `exponentialBackoff: false` for the legacy fixed + interval. +- Console `print` diagnostics replaced by a gated logger (off by default). + +### Fixed + +- `serverDidSendError` now reports a real `detailedErrorMessage` on transport + failures; it previously always passed `nil` (`error as? String` never succeeds). +- `connection` is now reset to `false` when the socket fails or the server closes + it, so `isConnected()` is accurate and auto-reconnect actually fires + (issues #123, #125). + +### Breaking (recommend a major version, e.g. 2.0.0) + +- Minimum deployment target raised from iOS 9.0 to iOS 12.0. + +--- + +## Historical releases + +The entries below were auto-generated against the original `WrathChaos/StompClientLib` +repository and are retained for reference. + [Full Changelog](https://github.com/WrathChaos/StompClientLib/compare/1.3.8...HEAD) **Implemented enhancements:** diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..60d4b02 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,55 @@ +# Contributing to StompClientLib + +Thanks for taking the time to contribute! This document explains how to set up +the project, the conventions we follow, and how to get a change merged. + +## Getting started + +```bash +git clone https://github.com/kuraydev/StompClientLib.git +cd StompClientLib/StompClientLibExample +pod install +open StompClientLibExample.xcworkspace +``` + +The library source lives in `StompClientLib/Classes/`. The wire-format core +(`StompFrame.swift`) is pure Foundation and is unit tested in +`StompClientLibExample/StompClientLibExampleTests/`. + +## Running the checks locally + +The same checks run in CI (`.github/workflows/ci.yml`): + +```bash +# Lint +swiftlint lint --strict + +# Validate the podspec +pod lib lint StompClientLib.podspec --allow-warnings + +# Build & test the example app +cd StompClientLibExample +xcodebuild test \ + -workspace StompClientLibExample.xcworkspace \ + -scheme StompClientLibExample \ + -destination 'platform=iOS Simulator,name=iPhone 16' +``` + +## Pull request guidelines + +- Branch off `master` and open the PR against `master`. +- Keep the **public API stable.** Exported type names, the `StompClientLibDelegate` + method signatures, public method names, and their default values are a contract + for existing CocoaPods consumers. Any source-breaking change must be called out + explicitly and warrants a major version bump. +- Preserve **Objective-C run-time compatibility** (`@objc` / `@objcMembers`) — it + is an advertised feature. +- Use [Conventional Commits](https://www.conventionalcommits.org/) for commit + messages (e.g. `fix:`, `feat:`, `docs:`, `chore:`). +- Add or update tests for any behavioural change. +- Update `CHANGELOG.md` under `## [Unreleased]`. + +## Reporting bugs + +Open an issue using the bug-report template and include the STOMP broker you are +connecting to (e.g. Spring Boot), the deployment target, and a minimal repro. diff --git a/LICENSE b/LICENSE index 3e4da3f..5bc87b8 100755 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,6 @@ -Copyright (c) 2017 wrathchaos +MIT License + +Copyright (c) 2017-2026 Kuray Ogun (FreakyCoder) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 03f9748..cac1ef4 100755 --- a/README.md +++ b/README.md @@ -5,506 +5,272 @@

- - + +License - - + +Platform - - + +Pod Version -

- -

- - - - + +CI - - - - - + +Issues +Swift 5

+

- Swift 5+ StompClient Library + Swift STOMP Client Library

## Introduction -StompClientLib is a stomp client in Swift. It uses Facebook's [ SocketRocket ](https://github.com/facebook/SocketRocket) as a websocket dependency. SocketRocket is written in Objective-C but StompClientLib's STOMP part is written in Swift and its usage is Swift. You can use this library in your Swift 5+, 4+ and 3+ projects. +StompClientLib is a [STOMP](https://stomp.github.io/) (Simple Text Oriented +Messaging Protocol) client for iOS, written in Swift with Objective-C run-time +compatibility. It opens a WebSocket connection and layers STOMP 1.1 / 1.2 +(connect, subscribe/unsubscribe, send, ack, transactions, receipts, ping, +auto-reconnect/disconnect) on top, surfaced through a delegate protocol. -## Supported Stomp Versions +The WebSocket transport is provided by Facebook's +[SocketRocket](https://github.com/facebookincubator/SocketRocket), which is +pulled in automatically as a dependency. -Stomp version -- **1.1** -- **1.2** -- Might **not** be worked with 1.0 (Never tested) +> **Note:** SocketRocket is in maintenance mode. A migration to Apple's +> first-party `URLSessionWebSocketTask` / `NWProtocolWebSocket` (which would also +> unblock Swift Package Manager support — see issue +> [#128](https://github.com/kuraydev/StompClientLib/issues/128)) is planned for a +> future major release. -## Example +## Supported STOMP versions -To run the example project, clone the repo, and run `pod install` from the Example directory first. +- **1.1** +- **1.2** +- 1.0 is not supported (never tested) + +The client automatically negotiates `accept-version: 1.1,1.2` on connect, which +is what brokers such as Spring Boot expect. ## Requirements -- iOS 8.0+ -- XCode 8.1, 8.2, 8.3 -- XCode 9.0+ -- **XCode 10.0 +** -- **XCode 12.1 +** -- Swift 3.0, 3.1, 3.2 -- **Swift 4.0, Swift 4.1, Swift 4.2, Swift 5.0** +| | Minimum | +|---|---| +| iOS deployment target | 12.0+ | +| Swift | 5.0+ | +| Xcode | 14+ (built & tested on Xcode 16 / 26) | ## Installation -StompClientLib is available through [CocoaPods](http://cocoapods.org). To install -it, simply add the following line to your Podfile: +### CocoaPods -#### Cocoapods +StompClientLib is available through [CocoaPods](https://cocoapods.org). Add the +following line to your `Podfile`: ```ruby -pod "StompClientLib" +pod 'StompClientLib' ``` -#### Carthage +Then run `pod install`. This also pulls in the transitive `SocketRocket` +dependency. -```ruby -github "WrathChaos/StompClientLib" +### Carthage + +```ogdl +github "kuraydev/StompClientLib" ``` +### Swift Package Manager + +SPM is **not yet supported** because the underlying SocketRocket dependency does +not ship a `Package.swift`. It is tracked in issue +[#128](https://github.com/kuraydev/StompClientLib/issues/128) and is gated on the +planned migration to `URLSessionWebSocketTask`. + ## Usage ```swift import StompClientLib ``` -Once imported, you can open a connection to your WebSocket server. +### Open a connection ```swift var socketClient = StompClientLib() -let url = NSURL(string: "your-socket-url-is-here")! -socketClient.openSocketWithURLRequest(request: NSURLRequest(url: url as URL) , delegate: self) +let url = URL(string: "ws://your-broker-host/your-endpoint")! +let request = NSURLRequest(url: url) +socketClient.openSocketWithURLRequest(request: request, delegate: self) ``` -After you are connected, there are some delegate methods that you need to implement. - -# StompClientLibDelegate - -## stompClientDidConnect +For self-signed / untrusted TLS certificates (e.g. a local `wss://` dev broker), +set `certificateCheckEnabled = false` **before** opening the socket. Leave it at +its default (`true`) in production so the system performs normal TLS validation: ```swift -func stompClientDidConnect(client: StompClientLib!) { -print("Socket is connected") -// Stomp subscribe will be here! -socketClient.subscribe(destination: topic) -// Note : topic needs to be a String object -} +socketClient.certificateCheckEnabled = false // accepts untrusted certs — dev only ``` -## stompClientDidDisconnect - -```swift -func stompClientDidDisconnect(client: StompClientLib!) { -print("Socket is Disconnected") -} -``` +### Enable logging (optional) -## didReceiveMessageWithJSONBody ( Message Received via STOMP ) - -Your json message will be converted to JSON Body as AnyObject and you will receive your message in this function +Diagnostics are silent by default. Turn them on while debugging: ```swift -func stompClient(client: StompClientLib!, didReceiveMessageWithJSONBody jsonBody: AnyObject?, akaStringBody stringBody: String?, withHeader header: [String : String]?, withDestination destination: String) { -print("Destination : \(destination)") -print("JSON Body : \(String(describing: jsonBody))") -print("String Body : \(stringBody ?? "nil")") -} +StompClientLib.isLoggingEnabled = true ``` -## didReceiveMessageWithJSONBody ( Message Received via STOMP as String ) +## StompClientLibDelegate + +Conform to `StompClientLibDelegate` to receive connection and message events: -Your json message will be converted to JSON Body as AnyObject and you will receive your message in this function +| Method | Called when | +|---|---| +| `stompClientDidConnect(client:)` | The STOMP `CONNECTED` frame is received | +| `stompClientDidDisconnect(client:)` | The socket disconnects or is closed | +| `stompClient(client:didReceiveMessageWithJSONBody:akaStringBody:withHeader:withDestination:)` | A `MESSAGE` frame arrives (body provided as parsed JSON **and** raw String) | +| `serverDidSendReceipt(client:withReceiptId:)` | A `RECEIPT` frame arrives | +| `serverDidSendError(client:withErrorMessage:detailedErrorMessage:)` | An `ERROR` frame or a transport failure occurs | +| `serverDidSendPing()` | The server sends a heart-beat / ping | ```swift -func stompClientJSONBody(client: StompClientLib!, didReceiveMessageWithJSONBody jsonBody: String?, withHeader header: [String : String]?, withDestination destination: String) { - print("DESTINATION : \(destination)") - print("String JSON BODY : \(String(describing: jsonBody))") +func stompClientDidConnect(client: StompClientLib!) { + print("Socket connected") + socketClient.subscribe(destination: "/topic/greetings") } -``` -## serverDidSendReceipt +func stompClientDidDisconnect(client: StompClientLib!) { + print("Socket disconnected") +} -If you will use STOMP for in-app purchase, you might need to use this function to get receipt +func stompClient(client: StompClientLib!, + didReceiveMessageWithJSONBody jsonBody: AnyObject?, + akaStringBody stringBody: String?, + withHeader header: [String: String]?, + withDestination destination: String) { + print("Destination: \(destination)") + print("JSON Body: \(String(describing: jsonBody))") + print("String Body: \(stringBody ?? "nil")") +} -```swift func serverDidSendReceipt(client: StompClientLib!, withReceiptId receiptId: String) { - print("Receipt : \(receiptId)") + print("Receipt: \(receiptId)") } -``` - -## serverDidSendError - -Your error message will be received in this function -```swift -func serverDidSendError(client: StompClientLib!, withErrorMessage description: String, detailedErrorMessage message: String?) { - print("Error Send : \(String(describing: message))") +func serverDidSendError(client: StompClientLib!, + withErrorMessage description: String, + detailedErrorMessage message: String?) { + print("Error: \(description) — \(message ?? "")") } -``` -## serverDidSendPing - -If you need to control your server's ping, here is your part - -```swift func serverDidSendPing() { - print("Server ping") + print("Server ping") } ``` -## How to subscribe and unsubscribe - -There are functions for subscribing and unsubscribing. -Note : You should handle your subscribe and unsubscribe methods ! -Suggestion : Subscribe to your topic in "stompClientDidConnect" function and unsubcribe to your topic in stompClientWillDisconnect method. +## Public API reference -## Subscribe +### Connection -```swift -socketClient.subscribe(destination: topic) -// Note : topic needs to be a String object -``` +| Method | Description | +|---|---| +| `openSocketWithURLRequest(request:delegate:connectionHeaders:)` | Open the WebSocket and connect | +| `disconnect()` | Send `DISCONNECT` and close the socket | +| `isConnected() -> Bool` | Current connection state | +| `reconnect(request:delegate:connectionHeaders:time:exponentialBackoff:)` | Auto-reconnect while down (see below) | +| `stopReconnect()` | Cancel auto-reconnect | +| `autoDisconnect(time:)` | Disconnect after `time` seconds | +| `var certificateCheckEnabled: Bool` | `false` accepts untrusted SSL certificates (default `true`) | +| `static var isLoggingEnabled: Bool` | Console logging toggle (default `false`) | -## Unsubscribe +### Subscriptions -```swift -socketClient.unsubscribe(destination: topic) -``` +| Method | Description | +|---|---| +| `subscribe(destination:)` | Subscribe with auto ack mode | +| `subscribeToDestination(destination:ackMode:)` | Subscribe with an explicit `StompAckMode` | +| `subscribeWithHeader(destination:withHeader:)` | Subscribe with custom headers | +| `unsubscribe(destination:)` | Unsubscribe from a destination | -Important : You have to send your destination for both subscribe or unsubscribe! +`StompAckMode` is one of `.AutoMode`, `.ClientMode`, `.ClientIndividualMode`. -## Unsubsribe with header +### Messaging & transactions -```swift -let destination = "/topic/your_topic" -let ack = destination -let id = destination -let header = ["destination": destination, "ack": ack, "id": id] +| Method | Description | +|---|---| +| `sendMessage(message:toDestination:withHeaders:withReceipt:)` | Send a text message | +| `sendJSONForDict(dict:toDestination:)` | Serialize a dictionary to JSON and send it | +| `ack(messageId:)` / `ack(messageId:withSubscription:)` | Acknowledge a message | +| `begin(transactionId:)` | Begin a transaction | +| `commit(transactionId:)` | Commit a transaction | +| `abort(transactionId:)` | Abort a transaction | -// subscribe -socketClient?.subscribeWithHeader(destination: destination, withHeader: header) +### Wire-format helpers -// unsubscribe -socketClient?.unsubscribe(destination: subsId) -``` +The pure STOMP frame core is exposed via `StompFrame` for advanced use and +testing: -## Auto Reconnect with a given time +- `StompFrame.encode(command:headers:body:) -> String` +- `StompFrame.decode(_:) -> (command: String, headers: [String: String], body: String)?` +- `StompFrame.ackHeaderValue(for:) -> String` -You can use this feature if you need to auto reconnect with a specific time or it will just try to reconnect every second. +## Auto reconnect ```swift -// Reconnect after 4 sec -socketClient.reconnect(request: NSURLRequest(url: url as URL) , delegate: self as StompClientLibDelegate, time: 4.0) -``` - -## Auto Disconnect with a given time +// Reconnect with exponential backoff starting at 1s (default), capped at 60s. +socketClient.reconnect(request: request, delegate: self) -```swift -// Auto Disconnect after 3 sec -socketClient.autoDisconnect(time: 3) +// Or with a fixed 4s interval: +socketClient.reconnect(request: request, delegate: self, time: 4.0, exponentialBackoff: false) ``` -## Login Passcode Implementation +When `exponentialBackoff` is `true` (the default), the wait between attempts +grows geometrically (1s, 2s, 4s, …) up to 60s and resets once the connection is +re-established. Call `stopReconnect()` to cancel. -This is just an example. You need to convert to your implementation. -[#42](https://github.com/WrathChaos/StompClientLib/issues/42) +## Auto disconnect ```swift -let connectFrame = "CONNECT\n login:admin\n passcode:password\n\n\n\0" -socket.write(string: connectFrame) +socketClient.autoDisconnect(time: 3) // disconnect after 3 seconds ``` -## Future Enhancements - -- [x] ~~Complete a working Example~~ -- [x] ~~Add Carthage installation option~~ -- [x] ~~Add Swift Package Manager installation option~~ -- [x] ~~XCode 9 compatibility~~ -- [x] ~~Swift 4 compatibility and tests~~ -- [ ] [Apple's New Socket for iOS 13](https://developer.apple.com/documentation/network/nwprotocolwebsocket) Implementation from stratch - -# Changelog - -[Full Changelog](https://github.com/WrathChaos/StompClientLib/compare/1.3.8...HEAD) - -**Implemented enhancements:** - -- SUBSCRIBE and UNSUBSCRIBE Delegate is missing [\#16](https://github.com/WrathChaos/StompClientLib/issues/16) - -**Closed issues:** - -- Error Domain=SRWebSocketErrorDomain Code=2132 "received bad response code from server 403" [\#86](https://github.com/WrathChaos/StompClientLib/issues/86) -- the delegate should be weak [\#83](https://github.com/WrathChaos/StompClientLib/issues/83) - -## [1.3.8](https://github.com/WrathChaos/StompClientLib/tree/1.3.8) (2020-03-08) - -[Full Changelog](https://github.com/WrathChaos/StompClientLib/compare/1.3.7...1.3.8) - -**Implemented enhancements:** - -- Socket stopped working once I moved from ws//: to wss//: [\#67](https://github.com/WrathChaos/StompClientLib/issues/67) -- Self sign certificate, 400 error [\#66](https://github.com/WrathChaos/StompClientLib/issues/66) -- How to increase output buffer [\#37](https://github.com/WrathChaos/StompClientLib/issues/37) - -**Fixed bugs:** - -- unable to install [\#78](https://github.com/WrathChaos/StompClientLib/issues/78) -- Number of received messages is limited [\#76](https://github.com/WrathChaos/StompClientLib/issues/76) - -**Closed issues:** - -- Can't find header in initial call [\#81](https://github.com/WrathChaos/StompClientLib/issues/81) -- Can't connect to websocket : received bad response code from server 422 [\#80](https://github.com/WrathChaos/StompClientLib/issues/80) -- I just closed the issue because of the stale & reproducible problem [\#79](https://github.com/WrathChaos/StompClientLib/issues/79) -- unable to install [\#77](https://github.com/WrathChaos/StompClientLib/issues/77) -- stompClientDidConnect not called with Spring boot [\#73](https://github.com/WrathChaos/StompClientLib/issues/73) -- Issue with Cookies in header [\#71](https://github.com/WrathChaos/StompClientLib/issues/71) -- Can't see any Websocket traffic in Charles Proxy [\#70](https://github.com/WrathChaos/StompClientLib/issues/70) -- End of stream error [\#69](https://github.com/WrathChaos/StompClientLib/issues/69) -- Cannot connect with Stomp Websocket when custom header [\#64](https://github.com/WrathChaos/StompClientLib/issues/64) -- Can't connect Socket with Spring Boot 2.x.x [\#63](https://github.com/WrathChaos/StompClientLib/issues/63) -- Multiple clients [\#48](https://github.com/WrathChaos/StompClientLib/issues/48) -- IPV6 [\#38](https://github.com/WrathChaos/StompClientLib/issues/38) - -**Merged pull requests:** - -- fix delegate [\#84](https://github.com/WrathChaos/StompClientLib/pull/84) ([soledue](https://github.com/soledue)) -- Fix typo [\#74](https://github.com/WrathChaos/StompClientLib/pull/74) ([wanbok](https://github.com/wanbok)) - -## [1.3.7](https://github.com/WrathChaos/StompClientLib/tree/1.3.7) (2019-08-26) - -[Full Changelog](https://github.com/WrathChaos/StompClientLib/compare/1.3.6...1.3.7) - -**Closed issues:** - -- The problem when receiving the messages from the Spring boot. [\#68](https://github.com/WrathChaos/StompClientLib/issues/68) - -## [1.3.6](https://github.com/WrathChaos/StompClientLib/tree/1.3.6) (2019-08-06) - -[Full Changelog](https://github.com/WrathChaos/StompClientLib/compare/1.3.5...1.3.6) - -**Implemented enhancements:** - -- Refactor code and fix support spring 2.1.x [\#65](https://github.com/WrathChaos/StompClientLib/pull/65) ([baonguyena1](https://github.com/baonguyena1)) - -**Fixed bugs:** - -- v1.3.4 replaced my custom header in open socket? [\#58](https://github.com/WrathChaos/StompClientLib/issues/58) - -**Closed issues:** +## Login / passcode -- Json error: The data couldn’t be read because it isn’t in the correct format. [\#60](https://github.com/WrathChaos/StompClientLib/issues/60) +The library connects anonymously by default. To send credentials, supply them as +connection headers when opening the socket: -**Merged pull requests:** - -- Fixed ACK id header and added new ACK type [\#62](https://github.com/WrathChaos/StompClientLib/pull/62) ([rodmytro](https://github.com/rodmytro)) - -## [1.3.5](https://github.com/WrathChaos/StompClientLib/tree/1.3.5) (2019-07-25) - -[Full Changelog](https://github.com/WrathChaos/StompClientLib/compare/1.3.4...1.3.5) - -**Fixed bugs:** - -- StompClientDidConnect not called on a fresh project [\#51](https://github.com/WrathChaos/StompClientLib/issues/51) - -**Closed issues:** - -- SendJSONForDict error [\#57](https://github.com/WrathChaos/StompClientLib/issues/57) -- Can't connect to websocket with authorization header [\#39](https://github.com/WrathChaos/StompClientLib/issues/39) - -**Merged pull requests:** - -- \#58 fix open socket with custom header issue [\#59](https://github.com/WrathChaos/StompClientLib/pull/59) ([marain87](https://github.com/marain87)) - -## [1.3.4](https://github.com/WrathChaos/StompClientLib/tree/1.3.4) (2019-07-19) - -[Full Changelog](https://github.com/WrathChaos/StompClientLib/compare/1.3.3...1.3.4) - -## [1.3.3](https://github.com/WrathChaos/StompClientLib/tree/1.3.3) (2019-07-18) - -[Full Changelog](https://github.com/WrathChaos/StompClientLib/compare/1.3.2...1.3.3) - -**Closed issues:** - -- Missing merge [\#55](https://github.com/WrathChaos/StompClientLib/issues/55) -- Data garbled problem,help [\#41](https://github.com/WrathChaos/StompClientLib/issues/41) - -**Merged pull requests:** - -- String body parameter and ':'-in-header-value fix [\#56](https://github.com/WrathChaos/StompClientLib/pull/56) ([Erhannis](https://github.com/Erhannis)) - -## [1.3.2](https://github.com/WrathChaos/StompClientLib/tree/1.3.2) (2019-07-10) - -[Full Changelog](https://github.com/WrathChaos/StompClientLib/compare/1.3.1...1.3.2) - -**Implemented enhancements:** - -- stompClientWillDisconnect missing [\#44](https://github.com/WrathChaos/StompClientLib/issues/44) - -## [1.3.1](https://github.com/WrathChaos/StompClientLib/tree/1.3.1) (2019-06-14) - -[Full Changelog](https://github.com/WrathChaos/StompClientLib/compare/1.3.0...1.3.1) - -**Closed issues:** - -- Can I subscribe to multiple topics with one stomp client? [\#47](https://github.com/WrathChaos/StompClientLib/issues/47) -- After subscribing to a topic, how to handle messages from server side? [\#46](https://github.com/WrathChaos/StompClientLib/issues/46) -- socket?.readyState is .OPEN and it never goes to my own "stompClientDidDisconnect" method [\#45](https://github.com/WrathChaos/StompClientLib/issues/45) -- Didconnect function cannot be callback after successful connection [\#43](https://github.com/WrathChaos/StompClientLib/issues/43) -- Login, passcode [\#42](https://github.com/WrathChaos/StompClientLib/issues/42) -- App goes in background lock the kepad the socket disconnected [\#40](https://github.com/WrathChaos/StompClientLib/issues/40) -- stompClientDidConnect not called with Spring boot [\#35](https://github.com/WrathChaos/StompClientLib/issues/35) -- Delegate StompClientDidConnect not called after connect-\>disconnect-\>connect [\#15](https://github.com/WrathChaos/StompClientLib/issues/15) - -## [1.3.0](https://github.com/WrathChaos/StompClientLib/tree/1.3.0) (2019-04-30) - -[Full Changelog](https://github.com/WrathChaos/StompClientLib/compare/1.2.7...1.3.0) - -**Implemented enhancements:** - -- invalidate reconnect [\#36](https://github.com/WrathChaos/StompClientLib/issues/36) - -**Closed issues:** - -- Should add Carthage [\#33](https://github.com/WrathChaos/StompClientLib/issues/33) -- Invalid Sec-WebSocket-Accept response [\#32](https://github.com/WrathChaos/StompClientLib/issues/32) -- Socket is disconnected with 1007 code as soon as it connected [\#31](https://github.com/WrathChaos/StompClientLib/issues/31) -- enable Assert \(self.readyState != SR_CONNECTING\) [\#24](https://github.com/WrathChaos/StompClientLib/issues/24) - -## [1.2.7](https://github.com/WrathChaos/StompClientLib/tree/1.2.7) (2018-10-23) - -[Full Changelog](https://github.com/WrathChaos/StompClientLib/compare/1.2.6...1.2.7) - -## [1.2.6](https://github.com/WrathChaos/StompClientLib/tree/1.2.6) (2018-10-23) - -[Full Changelog](https://github.com/WrathChaos/StompClientLib/compare/1.2.5...1.2.6) - -**Fixed bugs:** - -- Error when connected to socket [\#23](https://github.com/WrathChaos/StompClientLib/issues/23) - -**Closed issues:** - -- Auto disconnects [\#11](https://github.com/WrathChaos/StompClientLib/issues/11) - -## [1.2.5](https://github.com/WrathChaos/StompClientLib/tree/1.2.5) (2018-10-22) - -[Full Changelog](https://github.com/WrathChaos/StompClientLib/compare/1.2.4...1.2.5) - -**Closed issues:** - -- Value for "message-id" is always "1" [\#22](https://github.com/WrathChaos/StompClientLib/issues/22) -- Multiple subscription to topics [\#20](https://github.com/WrathChaos/StompClientLib/issues/20) -- I think there is a memory leak for the delegate [\#19](https://github.com/WrathChaos/StompClientLib/issues/19) -- Getting error when framwork is installed in Objective c project [\#9](https://github.com/WrathChaos/StompClientLib/issues/9) - -## [1.2.4](https://github.com/WrathChaos/StompClientLib/tree/1.2.4) (2018-10-17) - -[Full Changelog](https://github.com/WrathChaos/StompClientLib/compare/1.2.3...1.2.4) - -**Closed issues:** - -- didCloseWithCode 1000, reason: nil [\#21](https://github.com/WrathChaos/StompClientLib/issues/21) - -## [1.2.3](https://github.com/WrathChaos/StompClientLib/tree/1.2.3) (2018-10-17) - -[Full Changelog](https://github.com/WrathChaos/StompClientLib/compare/1.2.2...1.2.3) - -**Implemented enhancements:** - -- How to receive heartbeat? [\#18](https://github.com/WrathChaos/StompClientLib/issues/18) - -**Closed issues:** - -- Socket is not getting connected [\#30](https://github.com/WrathChaos/StompClientLib/issues/30) -- didCloseWithCode 1002 [\#29](https://github.com/WrathChaos/StompClientLib/issues/29) -- No response [\#27](https://github.com/WrathChaos/StompClientLib/issues/27) -- didCloseWithCode 1001, reason: "Stream end encountered" [\#26](https://github.com/WrathChaos/StompClientLib/issues/26) -- Stream end encountered [\#17](https://github.com/WrathChaos/StompClientLib/issues/17) -- unsubscribe socketclient [\#14](https://github.com/WrathChaos/StompClientLib/issues/14) -- It not able to connect web socket. [\#13](https://github.com/WrathChaos/StompClientLib/issues/13) -- One of the delegate method is not being called. [\#12](https://github.com/WrathChaos/StompClientLib/issues/12) -- StompClient Disconnection. [\#10](https://github.com/WrathChaos/StompClientLib/issues/10) -- Unable to find a specification for 'StompClientLib' [\#8](https://github.com/WrathChaos/StompClientLib/issues/8) - -## [1.2.2](https://github.com/WrathChaos/StompClientLib/tree/1.2.2) (2017-11-03) - -[Full Changelog](https://github.com/WrathChaos/StompClientLib/compare/1.2.1...1.2.2) - -## [1.2.1](https://github.com/WrathChaos/StompClientLib/tree/1.2.1) (2017-10-31) - -[Full Changelog](https://github.com/WrathChaos/StompClientLib/compare/1.2.0...1.2.1) - -## [1.2.0](https://github.com/WrathChaos/StompClientLib/tree/1.2.0) (2017-10-29) - -[Full Changelog](https://github.com/WrathChaos/StompClientLib/compare/1.1.7...1.2.0) - -**Closed issues:** - -- Let client decide what to do with stomp frame body [\#4](https://github.com/WrathChaos/StompClientLib/issues/4) -- Send message support [\#3](https://github.com/WrathChaos/StompClientLib/issues/3) -- Error when calling delegate [\#1](https://github.com/WrathChaos/StompClientLib/issues/1) - -## [1.1.7](https://github.com/WrathChaos/StompClientLib/tree/1.1.7) (2017-10-02) - -[Full Changelog](https://github.com/WrathChaos/StompClientLib/compare/1.1.6...1.1.7) - -## [1.1.6](https://github.com/WrathChaos/StompClientLib/tree/1.1.6) (2017-08-08) - -[Full Changelog](https://github.com/WrathChaos/StompClientLib/compare/0.1.5...1.1.6) - -## [0.1.5](https://github.com/WrathChaos/StompClientLib/tree/0.1.5) (2017-07-10) - -[Full Changelog](https://github.com/WrathChaos/StompClientLib/compare/0.1.4...0.1.5) - -## [0.1.4](https://github.com/WrathChaos/StompClientLib/tree/0.1.4) (2017-07-10) - -[Full Changelog](https://github.com/WrathChaos/StompClientLib/compare/0.1.3...0.1.4) - -## [0.1.3](https://github.com/WrathChaos/StompClientLib/tree/0.1.3) (2017-07-10) - -[Full Changelog](https://github.com/WrathChaos/StompClientLib/compare/0.1.2...0.1.3) +```swift +let headers = ["login": "admin", "passcode": "password"] +socketClient.openSocketWithURLRequest(request: request, delegate: self, connectionHeaders: headers) +``` -## [0.1.2](https://github.com/WrathChaos/StompClientLib/tree/0.1.2) (2017-07-08) +## Example app -[Full Changelog](https://github.com/WrathChaos/StompClientLib/compare/0.1.1...0.1.2) +`StompClientLibExample/` contains a UIKit demo. By default it points at a local +Spring Boot broker (`ws://localhost:8080`); change the URL in `ViewController.swift` +to target your own broker. -## [0.1.1](https://github.com/WrathChaos/StompClientLib/tree/0.1.1) (2017-07-08) +```bash +cd StompClientLibExample +pod install +open StompClientLibExample.xcworkspace +``` -[Full Changelog](https://github.com/WrathChaos/StompClientLib/compare/0.1.0...0.1.1) +## Contributing -## [0.1.0](https://github.com/WrathChaos/StompClientLib/tree/0.1.0) (2017-07-08) +Contributions are welcome — please read [CONTRIBUTING.md](CONTRIBUTING.md) first. +Keep in mind that the public API and Objective-C compatibility are a contract for +existing consumers. -[Full Changelog](https://github.com/WrathChaos/StompClientLib/compare/cbd49d3cad9a33ae96ff1708f7e0d3e975455325...0.1.0) +## Changelog -\* _This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)_ +See [CHANGELOG.md](CHANGELOG.md). ## Author -FreakyCoder, kurayogun@gmail.com +Kuray Ogun (FreakyCoder) — kurayogun@gmail.com ## License -StompClientLib is available under the MIT license. See the LICENSE file for more info. +StompClientLib is available under the MIT license. See [LICENSE](LICENSE) for +more information. diff --git a/StompClientLib.podspec b/StompClientLib.podspec index a6433bc..be03bd1 100755 --- a/StompClientLib.podspec +++ b/StompClientLib.podspec @@ -9,8 +9,8 @@ Pod::Spec.new do |s| s.name = 'StompClientLib' s.version = '1.4.1' - s.summary = 'Simple STOMP Client library. Swift 3, 4, 4.2, 5 compatible. Objective-C Run-time compatibility' - s.swift_version = '4.0', '4.2', '5.0' + s.summary = 'Simple STOMP client library for iOS. Swift 5 with Objective-C run-time compatibility.' + s.swift_versions = ['5.0'] # This description is used to generate tags and improve search results. # * Think: What does it do? Why did you write it? What is the focus? @@ -19,25 +19,23 @@ Pod::Spec.new do |s| # * Finally, don't worry about the indent, CocoaPods strips it! s.description = <<-DESC -Simple STOMP Client library, Swift 3, 4, 4.2, 5 compatible. STOMP Protocol let the program subscribe or unsubscribe the topic. It connects the websocket and use the STOMP protocol to subscribe the topic and recieve the message, receipt or even a ping. +Simple STOMP client library for iOS, written in Swift with Objective-C run-time compatibility. The STOMP protocol lets your app subscribe/unsubscribe to topics over a WebSocket connection and send, receive, ack, and receipt messages (STOMP 1.1 / 1.2). DESC - s.homepage = 'https://github.com/wrathchaos/StompClientLib' + s.homepage = 'https://github.com/kuraydev/StompClientLib' # s.screenshots = 'www.example.com/screenshots_1', 'www.example.com/screenshots_2' s.license = { :type => 'MIT', :file => 'LICENSE' } s.author = { 'FreakyCoder' => 'kurayogun@gmail.com' } - s.source = { :git => 'https://github.com/wrathchaos/StompClientLib.git', :tag => s.version.to_s } - s.social_media_url = 'https://twitter.com/freakycodercom' + s.source = { :git => 'https://github.com/kuraydev/StompClientLib.git', :tag => s.version.to_s } - s.ios.deployment_target = '9.0' + s.ios.deployment_target = '12.0' s.source_files = 'StompClientLib/Classes/**/*' - - # s.resource_bundles = { - # 'StompClientLib' => ['StompClientLib/Assets/*.png'] - # } - - # s.public_header_files = 'Pod/Classes/**/*.h' - # s.frameworks = 'UIKit', 'MapKit' - s.dependency 'SocketRocket' + + # Ship the App Store privacy manifest alongside the pod. + s.resource_bundles = { + 'StompClientLib' => ['StompClientLib/PrivacyInfo.xcprivacy'] + } + + s.dependency 'SocketRocket', '~> 0.5' end diff --git a/StompClientLib/Classes/StompClientLib.swift b/StompClientLib/Classes/StompClientLib.swift index 50e7f44..9d6d7c9 100755 --- a/StompClientLib/Classes/StompClientLib.swift +++ b/StompClientLib/Classes/StompClientLib.swift @@ -1,69 +1,23 @@ // -// StompClient.swift -// Pods +// StompClientLib.swift +// StompClientLib // // Created by Kuray (FreakyCoder) // Created at July 07, 2017 -// Updated at March 07, 2021 // -import UIKit +import Foundation import SocketRocket -struct StompCommands { - // Basic Commands - static let commandConnect = "CONNECT" - static let commandSend = "SEND" - static let commandSubscribe = "SUBSCRIBE" - static let commandUnsubscribe = "UNSUBSCRIBE" - static let commandBegin = "BEGIN" - static let commandCommit = "COMMIT" - static let commandAbort = "ABORT" - static let commandAck = "ACK" - static let commandDisconnect = "DISCONNECT" - static let commandPing = "\n" - - static let controlChar = String(format: "%C", arguments: [0x00]) - - // Ack Mode - static let ackClientIndividual = "client-individual" - static let ackClient = "client" - static let ackAuto = "auto" - // Header Commands - static let commandHeaderReceipt = "receipt" - static let commandHeaderDestination = "destination" - static let commandHeaderDestinationId = "id" - static let commandHeaderContentLength = "content-length" - static let commandHeaderContentType = "content-type" - static let commandHeaderAck = "ack" - static let commandHeaderTransaction = "transaction" - static let commandHeaderMessageId = "id" - static let commandHeaderSubscription = "subscription" - static let commandHeaderDisconnected = "disconnected" - static let commandHeaderHeartBeat = "heart-beat" - static let commandHeaderAcceptVersion = "accept-version" - // Header Response Keys - static let responseHeaderSession = "session" - static let responseHeaderReceiptId = "receipt-id" - static let responseHeaderErrorMessage = "message" - // Frame Response Keys - static let responseFrameConnected = "CONNECTED" - static let responseFrameMessage = "MESSAGE" - static let responseFrameReceipt = "RECEIPT" - static let responseFrameError = "ERROR" -} - -public enum StompAckMode { - case AutoMode - case ClientMode - case ClientIndividualMode -} - // Fundamental Protocols @objc -public protocol StompClientLibDelegate: class { - func stompClient(client: StompClientLib!, didReceiveMessageWithJSONBody jsonBody: AnyObject?, akaStringBody stringBody: String?, withHeader header:[String:String]?, withDestination destination: String) - +public protocol StompClientLibDelegate: AnyObject { + func stompClient(client: StompClientLib!, + didReceiveMessageWithJSONBody jsonBody: AnyObject?, + akaStringBody stringBody: String?, + withHeader header: [String: String]?, + withDestination destination: String) + func stompClientDidDisconnect(client: StompClientLib!) func stompClientDidConnect(client: StompClientLib!) func serverDidSendReceipt(client: StompClientLib!, withReceiptId receiptId: String) @@ -73,6 +27,13 @@ public protocol StompClientLibDelegate: class { @objcMembers public class StompClientLib: NSObject, SRWebSocketDelegate { + /// When `true`, the library prints diagnostic information to the console. + /// Defaults to `false` so the library no longer pollutes consumer logs. + public static var isLoggingEnabled = false + + /// Upper bound for the exponential reconnect interval, in seconds. + private static let maxReconnectInterval: TimeInterval = 60 + var socket: SRWebSocket? var sessionId: String? weak var delegate: StompClientLibDelegate? @@ -80,21 +41,27 @@ public class StompClientLib: NSObject, SRWebSocketDelegate { public var connection: Bool = false public var certificateCheckEnabled = true private var urlRequest: NSURLRequest? - - private var reconnectTimer : Timer? - + + private var reconnectTimer: Timer? + private var reconnectAttempts = 0 + + private static func log(_ message: @autoclosure () -> String) { + if isLoggingEnabled { + print("StompClientLib: \(message())") + } + } + public func sendJSONForDict(dict: AnyObject, toDestination destination: String) { do { let theJSONData = try JSONSerialization.data(withJSONObject: dict, options: JSONSerialization.WritingOptions()) let theJSONText = String(data: theJSONData, encoding: String.Encoding.utf8) - //print(theJSONText!) - let header = [StompCommands.commandHeaderContentType:"application/json;charset=UTF-8"] + let header = [StompCommands.commandHeaderContentType: "application/json;charset=UTF-8"] sendMessage(message: theJSONText!, toDestination: destination, withHeaders: header, withReceipt: nil) } catch { - print("error serializing JSON: \(error)") + StompClientLib.log("error serializing JSON: \(error)") } } - + public func openSocketWithURLRequest(request: NSURLRequest, delegate: StompClientLibDelegate, connectionHeaders: [String: String]? = nil) { self.connectionHeaders = connectionHeaders self.delegate = delegate @@ -103,7 +70,7 @@ public class StompClientLib: NSObject, SRWebSocketDelegate { openSocket() self.connection = true } - + private func openSocket() { if socket == nil || socket?.readyState == .CLOSED { if certificateCheckEnabled == true { @@ -111,13 +78,13 @@ public class StompClientLib: NSObject, SRWebSocketDelegate { } else { self.socket = SRWebSocket(urlRequest: urlRequest! as URLRequest, protocols: [], allowsUntrustedSSLCertificates: true) } - + socket!.delegate = self socket!.open() } } - - private func closeSocket(){ + + private func closeSocket() { if let delegate = delegate { DispatchQueue.main.async(execute: { delegate.stompClientDidDisconnect(client: self) @@ -130,7 +97,7 @@ public class StompClientLib: NSObject, SRWebSocketDelegate { }) } } - + /* Main Connection Method to open socket */ @@ -138,7 +105,7 @@ public class StompClientLib: NSObject, SRWebSocketDelegate { if socket?.readyState == .OPEN { // Support for Spring Boot 2.1.x if connectionHeaders == nil { - connectionHeaders = [StompCommands.commandHeaderAcceptVersion:"1.1,1.2"] + connectionHeaders = [StompCommands.commandHeaderAcceptVersion: "1.1,1.2"] } else { connectionHeaders?[StompCommands.commandHeaderAcceptVersion] = "1.1,1.2" } @@ -148,111 +115,63 @@ public class StompClientLib: NSObject, SRWebSocketDelegate { self.openSocket() } } - + public func webSocket(_ webSocket: SRWebSocket!, didReceiveMessage message: Any!) { - - func processString(string: String) { - var contents = string.components(separatedBy: "\n") - if contents.first == "" { - contents.removeFirst() - } - - if let command = contents.first { - var headers = [String: String]() - var body = "" - var hasHeaders = false - - contents.removeFirst() - for line in contents { - if hasHeaders == true { - body += line - } else { - if line == "" { - hasHeaders = true - } else { - let parts = line.components(separatedBy: ":") - if let key = parts.first { - headers[key] = parts.dropFirst().joined(separator: ":") - } - } - } - } - - // Remove the garbage from body - if body.hasSuffix("\0") { - body = body.replacingOccurrences(of: "\0", with: "") - } - - receiveFrame(command: command, headers: headers, body: body) + func handle(string: String) { + if let frame = StompFrame.decode(string) { + receiveFrame(command: frame.command, headers: frame.headers, body: frame.body) } } - + if let strData = message as? NSData { if let msg = String(data: strData as Data, encoding: String.Encoding.utf8) { - processString(string: msg) + handle(string: msg) } } else if let str = message as? String { - processString(string: str) + handle(string: str) } } - + public func webSocketDidOpen(_ webSocket: SRWebSocket!) { - print("WebSocket is connected") + StompClientLib.log("WebSocket is connected") connect() } - + public func webSocket(_ webSocket: SRWebSocket!, didFailWithError error: Error!) { - print("didFailWithError: \(String(describing: error))") - + StompClientLib.log("didFailWithError: \(String(describing: error))") + // The socket failed, so we are no longer connected. Without this the + // auto-reconnect timer (which only fires while !isConnected) never runs. + connection = false + if let delegate = delegate { + let nsError = error as NSError? DispatchQueue.main.async(execute: { - delegate.serverDidSendError(client: self, withErrorMessage: error.localizedDescription, detailedErrorMessage: error as? String) + delegate.serverDidSendError(client: self, + withErrorMessage: error.localizedDescription, + detailedErrorMessage: nsError?.debugDescription) }) } } - + public func webSocket(_ webSocket: SRWebSocket!, didCloseWithCode code: Int, reason: String!, wasClean: Bool) { - print("didCloseWithCode \(code), reason: \(String(describing: reason))") + StompClientLib.log("didCloseWithCode \(code), reason: \(String(describing: reason))") + // A server-initiated close means we are no longer connected (issue #123). + connection = false if let delegate = delegate { DispatchQueue.main.async(execute: { delegate.stompClientDidDisconnect(client: self) }) } } - + public func webSocket(_ webSocket: SRWebSocket!, didReceivePong pongPayload: Data!) { - print("didReceivePong") + StompClientLib.log("didReceivePong") } - + private func sendFrame(command: String?, header: [String: String]?, body: AnyObject?) { if socket?.readyState == .OPEN { - var frameString = "" - if command != nil { - frameString = command! + "\n" - } - - if let header = header { - for (key, value) in header { - frameString += key - frameString += ":" - frameString += value - frameString += "\n" - } - } - - if let body = body as? String { - frameString += "\n" - frameString += body - } else if let _ = body as? NSData { - - } - - if body == nil { - frameString += "\n" - } - - frameString += StompCommands.controlChar - + let frameString = StompFrame.encode(command: command, headers: header, body: body as? String) + if socket?.readyState == .OPEN { socket?.send(frameString) } else { @@ -264,17 +183,11 @@ public class StompClientLib: NSObject, SRWebSocketDelegate { } } } - + private func destinationFromHeader(header: [String: String]) -> String { - for (key, _) in header { - if key == "destination" { - let destination = header[key]! - return destination - } - } - return "" + return header[StompCommands.commandHeaderDestination] ?? "" } - + private func dictForJSONString(jsonStr: String?) -> AnyObject? { if let jsonStr = jsonStr { do { @@ -283,19 +196,19 @@ public class StompClientLib: NSObject, SRWebSocketDelegate { return json as AnyObject } } catch { - print("error serializing JSON: \(error)") + StompClientLib.log("error serializing JSON: \(error)") } } return nil } - + private func receiveFrame(command: String, headers: [String: String], body: String?) { if command == StompCommands.responseFrameConnected { // Connected if let sessId = headers[StompCommands.responseHeaderSession] { sessionId = sessId } - + if let delegate = delegate { DispatchQueue.main.async(execute: { delegate.stompClientDidConnect(client: self) @@ -305,7 +218,11 @@ public class StompClientLib: NSObject, SRWebSocketDelegate { // Response if let delegate = delegate { DispatchQueue.main.async(execute: { - delegate.stompClient(client: self, didReceiveMessageWithJSONBody: self.dictForJSONString(jsonStr: body), akaStringBody: body, withHeader: headers, withDestination: self.destinationFromHeader(header: headers)) + delegate.stompClient(client: self, + didReceiveMessageWithJSONBody: self.dictForJSONString(jsonStr: body), + akaStringBody: body, + withHeader: headers, + withDestination: self.destinationFromHeader(header: headers)) }) } } else if command == StompCommands.responseFrameReceipt { // @@ -317,7 +234,7 @@ public class StompClientLib: NSObject, SRWebSocketDelegate { }) } } - } else if command.count == 0 { + } else if command.isEmpty { // Pong from the server socket?.send(StompCommands.commandPing) if let delegate = delegate { @@ -336,38 +253,38 @@ public class StompClientLib: NSObject, SRWebSocketDelegate { } } } - + public func sendMessage(message: String, toDestination destination: String, withHeaders headers: [String: String]?, withReceipt receipt: String?) { var headersToSend = [String: String]() if let headers = headers { headersToSend = headers } - + // Setting up the receipt. if let receipt = receipt { headersToSend[StompCommands.commandHeaderReceipt] = receipt } - + headersToSend[StompCommands.commandHeaderDestination] = destination - + // Setting up the content length. let contentLength = message.utf8.count headersToSend[StompCommands.commandHeaderContentLength] = "\(contentLength)" - + // Setting up content type as plain text. if headersToSend[StompCommands.commandHeaderContentType] == nil { headersToSend[StompCommands.commandHeaderContentType] = "text/plain" } sendFrame(command: StompCommands.commandSend, header: headersToSend, body: message as AnyObject) } - + /* Main Connection Check Method */ - public func isConnected() -> Bool{ + public func isConnected() -> Bool { return connection } - + /* Main Subscribe Method with topic name */ @@ -375,33 +292,22 @@ public class StompClientLib: NSObject, SRWebSocketDelegate { connection = true subscribeToDestination(destination: destination, ackMode: .AutoMode) } - + public func subscribeToDestination(destination: String, ackMode: StompAckMode) { - var ack = "" - switch ackMode { - case StompAckMode.ClientMode: - ack = StompCommands.ackClient - break - case StompAckMode.ClientIndividualMode: - ack = StompCommands.ackClientIndividual - break - default: - ack = StompCommands.ackAuto - break - } + let ack = StompFrame.ackHeaderValue(for: ackMode) var headers = [StompCommands.commandHeaderDestination: destination, StompCommands.commandHeaderAck: ack, StompCommands.commandHeaderDestinationId: ""] if destination != "" { headers = [StompCommands.commandHeaderDestination: destination, StompCommands.commandHeaderAck: ack, StompCommands.commandHeaderDestinationId: destination] } self.sendFrame(command: StompCommands.commandSubscribe, header: headers, body: nil) } - + public func subscribeWithHeader(destination: String, withHeader header: [String: String]) { var headerToSend = header headerToSend[StompCommands.commandHeaderDestination] = destination sendFrame(command: StompCommands.commandSubscribe, header: headerToSend, body: nil) } - + /* Main Unsubscribe Method with topic name */ @@ -411,83 +317,96 @@ public class StompClientLib: NSObject, SRWebSocketDelegate { headerToSend[StompCommands.commandHeaderDestinationId] = destination sendFrame(command: StompCommands.commandUnsubscribe, header: headerToSend, body: nil) } - + public func begin(transactionId: String) { var headerToSend = [String: String]() headerToSend[StompCommands.commandHeaderTransaction] = transactionId sendFrame(command: StompCommands.commandBegin, header: headerToSend, body: nil) } - + public func commit(transactionId: String) { var headerToSend = [String: String]() headerToSend[StompCommands.commandHeaderTransaction] = transactionId sendFrame(command: StompCommands.commandCommit, header: headerToSend, body: nil) } - + public func abort(transactionId: String) { var headerToSend = [String: String]() headerToSend[StompCommands.commandHeaderTransaction] = transactionId sendFrame(command: StompCommands.commandAbort, header: headerToSend, body: nil) } - + public func ack(messageId: String) { var headerToSend = [String: String]() headerToSend[StompCommands.commandHeaderMessageId] = messageId sendFrame(command: StompCommands.commandAck, header: headerToSend, body: nil) } - + public func ack(messageId: String, withSubscription subscription: String) { var headerToSend = [String: String]() headerToSend[StompCommands.commandHeaderMessageId] = messageId headerToSend[StompCommands.commandHeaderSubscription] = subscription sendFrame(command: StompCommands.commandAck, header: headerToSend, body: nil) } - + /* Main Disconnection Method to close the socket */ public func disconnect() { connection = false var headerToSend = [String: String]() - headerToSend[StompCommands.commandDisconnect] = String(Int(NSDate().timeIntervalSince1970)) + headerToSend[StompCommands.commandDisconnect] = String(Int(Date().timeIntervalSince1970)) sendFrame(command: StompCommands.commandDisconnect, header: headerToSend, body: nil) // Close the socket to allow recreation self.closeSocket() } - - // Reconnect after one sec or arg, if reconnect is available - // TODO: MAKE A VARIABLE TO CHECK RECONNECT OPTION IS AVAILABLE OR NOT - public func reconnect(request: NSURLRequest, delegate: StompClientLibDelegate, connectionHeaders: [String: String] = [String: String](), time: Double = 1.0, exponentialBackoff: Bool = true){ - if #available(iOS 10.0, *) { - reconnectTimer = Timer.scheduledTimer(withTimeInterval: time, repeats: true, block: { _ in - self.reconnectLogic(request: request, delegate: delegate - , connectionHeaders: connectionHeaders) - }) - } else { - // Fallback on earlier versions - // Swift >=3 selector syntax - // Timer.scheduledTimer(timeInterval: 1.0, target: self, selector: #selector(self.reconnectFallback), userInfo: nil, repeats: true) - print("Reconnect Feature has no support for below iOS 10, it is going to be available soon!") + + /// Auto reconnect after the given interval while the connection is down. + /// + /// When `exponentialBackoff` is `true` (the default) the wait between + /// attempts grows geometrically from `time` (1s, 2s, 4s, …) up to a 60s + /// cap and resets once a connection is re-established. Pass `false` to keep + /// the legacy fixed-interval behaviour. Call `stopReconnect()` to cancel. + public func reconnect(request: NSURLRequest, delegate: StompClientLibDelegate, connectionHeaders: [String: String] = [String: String](), time: Double = 1.0, exponentialBackoff: Bool = true) { + reconnectAttempts = 0 + scheduleReconnect(request: request, delegate: delegate, connectionHeaders: connectionHeaders, time: time, exponentialBackoff: exponentialBackoff) + } + + private func scheduleReconnect(request: NSURLRequest, delegate: StompClientLibDelegate, connectionHeaders: [String: String], time: Double, exponentialBackoff: Bool) { + reconnectTimer?.invalidate() + let interval: TimeInterval = exponentialBackoff + ? min(time * pow(2.0, Double(reconnectAttempts)), StompClientLib.maxReconnectInterval) + : time + reconnectTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in + guard let self = self else { return } + if self.isConnected() { + self.reconnectAttempts = 0 + } else { + self.reconnectAttempts += 1 + self.reconnectLogic(request: request, delegate: delegate, connectionHeaders: connectionHeaders) + } + self.scheduleReconnect(request: request, delegate: delegate, connectionHeaders: connectionHeaders, time: time, exponentialBackoff: exponentialBackoff) } } - // @objc func reconnectFallback() { - // reconnectLogic(request: request, delegate: delegate, connectionHeaders: connectionHeaders) - // } - - private func reconnectLogic(request: NSURLRequest, delegate: StompClientLibDelegate, connectionHeaders: [String: String] = [String: String]()){ + + private func reconnectLogic(request: NSURLRequest, delegate: StompClientLibDelegate, connectionHeaders: [String: String] = [String: String]()) { // Check if connection is alive or dead - if (!self.isConnected()){ - self.checkConnectionHeader(connectionHeaders: connectionHeaders) ? self.openSocketWithURLRequest(request: request, delegate: delegate, connectionHeaders: connectionHeaders) : self.openSocketWithURLRequest(request: request, delegate: delegate) + guard !self.isConnected() else { return } + if checkConnectionHeader(connectionHeaders: connectionHeaders) { + openSocketWithURLRequest(request: request, delegate: delegate, connectionHeaders: connectionHeaders) + } else { + openSocketWithURLRequest(request: request, delegate: delegate) } } - + public func stopReconnect() { reconnectTimer?.invalidate() reconnectTimer = nil + reconnectAttempts = 0 } - - private func checkConnectionHeader(connectionHeaders: [String: String] = [String: String]()) -> Bool{ - if (connectionHeaders.isEmpty){ + + private func checkConnectionHeader(connectionHeaders: [String: String] = [String: String]()) -> Bool { + if connectionHeaders.isEmpty { // No connection header return false } else { @@ -495,13 +414,12 @@ public class StompClientLib: NSObject, SRWebSocketDelegate { return true } } - + // Autodisconnect with a given time - public func autoDisconnect(time: Double){ + public func autoDisconnect(time: Double) { DispatchQueue.main.asyncAfter(deadline: .now() + time) { // Disconnect the socket self.disconnect() } } } - diff --git a/StompClientLib/Classes/StompFrame.swift b/StompClientLib/Classes/StompFrame.swift new file mode 100644 index 0000000..943fb56 --- /dev/null +++ b/StompClientLib/Classes/StompFrame.swift @@ -0,0 +1,147 @@ +// +// StompFrame.swift +// StompClientLib +// +// Pure, transport-agnostic STOMP frame encoding/decoding helpers. +// Depends only on Foundation, so it stays unit-testable without a live +// WebSocket and without UIKit. +// + +import Foundation + +struct StompCommands { + // Basic Commands + static let commandConnect = "CONNECT" + static let commandSend = "SEND" + static let commandSubscribe = "SUBSCRIBE" + static let commandUnsubscribe = "UNSUBSCRIBE" + static let commandBegin = "BEGIN" + static let commandCommit = "COMMIT" + static let commandAbort = "ABORT" + static let commandAck = "ACK" + static let commandDisconnect = "DISCONNECT" + static let commandPing = "\n" + + static let controlChar = String(format: "%C", arguments: [0x00]) + + // Ack Mode + static let ackClientIndividual = "client-individual" + static let ackClient = "client" + static let ackAuto = "auto" + // Header Commands + static let commandHeaderReceipt = "receipt" + static let commandHeaderDestination = "destination" + static let commandHeaderDestinationId = "id" + static let commandHeaderContentLength = "content-length" + static let commandHeaderContentType = "content-type" + static let commandHeaderAck = "ack" + static let commandHeaderTransaction = "transaction" + static let commandHeaderMessageId = "id" + static let commandHeaderSubscription = "subscription" + static let commandHeaderDisconnected = "disconnected" + static let commandHeaderHeartBeat = "heart-beat" + static let commandHeaderAcceptVersion = "accept-version" + // Header Response Keys + static let responseHeaderSession = "session" + static let responseHeaderReceiptId = "receipt-id" + static let responseHeaderErrorMessage = "message" + // Frame Response Keys + static let responseFrameConnected = "CONNECTED" + static let responseFrameMessage = "MESSAGE" + static let responseFrameReceipt = "RECEIPT" + static let responseFrameError = "ERROR" +} + +public enum StompAckMode { + case AutoMode + case ClientMode + case ClientIndividualMode +} + +/// Pure helpers for building and parsing STOMP frames. Exposed publicly so the +/// wire format can be unit-tested (and reused) independently of the WebSocket +/// transport. The behaviour mirrors what `StompClientLib` sends/receives. +public enum StompFrame { + + /// Builds a STOMP frame string for the given command, headers and optional + /// text body, terminated by the STOMP NULL control character. + public static func encode(command: String?, headers: [String: String]?, body: String?) -> String { + var frame = "" + + if let command = command { + frame += command + "\n" + } + + if let headers = headers { + for (key, value) in headers { + frame += key + frame += ":" + frame += value + frame += "\n" + } + } + + if let body = body { + frame += "\n" + frame += body + } else { + frame += "\n" + } + + frame += StompCommands.controlChar + return frame + } + + /// Parses a raw STOMP frame string into its command, headers and body. + /// Header values that themselves contain ':' are preserved (e.g. URLs). + /// Returns `nil` if no command line can be found. + public static func decode(_ string: String) -> (command: String, headers: [String: String], body: String)? { + var contents = string.components(separatedBy: "\n") + if contents.first == "" { + contents.removeFirst() + } + + guard let command = contents.first else { + return nil + } + + var headers = [String: String]() + var body = "" + var hasHeaders = false + + contents.removeFirst() + for line in contents { + if hasHeaders == true { + body += line + } else { + if line == "" { + hasHeaders = true + } else { + let parts = line.components(separatedBy: ":") + if let key = parts.first { + headers[key] = parts.dropFirst().joined(separator: ":") + } + } + } + } + + // Remove the trailing NULL control character from the body, if present. + if body.hasSuffix("\0") { + body = body.replacingOccurrences(of: "\0", with: "") + } + + return (command, headers, body) + } + + /// Maps a public `StompAckMode` to its STOMP `ack` header value. + public static func ackHeaderValue(for mode: StompAckMode) -> String { + switch mode { + case .ClientMode: + return StompCommands.ackClient + case .ClientIndividualMode: + return StompCommands.ackClientIndividual + case .AutoMode: + return StompCommands.ackAuto + } + } +} diff --git a/StompClientLibExample/Podfile b/StompClientLibExample/Podfile index 8fa83fd..4844b1d 100644 --- a/StompClientLibExample/Podfile +++ b/StompClientLibExample/Podfile @@ -1,5 +1,4 @@ -# Uncomment the next line to define a global platform for your project -# platform :ios, '9.0' +platform :ios, '12.0' target 'StompClientLibExample' do # Comment the next line if you don't want to use dynamic frameworks @@ -8,8 +7,6 @@ target 'StompClientLibExample' do # Pods for StompClientLibExample pod 'StompClientLib', :path => '../' - - target 'StompClientLibExampleTests' do inherit! :search_paths # Pods for testing @@ -21,3 +18,13 @@ target 'StompClientLibExample' do end end + +# Ensure every pod (notably SocketRocket) builds with a modern deployment +# target so recent Xcode toolchains (which dropped libarclite) can compile them. +post_install do |installer| + installer.pods_project.targets.each do |target| + target.build_configurations.each do |config| + config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '12.0' + end + end +end diff --git a/StompClientLibExample/StompClientLibExample.xcodeproj/project.pbxproj b/StompClientLibExample/StompClientLibExample.xcodeproj/project.pbxproj index 7fb3d57..4be2ffb 100644 --- a/StompClientLibExample/StompClientLibExample.xcodeproj/project.pbxproj +++ b/StompClientLibExample/StompClientLibExample.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 4D7A2EDF252F0A3400C25C02 /* StompClientLib.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D7A2EDE252F0A3400C25C02 /* StompClientLib.swift */; }; + 4D7A2EE0252F0A3400C25C02 /* StompFrame.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D7A2EE1252F0A3400C25C02 /* StompFrame.swift */; }; 4DDC24BE252EFF0200E14704 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DDC24BD252EFF0200E14704 /* AppDelegate.swift */; }; 4DDC24C0252EFF0200E14704 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DDC24BF252EFF0200E14704 /* SceneDelegate.swift */; }; 4DDC24C2252EFF0200E14704 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DDC24C1252EFF0200E14704 /* ViewController.swift */; }; @@ -44,6 +45,7 @@ 2420D6AC0CC10C10A0D9CB8C /* Pods-StompClientLibExampleTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-StompClientLibExampleTests.debug.xcconfig"; path = "Target Support Files/Pods-StompClientLibExampleTests/Pods-StompClientLibExampleTests.debug.xcconfig"; sourceTree = ""; }; 4CB0E35D0539542EEC8D550E /* Pods-StompClientLibExample.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-StompClientLibExample.release.xcconfig"; path = "Target Support Files/Pods-StompClientLibExample/Pods-StompClientLibExample.release.xcconfig"; sourceTree = ""; }; 4D7A2EDE252F0A3400C25C02 /* StompClientLib.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = StompClientLib.swift; path = ../StompClientLib/Classes/StompClientLib.swift; sourceTree = ""; }; + 4D7A2EE1252F0A3400C25C02 /* StompFrame.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = StompFrame.swift; path = ../StompClientLib/Classes/StompFrame.swift; sourceTree = ""; }; 4DDC24BA252EFF0200E14704 /* StompClientLibExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = StompClientLibExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 4DDC24BD252EFF0200E14704 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 4DDC24BF252EFF0200E14704 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; @@ -107,6 +109,7 @@ isa = PBXGroup; children = ( 4D7A2EDE252F0A3400C25C02 /* StompClientLib.swift */, + 4D7A2EE1252F0A3400C25C02 /* StompFrame.swift */, 4DDC24BC252EFF0200E14704 /* StompClientLibExample */, 4DDC24D3252EFF0400E14704 /* StompClientLibExampleTests */, 4DDC24DE252EFF0400E14704 /* StompClientLibExampleUITests */, @@ -428,6 +431,7 @@ buildActionMask = 2147483647; files = ( 4D7A2EDF252F0A3400C25C02 /* StompClientLib.swift in Sources */, + 4D7A2EE0252F0A3400C25C02 /* StompFrame.swift in Sources */, 4DDC24C2252EFF0200E14704 /* ViewController.swift in Sources */, 4DDC24BE252EFF0200E14704 /* AppDelegate.swift in Sources */, 4DDC24C0252EFF0200E14704 /* SceneDelegate.swift in Sources */, diff --git a/StompClientLibExample/StompClientLibExample.xcodeproj/xcshareddata/xcschemes/StompClientLibExample.xcscheme b/StompClientLibExample/StompClientLibExample.xcodeproj/xcshareddata/xcschemes/StompClientLibExample.xcscheme new file mode 100644 index 0000000..cdaccfe --- /dev/null +++ b/StompClientLibExample/StompClientLibExample.xcodeproj/xcshareddata/xcschemes/StompClientLibExample.xcscheme @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/StompClientLibExample/StompClientLibExampleTests/StompClientLibExampleTests.swift b/StompClientLibExample/StompClientLibExampleTests/StompClientLibExampleTests.swift index cbdc4bc..c9e8a31 100644 --- a/StompClientLibExample/StompClientLibExampleTests/StompClientLibExampleTests.swift +++ b/StompClientLibExample/StompClientLibExampleTests/StompClientLibExampleTests.swift @@ -2,32 +2,107 @@ // StompClientLibExampleTests.swift // StompClientLibExampleTests // -// Created by Kuray on 8.10.2020. +// STOMP wire-format unit tests for StompClientLib. These exercise the pure +// frame encode/decode/ack helpers that back the public client, without +// needing a live WebSocket connection. // import XCTest -@testable import StompClientLibExample +import StompClientLib class StompClientLibExampleTests: XCTestCase { - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. + private let NUL = "\u{00}" + + // MARK: encode + + func testEncodeCommandHeadersAndBody() { + let frame = StompFrame.encode(command: "SEND", + headers: ["destination": "/app/hello"], + body: "hi") + XCTAssertTrue(frame.hasPrefix("SEND\n")) + XCTAssertTrue(frame.contains("destination:/app/hello\n")) + XCTAssertTrue(frame.contains("\nhi")) + XCTAssertTrue(frame.hasSuffix(NUL)) + } + + func testEncodeNilBodyStillAddsBlankLineAndTerminator() { + let frame = StompFrame.encode(command: "DISCONNECT", headers: ["id": "1"], body: nil) + XCTAssertEqual(frame, "DISCONNECT\nid:1\n\n" + NUL) + } + + func testEncodeNilCommandOmitsCommandLine() { + let frame = StompFrame.encode(command: nil, headers: nil, body: nil) + XCTAssertEqual(frame, "\n" + NUL) } - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. + // MARK: decode + + func testDecodeBasicFrame() { + let raw = "MESSAGE\ndestination:/topic/x\nmessage-id:42\n\nhello world" + NUL + let frame = StompFrame.decode(raw) + XCTAssertEqual(frame?.command, "MESSAGE") + XCTAssertEqual(frame?.headers["destination"], "/topic/x") + XCTAssertEqual(frame?.headers["message-id"], "42") + XCTAssertEqual(frame?.body, "hello world") + } + + func testDecodeStripsLeadingEmptyLine() { + let raw = "\nCONNECTED\nsession:abc\n\n" + NUL + XCTAssertEqual(StompFrame.decode(raw)?.command, "CONNECTED") + XCTAssertEqual(StompFrame.decode(raw)?.headers["session"], "abc") } - func testExample() throws { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct results. + // Regression: header values that contain ':' must be preserved (PR #56). + func testDecodeKeepsColonInHeaderValue() { + let raw = "MESSAGE\nlocation:ws://example.com:8080/path\n\nbody" + NUL + let frame = StompFrame.decode(raw) + XCTAssertEqual(frame?.headers["location"], "ws://example.com:8080/path") } - func testPerformanceExample() throws { - // This is an example of a performance test case. - self.measure { - // Put the code you want to measure the time of here. - } + func testDecodeStripsTrailingNullFromBody() { + let raw = "MESSAGE\n\npayload" + NUL + XCTAssertEqual(StompFrame.decode(raw)?.body, "payload") + XCTAssertFalse(StompFrame.decode(raw)?.body.contains(NUL) ?? true) } + func testDecodeEmptyStringReturnsNil() { + XCTAssertNil(StompFrame.decode("")) + } + + func testDecodePingFrameHasEmptyCommand() { + // A server heart-beat is a lone newline; decode yields an empty command, + // which the client treats as a pong. + XCTAssertEqual(StompFrame.decode("\n")?.command, "") + } + + // MARK: round trip + + func testEncodeDecodeRoundTrip() { + let headers = ["destination": "/queue/a", "content-type": "text/plain"] + let encoded = StompFrame.encode(command: "SEND", headers: headers, body: "payload") + let decoded = StompFrame.decode(encoded) + XCTAssertEqual(decoded?.command, "SEND") + XCTAssertEqual(decoded?.headers["destination"], "/queue/a") + XCTAssertEqual(decoded?.headers["content-type"], "text/plain") + XCTAssertEqual(decoded?.body, "payload") + } + + // MARK: ack mode mapping + + func testAckHeaderValueMapping() { + XCTAssertEqual(StompFrame.ackHeaderValue(for: .AutoMode), "auto") + XCTAssertEqual(StompFrame.ackHeaderValue(for: .ClientMode), "client") + XCTAssertEqual(StompFrame.ackHeaderValue(for: .ClientIndividualMode), "client-individual") + } + + // MARK: client defaults + + func testLoggingDisabledByDefault() { + XCTAssertFalse(StompClientLib.isLoggingEnabled) + } + + func testFreshClientIsNotConnected() { + XCTAssertFalse(StompClientLib().isConnected()) + } }