mirror of
https://github.com/strukturag/nextcloud-spreed-signaling
synced 2026-03-14 14:35:44 +01:00
Move NATS client to nats package.
This commit is contained in:
parent
f1a719cb23
commit
5543046305
17 changed files with 484 additions and 219 deletions
136
nats/client.go
Normal file
136
nats/client.go
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
/**
|
||||
* Standalone signaling server for the Nextcloud Spreed app.
|
||||
* Copyright (C) 2025 struktur AG
|
||||
*
|
||||
* @author Joachim Bauch <bauch@struktur.de>
|
||||
*
|
||||
* @license GNU AGPL version 3 or any later version
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package nats
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/strukturag/nextcloud-spreed-signaling/async"
|
||||
"github.com/strukturag/nextcloud-spreed-signaling/log"
|
||||
)
|
||||
|
||||
const (
|
||||
initialConnectInterval = time.Second
|
||||
maxConnectInterval = 8 * time.Second
|
||||
|
||||
LoopbackUrl = "nats://loopback"
|
||||
|
||||
DefaultURL = nats.DefaultURL
|
||||
)
|
||||
|
||||
var (
|
||||
ErrConnectionClosed = nats.ErrConnectionClosed
|
||||
)
|
||||
|
||||
type Msg = nats.Msg
|
||||
|
||||
type Subscription interface {
|
||||
Unsubscribe() error
|
||||
}
|
||||
|
||||
type Client interface {
|
||||
Close(ctx context.Context) error
|
||||
|
||||
Subscribe(subject string, ch chan *Msg) (Subscription, error)
|
||||
Publish(subject string, message any) error
|
||||
}
|
||||
|
||||
// The NATS client doesn't work if a subject contains spaces. As the room id
|
||||
// can have an arbitrary format, we need to make sure the subject is valid.
|
||||
// See "https://github.com/nats-io/nats.js/issues/158" for a similar report.
|
||||
func GetEncodedSubject(prefix string, suffix string) string {
|
||||
return prefix + "." + base64.StdEncoding.EncodeToString([]byte(suffix))
|
||||
}
|
||||
|
||||
func NewClient(ctx context.Context, url string, options ...nats.Option) (Client, error) {
|
||||
logger := log.LoggerFromContext(ctx)
|
||||
if url == ":loopback:" {
|
||||
logger.Printf("WARNING: events url %s is deprecated, please use %s instead", url, LoopbackUrl)
|
||||
url = LoopbackUrl
|
||||
}
|
||||
if url == LoopbackUrl {
|
||||
logger.Println("Using internal NATS loopback client")
|
||||
return NewLoopbackClient(logger)
|
||||
}
|
||||
|
||||
backoff, err := async.NewExponentialBackoff(initialConnectInterval, maxConnectInterval)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := &NativeClient{
|
||||
logger: logger,
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
|
||||
options = append([]nats.Option{
|
||||
nats.ClosedHandler(client.onClosed),
|
||||
nats.DisconnectHandler(client.onDisconnected),
|
||||
nats.ReconnectHandler(client.onReconnected),
|
||||
nats.MaxReconnects(-1),
|
||||
}, options...)
|
||||
client.conn, err = nats.Connect(url, options...)
|
||||
|
||||
ctx, stop := signal.NotifyContext(ctx, os.Interrupt)
|
||||
defer stop()
|
||||
|
||||
// The initial connect must succeed, so we retry in the case of an error.
|
||||
for err != nil {
|
||||
logger.Printf("Could not create connection (%s), will retry in %s", err, backoff.NextWait())
|
||||
backoff.Wait(ctx)
|
||||
if ctx.Err() != nil {
|
||||
return nil, errors.New("interrupted")
|
||||
}
|
||||
|
||||
client.conn, err = nats.Connect(url)
|
||||
}
|
||||
logger.Printf("Connection established to %s (%s)", removeURLCredentials(client.conn.ConnectedUrl()), client.conn.ConnectedServerId())
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func Decode(msg *nats.Msg, vPtr any) (err error) {
|
||||
switch arg := vPtr.(type) {
|
||||
case *string:
|
||||
// If they want a string and it is a JSON string, strip quotes
|
||||
// This allows someone to send a struct but receive as a plain string
|
||||
// This cast should be efficient for Go 1.3 and beyond.
|
||||
str := string(msg.Data)
|
||||
if strings.HasPrefix(str, `"`) && strings.HasSuffix(str, `"`) {
|
||||
*arg = str[1 : len(str)-1]
|
||||
} else {
|
||||
*arg = str
|
||||
}
|
||||
case *[]byte:
|
||||
*arg = msg.Data
|
||||
default:
|
||||
err = json.Unmarshal(msg.Data, arg)
|
||||
}
|
||||
return
|
||||
}
|
||||
159
nats/client_test.go
Normal file
159
nats/client_test.go
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
/**
|
||||
* Standalone signaling server for the Nextcloud Spreed app.
|
||||
* Copyright (C) 2026 struktur AG
|
||||
*
|
||||
* @author Joachim Bauch <bauch@struktur.de>
|
||||
*
|
||||
* @license GNU AGPL version 3 or any later version
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package nats
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestGetEncodedSubject(t *testing.T) {
|
||||
t.Parallel()
|
||||
assert := assert.New(t)
|
||||
|
||||
encoded := GetEncodedSubject("foo", "this is the subject")
|
||||
assert.NotContains(encoded, " ")
|
||||
|
||||
encoded = GetEncodedSubject("foo", "this-is-the-subject")
|
||||
assert.NotContains(encoded, "this-is")
|
||||
}
|
||||
|
||||
func TestDecodeToString(t *testing.T) {
|
||||
t.Parallel()
|
||||
assert := assert.New(t)
|
||||
|
||||
testcases := []struct {
|
||||
data []byte
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
[]byte(`""`),
|
||||
"",
|
||||
},
|
||||
{
|
||||
[]byte(`"foo"`),
|
||||
"foo",
|
||||
},
|
||||
{
|
||||
[]byte(`{"type":"foo"}`),
|
||||
`{"type":"foo"}`,
|
||||
},
|
||||
{
|
||||
[]byte(`1234`),
|
||||
"1234",
|
||||
},
|
||||
}
|
||||
|
||||
for idx, tc := range testcases {
|
||||
var dest string
|
||||
if assert.NoError(Decode(&nats.Msg{
|
||||
Data: tc.data,
|
||||
}, &dest), "decoding failed for test %d (%s)", idx, string(tc.data)) {
|
||||
assert.Equal(tc.expected, dest, "failed for test %s (%s)", idx, string(tc.data))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeToByteSlice(t *testing.T) {
|
||||
t.Parallel()
|
||||
assert := assert.New(t)
|
||||
|
||||
testcases := []struct {
|
||||
data []byte
|
||||
expected []byte
|
||||
}{
|
||||
{
|
||||
[]byte(``),
|
||||
[]byte{},
|
||||
},
|
||||
{
|
||||
[]byte(`""`),
|
||||
[]byte(`""`),
|
||||
},
|
||||
{
|
||||
[]byte(`"foo"`),
|
||||
[]byte(`"foo"`),
|
||||
},
|
||||
{
|
||||
[]byte(`{"type":"foo"}`),
|
||||
[]byte(`{"type":"foo"}`),
|
||||
},
|
||||
{
|
||||
[]byte(`1234`),
|
||||
[]byte(`1234`),
|
||||
},
|
||||
}
|
||||
|
||||
for idx, tc := range testcases {
|
||||
var dest []byte
|
||||
if assert.NoError(Decode(&nats.Msg{
|
||||
Data: tc.data,
|
||||
}, &dest), "decoding failed for test %d (%s)", idx, string(tc.data)) {
|
||||
assert.Equal(tc.expected, dest, "failed for test %s (%s)", idx, string(tc.data))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeRegular(t *testing.T) {
|
||||
t.Parallel()
|
||||
assert := assert.New(t)
|
||||
|
||||
type testdata struct {
|
||||
Type string `json:"type"`
|
||||
Value any `json:"value"`
|
||||
}
|
||||
|
||||
testcases := []struct {
|
||||
data []byte
|
||||
expected *testdata
|
||||
}{
|
||||
{
|
||||
[]byte(`null`),
|
||||
nil,
|
||||
},
|
||||
{
|
||||
[]byte(`{"value":"bar","type":"foo"}`),
|
||||
&testdata{
|
||||
Type: "foo",
|
||||
Value: "bar",
|
||||
},
|
||||
},
|
||||
{
|
||||
[]byte(`{"value":123,"type":"foo"}`),
|
||||
&testdata{
|
||||
Type: "foo",
|
||||
Value: float64(123),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for idx, tc := range testcases {
|
||||
var dest *testdata
|
||||
if assert.NoError(Decode(&nats.Msg{
|
||||
Data: tc.data,
|
||||
}, &dest), "decoding failed for test %d (%s)", idx, string(tc.data)) {
|
||||
assert.Equal(tc.expected, dest, "failed for test %s (%s)", idx, string(tc.data))
|
||||
}
|
||||
}
|
||||
}
|
||||
194
nats/loopback.go
Normal file
194
nats/loopback.go
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
/**
|
||||
* Standalone signaling server for the Nextcloud Spreed app.
|
||||
* Copyright (C) 2017 struktur AG
|
||||
*
|
||||
* @author Joachim Bauch <bauch@struktur.de>
|
||||
*
|
||||
* @license GNU AGPL version 3 or any later version
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package nats
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"github.com/strukturag/nextcloud-spreed-signaling/log"
|
||||
)
|
||||
|
||||
type LoopbackClient struct {
|
||||
logger log.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
closed chan struct{}
|
||||
|
||||
// +checklocks:mu
|
||||
subscriptions map[string]map[*loopbackSubscription]bool
|
||||
|
||||
// +checklocks:mu
|
||||
wakeup sync.Cond
|
||||
// +checklocks:mu
|
||||
incoming list.List
|
||||
}
|
||||
|
||||
func NewLoopbackClient(logger log.Logger) (Client, error) {
|
||||
client := &LoopbackClient{
|
||||
logger: logger,
|
||||
closed: make(chan struct{}),
|
||||
|
||||
subscriptions: make(map[string]map[*loopbackSubscription]bool),
|
||||
}
|
||||
client.wakeup.L = &client.mu
|
||||
go client.processMessages()
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (c *LoopbackClient) processMessages() {
|
||||
defer close(c.closed)
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for {
|
||||
for c.subscriptions != nil && c.incoming.Len() == 0 {
|
||||
c.wakeup.Wait()
|
||||
}
|
||||
if c.subscriptions == nil {
|
||||
// Client was closed.
|
||||
break
|
||||
}
|
||||
|
||||
msg := c.incoming.Remove(c.incoming.Front()).(*Msg)
|
||||
c.processMessage(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// +checklocks:c.mu
|
||||
func (c *LoopbackClient) processMessage(msg *Msg) {
|
||||
subs, found := c.subscriptions[msg.Subject]
|
||||
if !found {
|
||||
return
|
||||
}
|
||||
|
||||
channels := make([]chan *Msg, 0, len(subs))
|
||||
for sub := range subs {
|
||||
channels = append(channels, sub.ch)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
defer c.mu.Lock()
|
||||
for _, ch := range channels {
|
||||
select {
|
||||
case ch <- msg:
|
||||
default:
|
||||
c.logger.Printf("Slow consumer %s, dropping message", msg.Subject)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *LoopbackClient) doClose() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.subscriptions = nil
|
||||
c.incoming.Init()
|
||||
c.wakeup.Signal()
|
||||
}
|
||||
|
||||
func (c *LoopbackClient) Close(ctx context.Context) error {
|
||||
c.doClose()
|
||||
select {
|
||||
case <-c.closed:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
type loopbackSubscription struct {
|
||||
subject string
|
||||
client *LoopbackClient
|
||||
|
||||
ch chan *Msg
|
||||
}
|
||||
|
||||
func (s *loopbackSubscription) Unsubscribe() error {
|
||||
s.client.unsubscribe(s)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *LoopbackClient) Subscribe(subject string, ch chan *Msg) (Subscription, error) {
|
||||
if strings.HasSuffix(subject, ".") || strings.Contains(subject, " ") {
|
||||
return nil, nats.ErrBadSubject
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.subscriptions == nil {
|
||||
return nil, nats.ErrConnectionClosed
|
||||
}
|
||||
|
||||
s := &loopbackSubscription{
|
||||
subject: subject,
|
||||
client: c,
|
||||
ch: ch,
|
||||
}
|
||||
subs, found := c.subscriptions[subject]
|
||||
if !found {
|
||||
subs = make(map[*loopbackSubscription]bool)
|
||||
c.subscriptions[subject] = subs
|
||||
}
|
||||
subs[s] = true
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (c *LoopbackClient) unsubscribe(s *loopbackSubscription) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if subs, found := c.subscriptions[s.subject]; found {
|
||||
delete(subs, s)
|
||||
if len(subs) == 0 {
|
||||
delete(c.subscriptions, s.subject)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *LoopbackClient) Publish(subject string, message any) error {
|
||||
if strings.HasSuffix(subject, ".") || strings.Contains(subject, " ") {
|
||||
return nats.ErrBadSubject
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.subscriptions == nil {
|
||||
return nats.ErrConnectionClosed
|
||||
}
|
||||
|
||||
msg := &Msg{
|
||||
Subject: subject,
|
||||
}
|
||||
var err error
|
||||
if msg.Data, err = json.Marshal(message); err != nil {
|
||||
return err
|
||||
}
|
||||
c.incoming.PushBack(msg)
|
||||
c.wakeup.Signal()
|
||||
return nil
|
||||
}
|
||||
73
nats/loopback_test.go
Normal file
73
nats/loopback_test.go
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
/**
|
||||
* Standalone signaling server for the Nextcloud Spreed app.
|
||||
* Copyright (C) 2018 struktur AG
|
||||
*
|
||||
* @author Joachim Bauch <bauch@struktur.de>
|
||||
*
|
||||
* @license GNU AGPL version 3 or any later version
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package nats
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/strukturag/nextcloud-spreed-signaling/log"
|
||||
)
|
||||
|
||||
func CreateLoopbackClientForTest(t *testing.T) Client {
|
||||
logger := log.NewLoggerForTest(t)
|
||||
result, err := NewLoopbackClient(logger)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
assert.NoError(t, result.Close(ctx))
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
func TestLoopbackClient_Subscribe(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := CreateLoopbackClientForTest(t)
|
||||
testClient_Subscribe(t, client)
|
||||
}
|
||||
|
||||
func TestLoopbackClient_PublishAfterClose(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := CreateLoopbackClientForTest(t)
|
||||
test_PublishAfterClose(t, client)
|
||||
}
|
||||
|
||||
func TestLoopbackClient_SubscribeAfterClose(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := CreateLoopbackClientForTest(t)
|
||||
testClient_SubscribeAfterClose(t, client)
|
||||
}
|
||||
|
||||
func TestLoopbackClient_BadSubjects(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := CreateLoopbackClientForTest(t)
|
||||
testClient_BadSubjects(t, client)
|
||||
}
|
||||
114
nats/native.go
Normal file
114
nats/native.go
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
/**
|
||||
* Standalone signaling server for the Nextcloud Spreed app.
|
||||
* Copyright (C) 2017 struktur AG
|
||||
*
|
||||
* @author Joachim Bauch <bauch@struktur.de>
|
||||
*
|
||||
* @license GNU AGPL version 3 or any later version
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package nats
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"github.com/strukturag/nextcloud-spreed-signaling/log"
|
||||
)
|
||||
|
||||
type NativeClient struct {
|
||||
logger log.Logger
|
||||
conn *nats.Conn
|
||||
closed chan struct{}
|
||||
}
|
||||
|
||||
func (c *NativeClient) URLs() []string {
|
||||
return c.conn.Servers()
|
||||
}
|
||||
|
||||
func (c *NativeClient) IsConnected() bool {
|
||||
return c.conn.IsConnected()
|
||||
}
|
||||
|
||||
func (c *NativeClient) ConnectedUrl() string {
|
||||
return c.conn.ConnectedUrl()
|
||||
}
|
||||
|
||||
func (c *NativeClient) ConnectedServerId() string {
|
||||
return c.conn.ConnectedServerId()
|
||||
}
|
||||
|
||||
func (c *NativeClient) ConnectedServerVersion() string {
|
||||
return c.conn.ConnectedServerVersion()
|
||||
}
|
||||
|
||||
func (c *NativeClient) ConnectedClusterName() string {
|
||||
return c.conn.ConnectedClusterName()
|
||||
}
|
||||
|
||||
func (c *NativeClient) Close(ctx context.Context) error {
|
||||
c.conn.Close()
|
||||
select {
|
||||
case <-c.closed:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *NativeClient) FlushWithContext(ctx context.Context) error {
|
||||
return c.conn.FlushWithContext(ctx)
|
||||
}
|
||||
|
||||
func (c *NativeClient) onClosed(conn *nats.Conn) {
|
||||
if err := conn.LastError(); err != nil {
|
||||
c.logger.Printf("NATS client closed, last error %s", conn.LastError())
|
||||
} else {
|
||||
c.logger.Println("NATS client closed")
|
||||
}
|
||||
close(c.closed)
|
||||
}
|
||||
|
||||
func (c *NativeClient) onDisconnected(conn *nats.Conn) {
|
||||
c.logger.Println("NATS client disconnected")
|
||||
}
|
||||
|
||||
func (c *NativeClient) onReconnected(conn *nats.Conn) {
|
||||
c.logger.Printf("NATS client reconnected to %s (%s)", conn.ConnectedUrl(), conn.ConnectedServerId())
|
||||
}
|
||||
|
||||
func (c *NativeClient) Subscribe(subject string, ch chan *Msg) (Subscription, error) {
|
||||
return c.conn.ChanSubscribe(subject, ch)
|
||||
}
|
||||
|
||||
func (c *NativeClient) Publish(subject string, message any) error {
|
||||
data, err := json.Marshal(message)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.conn.Publish(subject, data)
|
||||
}
|
||||
|
||||
func removeURLCredentials(u string) string {
|
||||
if u, err := url.Parse(u); err == nil && u.User != nil {
|
||||
u.User = url.User("***")
|
||||
return u.String()
|
||||
}
|
||||
return u
|
||||
}
|
||||
190
nats/native_test.go
Normal file
190
nats/native_test.go
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
/**
|
||||
* Standalone signaling server for the Nextcloud Spreed app.
|
||||
* Copyright (C) 2021 struktur AG
|
||||
*
|
||||
* @author Joachim Bauch <bauch@struktur.de>
|
||||
*
|
||||
* @license GNU AGPL version 3 or any later version
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package nats
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"github.com/strukturag/nextcloud-spreed-signaling/log"
|
||||
"github.com/strukturag/nextcloud-spreed-signaling/test"
|
||||
)
|
||||
|
||||
func CreateLocalClientForTest(t *testing.T, options ...nats.Option) (*server.Server, int, Client) {
|
||||
t.Helper()
|
||||
server, port := StartLocalServer(t)
|
||||
logger := log.NewLoggerForTest(t)
|
||||
ctx := log.NewLoggerContext(t.Context(), logger)
|
||||
result, err := NewClient(ctx, server.ClientURL(), options...)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
assert.NoError(t, result.Close(ctx))
|
||||
})
|
||||
return server, port, result
|
||||
}
|
||||
|
||||
func testClient_Subscribe(t *testing.T, client Client) {
|
||||
require := require.New(t)
|
||||
assert := assert.New(t)
|
||||
dest := make(chan *Msg)
|
||||
sub, err := client.Subscribe("foo", dest)
|
||||
require.NoError(err)
|
||||
ch := make(chan struct{})
|
||||
|
||||
var received atomic.Int32
|
||||
maxPublish := int32(20)
|
||||
ready := make(chan struct{})
|
||||
quit := make(chan struct{})
|
||||
defer close(quit)
|
||||
go func() {
|
||||
close(ready)
|
||||
for {
|
||||
select {
|
||||
case <-dest:
|
||||
total := received.Add(1)
|
||||
if total == maxPublish {
|
||||
if err := sub.Unsubscribe(); !assert.NoError(err) {
|
||||
return
|
||||
}
|
||||
close(ch)
|
||||
}
|
||||
case <-quit:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
<-ready
|
||||
for range maxPublish {
|
||||
assert.NoError(client.Publish("foo", []byte("hello")))
|
||||
|
||||
// Allow NATS goroutines to process messages.
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
<-ch
|
||||
|
||||
require.Equal(maxPublish, received.Load(), "Received wrong # of messages")
|
||||
}
|
||||
|
||||
func TestClient_Subscribe(t *testing.T) { // nolint:paralleltest
|
||||
test.EnsureNoGoroutinesLeak(t, func(t *testing.T) {
|
||||
_, _, client := CreateLocalClientForTest(t)
|
||||
|
||||
testClient_Subscribe(t, client)
|
||||
})
|
||||
}
|
||||
|
||||
func test_PublishAfterClose(t *testing.T, client Client) {
|
||||
assert.NoError(t, client.Close(t.Context()))
|
||||
|
||||
assert.ErrorIs(t, client.Publish("foo", "bar"), nats.ErrConnectionClosed)
|
||||
}
|
||||
|
||||
func TestClient_PublishAfterClose(t *testing.T) { // nolint:paralleltest
|
||||
test.EnsureNoGoroutinesLeak(t, func(t *testing.T) {
|
||||
_, _, client := CreateLocalClientForTest(t)
|
||||
|
||||
test_PublishAfterClose(t, client)
|
||||
})
|
||||
}
|
||||
|
||||
func testClient_SubscribeAfterClose(t *testing.T, client Client) {
|
||||
assert.NoError(t, client.Close(t.Context()))
|
||||
|
||||
ch := make(chan *Msg)
|
||||
_, err := client.Subscribe("foo", ch)
|
||||
assert.ErrorIs(t, err, nats.ErrConnectionClosed)
|
||||
}
|
||||
|
||||
func TestClient_SubscribeAfterClose(t *testing.T) { // nolint:paralleltest
|
||||
test.EnsureNoGoroutinesLeak(t, func(t *testing.T) {
|
||||
_, _, client := CreateLocalClientForTest(t)
|
||||
|
||||
testClient_SubscribeAfterClose(t, client)
|
||||
})
|
||||
}
|
||||
|
||||
func testClient_BadSubjects(t *testing.T, client Client) {
|
||||
assert := assert.New(t)
|
||||
subjects := []string{
|
||||
"foo bar",
|
||||
"foo.",
|
||||
}
|
||||
|
||||
ch := make(chan *Msg)
|
||||
for _, s := range subjects {
|
||||
_, err := client.Subscribe(s, ch)
|
||||
assert.ErrorIs(err, nats.ErrBadSubject, "Expected error for subject %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_BadSubjects(t *testing.T) { // nolint:paralleltest
|
||||
test.EnsureNoGoroutinesLeak(t, func(t *testing.T) {
|
||||
_, _, client := CreateLocalClientForTest(t)
|
||||
|
||||
testClient_BadSubjects(t, client)
|
||||
})
|
||||
}
|
||||
|
||||
func TestClient_MaxReconnects(t *testing.T) { // nolint:paralleltest
|
||||
test.EnsureNoGoroutinesLeak(t, func(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
require := require.New(t)
|
||||
reconnectWait := time.Millisecond
|
||||
server, port, client := CreateLocalClientForTest(t,
|
||||
nats.ReconnectWait(reconnectWait),
|
||||
nats.ReconnectJitter(0, 0),
|
||||
)
|
||||
c, ok := client.(*NativeClient)
|
||||
require.True(ok, "wrong class: %T", client)
|
||||
require.True(c.conn.IsConnected(), "not connected initially")
|
||||
assert.Equal(server.ID(), c.conn.ConnectedServerId())
|
||||
|
||||
server.Shutdown()
|
||||
server.WaitForShutdown()
|
||||
|
||||
// The NATS client tries to reconnect a maximum of 100 times by default.
|
||||
time.Sleep(100 * reconnectWait)
|
||||
for i := 0; i < 1000 && c.conn.IsConnected(); i++ {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
require.False(c.conn.IsConnected(), "should be disconnected after server shutdown")
|
||||
|
||||
server, _ = StartLocalServerPort(t, port)
|
||||
|
||||
// Wait for automatic reconnection
|
||||
for i := 0; i < 1000 && !c.conn.IsConnected(); i++ {
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
require.True(c.conn.IsConnected(), "not connected after restart")
|
||||
assert.Equal(server.ID(), c.conn.ConnectedServerId())
|
||||
})
|
||||
}
|
||||
74
nats/test_helpers.go
Normal file
74
nats/test_helpers.go
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
/**
|
||||
* Standalone signaling server for the Nextcloud Spreed app.
|
||||
* Copyright (C) 2025 struktur AG
|
||||
*
|
||||
* @author Joachim Bauch <bauch@struktur.de>
|
||||
*
|
||||
* @license GNU AGPL version 3 or any later version
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package nats
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
"github.com/nats-io/nats-server/v2/test"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func StartLocalServer(t *testing.T) (*server.Server, int) {
|
||||
t.Helper()
|
||||
return StartLocalServerPort(t, server.RANDOM_PORT)
|
||||
}
|
||||
|
||||
func StartLocalServerPort(t *testing.T, port int) (*server.Server, int) {
|
||||
t.Helper()
|
||||
opts := test.DefaultTestOptions
|
||||
opts.Port = port
|
||||
opts.Cluster.Name = "testing"
|
||||
srv := test.RunServer(&opts)
|
||||
t.Cleanup(func() {
|
||||
srv.Shutdown()
|
||||
srv.WaitForShutdown()
|
||||
})
|
||||
return srv, opts.Port
|
||||
}
|
||||
|
||||
func WaitForSubscriptionsEmpty(ctx context.Context, t *testing.T, client Client) {
|
||||
t.Helper()
|
||||
if c, ok := client.(*LoopbackClient); assert.True(t, ok, "expected LoopbackNatsClient, got %T", client) {
|
||||
for {
|
||||
c.mu.Lock()
|
||||
count := len(c.subscriptions)
|
||||
c.mu.Unlock()
|
||||
if count == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
c.mu.Lock()
|
||||
assert.NoError(t, ctx.Err(), "Error waiting for subscriptions %+v to terminate", c.subscriptions)
|
||||
c.mu.Unlock()
|
||||
return
|
||||
default:
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue