init
This commit is contained in:
40
Sources/Benchmark/main.swift
Normal file
40
Sources/Benchmark/main.swift
Normal file
@@ -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..<numMsgs {
|
||||
try! await nats.publish(data, subject: "foo")
|
||||
}
|
||||
try! await nats.flush()
|
||||
let elapsed = DispatchTime.now().uptimeNanoseconds - now.uptimeNanoseconds
|
||||
let msgsPerSec: Double = Double(numMsgs) / (Double(elapsed) / 1_000_000_000)
|
||||
print("Elapsed: \(elapsed / 1_000_000)ms")
|
||||
print("\(msgsPerSec) msgs/s")
|
||||
82
Sources/BenchmarkPubSub/main.swift
Normal file
82
Sources/BenchmarkPubSub/main.swift
Normal file
@@ -0,0 +1,82 @@
|
||||
// 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().urls([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
|
||||
let sub = try await nats.subscribe(subject: "foo")
|
||||
try await withThrowingTaskGroup(of: Void.self) { group in
|
||||
group.addTask {
|
||||
var hm = NatsHeaderMap()
|
||||
hm.append(try! NatsHeaderName("foo"), NatsHeaderValue("bar"))
|
||||
hm.append(try! NatsHeaderName("foo"), NatsHeaderValue("baz"))
|
||||
hm.insert(try! NatsHeaderName("another"), NatsHeaderValue("one"))
|
||||
var i = 0
|
||||
for try await msg in sub {
|
||||
let payload = msg.payload!
|
||||
if String(data: payload, encoding: .utf8) != "\(i)" {
|
||||
let emptyString = ""
|
||||
print(
|
||||
"invalid payload; expected: \(i); got: \(String(data: payload, encoding: .utf8) ?? emptyString)"
|
||||
)
|
||||
}
|
||||
guard let headers = msg.headers else {
|
||||
print("empty headers!")
|
||||
continue
|
||||
}
|
||||
if headers != hm {
|
||||
print("invalid headers; expected: \(hm); got: \(headers)")
|
||||
}
|
||||
if i % 1000 == 0 {
|
||||
print("received \(i) msgs")
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
|
||||
group.addTask {
|
||||
var hm = NatsHeaderMap()
|
||||
hm.append(try! NatsHeaderName("foo"), NatsHeaderValue("bar"))
|
||||
hm.append(try! NatsHeaderName("foo"), NatsHeaderValue("baz"))
|
||||
hm.insert(try! NatsHeaderName("another"), NatsHeaderValue("one"))
|
||||
for i in 0..<numMsgs {
|
||||
try await nats.publish("\(i)".data(using: .utf8)!, subject: "foo", headers: hm)
|
||||
if i % 1000 == 0 {
|
||||
print("published \(i) msgs")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all tasks in the group to complete
|
||||
try await group.waitForAll()
|
||||
}
|
||||
|
||||
try! await nats.flush()
|
||||
let elapsed = DispatchTime.now().uptimeNanoseconds - now.uptimeNanoseconds
|
||||
let msgsPerSec: Double = Double(numMsgs) / (Double(elapsed) / 1_000_000_000)
|
||||
print("Elapsed: \(elapsed / 1_000_000)ms")
|
||||
print("\(msgsPerSec) msgs/s")
|
||||
60
Sources/BenchmarkSub/main.swift
Normal file
60
Sources/BenchmarkSub/main.swift
Normal file
@@ -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
|
||||
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("Starting benchmark...")
|
||||
print("Waiting for first message...")
|
||||
var now = DispatchTime.now()
|
||||
let numMsgs = 1_000_000
|
||||
let sub = try await nats.subscribe(subject: "foo").makeAsyncIterator()
|
||||
var hm = NatsHeaderMap()
|
||||
hm.append(try! NatsHeaderName("foo"), NatsHeaderValue("bar"))
|
||||
hm.append(try! NatsHeaderName("foo"), NatsHeaderValue("baz"))
|
||||
for i in 1...numMsgs {
|
||||
let msg = try await sub.next()
|
||||
if i == 0 {
|
||||
print("Received first message! Starting the timer")
|
||||
now = DispatchTime.now()
|
||||
}
|
||||
guard let payload = msg?.payload else {
|
||||
print("empty payload!")
|
||||
continue
|
||||
}
|
||||
if String(data: payload, encoding: .utf8) != "\(i)" {
|
||||
let emptyString = ""
|
||||
print(
|
||||
"invalid payload; expected: \(i); got: \(String(data: payload, encoding: .utf8) ?? emptyString)"
|
||||
)
|
||||
}
|
||||
guard msg?.headers != nil else {
|
||||
print("empty headers!")
|
||||
continue
|
||||
}
|
||||
if i % 1000 == 0 {
|
||||
print("received \(i) msgs")
|
||||
}
|
||||
}
|
||||
|
||||
let elapsed = DispatchTime.now().uptimeNanoseconds - now.uptimeNanoseconds
|
||||
let msgsPerSec: Double = Double(numMsgs) / (Double(elapsed) / 1_000_000_000)
|
||||
print("Elapsed: \(elapsed / 1_000_000)ms")
|
||||
print("\(msgsPerSec) msgs/s")
|
||||
76
Sources/Example/main.swift
Executable file
76
Sources/Example/main.swift
Executable file
@@ -0,0 +1,76 @@
|
||||
// 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
|
||||
|
||||
print("\n### Setup NATS Connection")
|
||||
|
||||
let nats = NatsClientOptions()
|
||||
.url(URL(string: "nats://localhost:4222")!)
|
||||
.build()
|
||||
|
||||
nats.on(.connected) { event in
|
||||
print("event: connected")
|
||||
}
|
||||
|
||||
print("connecting...")
|
||||
try await nats.connect()
|
||||
|
||||
print("\n### Publish / Subscribe")
|
||||
|
||||
print("subscribing...")
|
||||
let sub = try await nats.subscribe(subject: "foo.>")
|
||||
|
||||
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")
|
||||
220
Sources/JetStream/Consumer+Pull.swift
Normal file
220
Sources/JetStream/Consumer+Pull.swift
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
379
Sources/JetStream/Consumer.swift
Normal file
379
Sources/JetStream/Consumer.swift
Normal file
@@ -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<ConsumerInfo> = 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
|
||||
}
|
||||
322
Sources/JetStream/JetStreamContext+Consumer.swift
Normal file
322
Sources/JetStream/JetStreamContext+Consumer.swift
Normal file
@@ -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<ConsumerInfo> = 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<ConsumerDeleteResponse> = 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<ConsumerInfo> = 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<ConsumerNamesPage> = 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<ConsumersPage> = 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
291
Sources/JetStream/JetStreamContext+Stream.swift
Normal file
291
Sources/JetStream/JetStreamContext+Stream.swift
Normal file
@@ -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<StreamInfo> = 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<StreamInfo> = 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<StreamInfo> = 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<StreamDeleteResponse> = 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<StreamsInfoPage> = 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<StreamNamesPage> = 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
234
Sources/JetStream/JetStreamContext.swift
Normal file
234
Sources/JetStream/JetStreamContext.swift
Normal file
@@ -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<T: Codable>(
|
||||
_ subject: String, message: Data? = nil
|
||||
) async throws -> Response<T> {
|
||||
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<T>.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<Ack>.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
|
||||
}
|
||||
863
Sources/JetStream/JetStreamError.swift
Normal file
863
Sources/JetStream/JetStreamError.swift
Normal file
@@ -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<T: Codable>: 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
193
Sources/JetStream/JetStreamMessage.swift
Normal file
193
Sources/JetStream/JetStreamMessage.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
39
Sources/JetStream/NanoTimeInterval.swift
Normal file
39
Sources/JetStream/NanoTimeInterval.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
118
Sources/JetStream/Stream+Consumer.swift
Normal file
118
Sources/JetStream/Stream+Consumer.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
1031
Sources/JetStream/Stream.swift
Normal file
1031
Sources/JetStream/Stream.swift
Normal file
File diff suppressed because it is too large
Load Diff
128
Sources/Nats/BatchBuffer.swift
Normal file
128
Sources/Nats/BatchBuffer.swift
Normal file
@@ -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<Void, Error>)] = []
|
||||
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<State>
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
32
Sources/Nats/ConcurrentQueue.swift
Normal file
32
Sources/Nats/ConcurrentQueue.swift
Normal file
@@ -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<T> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
87
Sources/Nats/Extensions/ByteBuffer+Writer.swift
Normal file
87
Sources/Nats/Extensions/ByteBuffer+Writer.swift
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
24
Sources/Nats/Extensions/Data+Base64.swift
Normal file
24
Sources/Nats/Extensions/Data+Base64.swift
Normal file
@@ -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: "="))
|
||||
}
|
||||
}
|
||||
184
Sources/Nats/Extensions/Data+Parser.swift
Normal file
184
Sources/Nats/Extensions/Data+Parser.swift
Normal file
@@ -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..<end].elementsEqual(separator) {
|
||||
if !omittingEmptySubsequences || start != end {
|
||||
chunks.append(self[start..<end])
|
||||
}
|
||||
start = index(end, offsetBy: separator.count)
|
||||
end = start
|
||||
splitsCount += 1
|
||||
continue
|
||||
}
|
||||
end = index(after: end)
|
||||
}
|
||||
|
||||
if start <= endIndex {
|
||||
if !omittingEmptySubsequences || start != endIndex {
|
||||
chunks.append(self[start..<endIndex])
|
||||
}
|
||||
}
|
||||
|
||||
return chunks
|
||||
}
|
||||
|
||||
func getMessageType() -> 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..<lineEndIndex]
|
||||
} else {
|
||||
remainder = self[startIndex..<self.endIndex]
|
||||
break
|
||||
}
|
||||
if lineData.count == 0 {
|
||||
startIndex = nextLineStartIndex
|
||||
continue
|
||||
}
|
||||
|
||||
let serverOp = try ServerOp.parse(from: lineData)
|
||||
|
||||
// if it's a message, get the full payload and add to returned data
|
||||
if case .message(var msg) = serverOp {
|
||||
if msg.length == 0 {
|
||||
serverOps.append(serverOp)
|
||||
} else {
|
||||
var payload = Data()
|
||||
let payloadEndIndex = nextLineStartIndex + msg.length
|
||||
let payloadStartIndex = nextLineStartIndex
|
||||
// include crlf in the expected payload leangth
|
||||
if payloadEndIndex + Data.crlf.count > endIndex {
|
||||
remainder = self[startIndex..<self.endIndex]
|
||||
break
|
||||
}
|
||||
payload.append(self[payloadStartIndex..<payloadEndIndex])
|
||||
msg.payload = payload
|
||||
startIndex =
|
||||
self.index(
|
||||
payloadEndIndex, offsetBy: Data.crlf.count, limitedBy: self.endIndex)
|
||||
?? self.endIndex
|
||||
serverOps.append(.message(msg))
|
||||
continue
|
||||
}
|
||||
//TODO(jrm): Add HMSG handling here too.
|
||||
} else if case .hMessage(var msg) = serverOp {
|
||||
if msg.length == 0 {
|
||||
serverOps.append(serverOp)
|
||||
} else {
|
||||
let headersStartIndex = nextLineStartIndex
|
||||
let headersEndIndex = nextLineStartIndex + msg.headersLength
|
||||
let payloadStartIndex = headersEndIndex
|
||||
let payloadEndIndex = nextLineStartIndex + msg.length
|
||||
|
||||
var payload: Data?
|
||||
if msg.length > 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..<self.endIndex]
|
||||
break
|
||||
}
|
||||
|
||||
let headersData = self[headersStartIndex..<headersEndIndex]
|
||||
if let headersString = String(data: headersData, encoding: .utf8) {
|
||||
headers = try NatsHeaderMap(from: headersString)
|
||||
}
|
||||
msg.status = headers.status
|
||||
msg.description = headers.description
|
||||
msg.headers = headers
|
||||
|
||||
if var payload = payload {
|
||||
payload.append(self[payloadStartIndex..<payloadEndIndex])
|
||||
msg.payload = payload
|
||||
}
|
||||
|
||||
startIndex =
|
||||
self.index(
|
||||
payloadEndIndex, offsetBy: Data.crlf.count, limitedBy: self.endIndex)
|
||||
?? self.endIndex
|
||||
serverOps.append(.hMessage(msg))
|
||||
continue
|
||||
}
|
||||
|
||||
} else {
|
||||
// otherwise, just add this server op to the result
|
||||
serverOps.append(serverOp)
|
||||
}
|
||||
startIndex = nextLineStartIndex
|
||||
|
||||
}
|
||||
|
||||
return (serverOps, remainder)
|
||||
}
|
||||
}
|
||||
24
Sources/Nats/Extensions/Data+String.swift
Normal file
24
Sources/Nats/Extensions/Data+String.swift
Normal file
@@ -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
|
||||
import NIOPosix
|
||||
|
||||
extension Data {
|
||||
func toString() -> String? {
|
||||
if let str = String(data: self, encoding: .utf8) {
|
||||
return str
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
38
Sources/Nats/Extensions/String+Utilities.swift
Normal file
38
Sources/Nats/Extensions/String+Utilities.swift
Normal file
@@ -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<Int>) -> String {
|
||||
let start = index(startIndex, offsetBy: bounds.lowerBound)
|
||||
let end = index(startIndex, offsetBy: bounds.upperBound)
|
||||
return String(self[start...end])
|
||||
}
|
||||
}
|
||||
182
Sources/Nats/HTTPUpgradeRequestHandler.swift
Normal file
182
Sources/Nats/HTTPUpgradeRequestHandler.swift
Normal file
@@ -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<Void>
|
||||
|
||||
private var requestSent = false
|
||||
|
||||
init(
|
||||
host: String, path: String, query: String?, headers: HTTPHeaders,
|
||||
upgradePromise: EventLoopPromise<Void>
|
||||
) {
|
||||
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<Void>?) {
|
||||
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<Void>? = 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)
|
||||
}
|
||||
}
|
||||
57
Sources/Nats/NatsClient/NatsClient+Events.swift
Normal file
57
Sources/Nats/NatsClient/NatsClient+Events.swift
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
352
Sources/Nats/NatsClient/NatsClient.swift
Executable file
352
Sources/Nats/NatsClient/NatsClient.swift
Executable file
@@ -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()
|
||||
}
|
||||
}
|
||||
202
Sources/Nats/NatsClient/NatsClientOptions.swift
Normal file
202
Sources/Nats/NatsClient/NatsClientOptions.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
1098
Sources/Nats/NatsConnection.swift
Normal file
1098
Sources/Nats/NatsConnection.swift
Normal file
File diff suppressed because it is too large
Load Diff
244
Sources/Nats/NatsError.swift
Normal file
244
Sources/Nats/NatsError.swift
Normal file
@@ -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 '-')"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
159
Sources/Nats/NatsHeaders.swift
Normal file
159
Sources/Nats/NatsHeaders.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
69
Sources/Nats/NatsJwtUtils.swift
Normal file
69
Sources/Nats/NatsJwtUtils.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
60
Sources/Nats/NatsMessage.swift
Normal file
60
Sources/Nats/NatsMessage.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
341
Sources/Nats/NatsProto.swift
Normal file
341
Sources/Nats/NatsProto.swift
Normal file
@@ -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 <subject> <sid> [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 <subject> <sid> [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)
|
||||
}
|
||||
}
|
||||
}
|
||||
185
Sources/Nats/NatsSubscription.swift
Normal file
185
Sources/Nats/NatsSubscription.swift
Normal file
@@ -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<Element, NatsError.SubscriptionError>]
|
||||
private let capacity: UInt64
|
||||
private var closed = false
|
||||
private var continuation:
|
||||
CheckedContinuation<Result<Element, NatsError.SubscriptionError>?, 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<Element, NatsError.SubscriptionError>? = 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
|
||||
}
|
||||
}
|
||||
39
Sources/Nats/RttCommand.swift
Normal file
39
Sources/Nats/RttCommand.swift
Normal file
@@ -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<TimeInterval>?
|
||||
|
||||
static func makeFrom(channel: Channel?) -> RttCommand {
|
||||
RttCommand(promise: channel?.eventLoop.makePromise(of: TimeInterval.self))
|
||||
}
|
||||
|
||||
private init(promise: EventLoopPromise<TimeInterval>?) {
|
||||
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
|
||||
}
|
||||
}
|
||||
206
Sources/NatsServer/NatsServer.swift
Normal file
206
Sources/NatsServer/NatsServer.swift
Normal file
@@ -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.endIndex, in: string)
|
||||
|
||||
if let match = regex.firstMatch(in: string, options: [], range: nsrange) {
|
||||
let portRange = match.range(at: 1)
|
||||
if let swiftRange = Range(portRange, in: string) {
|
||||
let portString = String(string[swiftRange])
|
||||
return Int(portString)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func extracErrorMessage(from logLine: String) -> 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"
|
||||
}
|
||||
}
|
||||
65
Sources/bench/main.swift
Normal file
65
Sources/bench/main.swift
Normal file
@@ -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 <subject> <msgs>")
|
||||
return 2
|
||||
}
|
||||
Reference in New Issue
Block a user