init
This commit is contained in:
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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user