This commit is contained in:
Mickael Remond 2019-11-04 16:25:07 +01:00
commit 3e94880916
No known key found for this signature in database
GPG key ID: E6F6045D79965AA3
9 changed files with 205 additions and 8 deletions

View file

@ -1,6 +1,7 @@
package xmpp package xmpp
import ( import (
"context"
"encoding/xml" "encoding/xml"
"errors" "errors"
"fmt" "fmt"
@ -82,6 +83,8 @@ func (em EventManager) streamError(error, desc string) {
// Client // Client
// ============================================================================ // ============================================================================
var ErrCanOnlySendGetOrSetIq = errors.New("SendIQ can only send get and set IQ stanzas")
// Client is the main structure used to connect as a client on an XMPP // Client is the main structure used to connect as a client on an XMPP
// server. // server.
type Client struct { type Client struct {
@ -221,6 +224,25 @@ func (c *Client) Send(packet stanza.Packet) error {
return c.sendWithWriter(c.transport, data) return c.sendWithWriter(c.transport, data)
} }
// SendIQ sends an IQ set or get stanza to the server. If a result is received
// the provided handler function will automatically be called.
//
// The provided context should have a timeout to prevent the client from waiting
// forever for an IQ result. For example:
//
// ctx, _ := context.WithTimeout(context.Background(), 30 * time.Second)
// result := <- client.SendIQ(ctx, iq)
//
func (c *Client) SendIQ(ctx context.Context, iq stanza.IQ) (chan stanza.IQ, error) {
if iq.Attrs.Type != "set" && iq.Attrs.Type != "get" {
return nil, ErrCanOnlySendGetOrSetIq
}
if err := c.Send(iq); err != nil {
return nil, err
}
return c.router.NewIQResultRoute(ctx, iq.Attrs.Id), nil
}
// SendRaw sends an XMPP stanza as a string to the server. // SendRaw sends an XMPP stanza as a string to the server.
// It can be invalid XML or XMPP content. In that case, the server will // It can be invalid XML or XMPP content. In that case, the server will
// disconnect the client. It is up to the user of this method to // disconnect the client. It is up to the user of this method to
@ -271,7 +293,10 @@ func (c *Client) recv(state SMState, keepaliveQuit chan<- struct{}) (err error)
state.Inbound++ state.Inbound++
} }
c.router.route(c, val) // Do normal route processing in a go-routine so we can immediately
// start receiving other stanzas. This also allows route handlers to
// send and receive more stanzas.
go c.router.route(c, val)
} }
} }

View file

@ -1,6 +1,7 @@
package xmpp package xmpp
import ( import (
"context"
"crypto/sha1" "crypto/sha1"
"encoding/hex" "encoding/hex"
"encoding/xml" "encoding/xml"
@ -158,6 +159,25 @@ func (c *Component) Send(packet stanza.Packet) error {
return nil return nil
} }
// SendIQ sends an IQ set or get stanza to the server. If a result is received
// the provided handler function will automatically be called.
//
// The provided context should have a timeout to prevent the client from waiting
// forever for an IQ result. For example:
//
// ctx, _ := context.WithTimeout(context.Background(), 30 * time.Second)
// result := <- client.SendIQ(ctx, iq)
//
func (c *Component) SendIQ(ctx context.Context, iq stanza.IQ) (chan stanza.IQ, error) {
if iq.Attrs.Type != "set" && iq.Attrs.Type != "get" {
return nil, ErrCanOnlySendGetOrSetIq
}
if err := c.Send(iq); err != nil {
return nil, err
}
return c.router.NewIQResultRoute(ctx, iq.Attrs.Id), nil
}
// SendRaw sends an XMPP stanza as a string to the server. // SendRaw sends an XMPP stanza as a string to the server.
// It can be invalid XML or XMPP content. In that case, the server will // It can be invalid XML or XMPP content. In that case, the server will
// disconnect the component. It is up to the user of this method to // disconnect the component. It is up to the user of this method to

1
go.mod
View file

@ -4,6 +4,7 @@ go 1.13
require ( require (
github.com/google/go-cmp v0.3.1 github.com/google/go-cmp v0.3.1
github.com/google/uuid v1.1.1
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7
nhooyr.io/websocket v1.6.5 nhooyr.io/websocket v1.6.5
) )

3
go.sum
View file

@ -21,9 +21,12 @@ github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY= github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1 h1:Xye71clBPdm5HgqGwUkwhbynsUJZhDbS20FvLhQ2izg=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190908185732-236ed259b199/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190908185732-236ed259b199/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
github.com/knq/sysutil v0.0.0-20181215143952-f05b59f0f307/go.mod h1:BjPj+aVjl9FW/cCGiF3nGh5v+9Gd3VCgBQbod/GlMaQ= github.com/knq/sysutil v0.0.0-20181215143952-f05b59f0f307/go.mod h1:BjPj+aVjl9FW/cCGiF3nGh5v+9Gd3VCgBQbod/GlMaQ=

View file

@ -1,8 +1,10 @@
package xmpp package xmpp
import ( import (
"context"
"encoding/xml" "encoding/xml"
"strings" "strings"
"sync"
"gosrc.io/xmpp/stanza" "gosrc.io/xmpp/stanza"
) )
@ -25,16 +27,35 @@ TODO: Automatically reply to IQ that do not match any route, to comply to XMPP s
type Router struct { type Router struct {
// Routes to be matched, in order. // Routes to be matched, in order.
routes []*Route routes []*Route
IQResultRoutes map[string]*IQResultRoute
IQResultRouteLock sync.RWMutex
} }
// NewRouter returns a new router instance. // NewRouter returns a new router instance.
func NewRouter() *Router { func NewRouter() *Router {
return &Router{} return &Router{
IQResultRoutes: make(map[string]*IQResultRoute),
}
} }
// route is called by the XMPP client to dispatch stanza received using the set up routes. // route is called by the XMPP client to dispatch stanza received using the set up routes.
// It is also used by test, but is not supposed to be used directly by users of the library. // It is also used by test, but is not supposed to be used directly by users of the library.
func (r *Router) route(s Sender, p stanza.Packet) { func (r *Router) route(s Sender, p stanza.Packet) {
iq, isIq := p.(stanza.IQ)
if isIq {
r.IQResultRouteLock.RLock()
route, ok := r.IQResultRoutes[iq.Id]
r.IQResultRouteLock.RUnlock()
if ok {
r.IQResultRouteLock.Lock()
delete(r.IQResultRoutes, iq.Id)
r.IQResultRouteLock.Unlock()
route.result <- iq
close(route.result)
return
}
}
var match RouteMatch var match RouteMatch
if r.Match(p, &match) { if r.Match(p, &match) {
@ -42,12 +63,11 @@ func (r *Router) route(s Sender, p stanza.Packet) {
match.Handler.HandlePacket(s, p) match.Handler.HandlePacket(s, p)
return return
} }
// If there is no match and we receive an iq set or get, we need to send a reply // If there is no match and we receive an iq set or get, we need to send a reply
if iq, ok := p.(stanza.IQ); ok { if isIq && (iq.Type == stanza.IQTypeGet || iq.Type == stanza.IQTypeSet) {
if iq.Type == stanza.IQTypeGet || iq.Type == stanza.IQTypeSet {
iqNotImplemented(s, iq) iqNotImplemented(s, iq)
} }
}
} }
func iqNotImplemented(s Sender, iq stanza.IQ) { func iqNotImplemented(s Sender, iq stanza.IQ) {
@ -68,6 +88,27 @@ func (r *Router) NewRoute() *Route {
return route return route
} }
// NewIQResultRoute register a route that will catch an IQ result stanza with
// the given Id. The route will only match ones, after which it will automatically
// be unregistered
func (r *Router) NewIQResultRoute(ctx context.Context, id string) chan stanza.IQ {
route := NewIQResultRoute(ctx)
r.IQResultRouteLock.Lock()
r.IQResultRoutes[id] = route
r.IQResultRouteLock.Unlock()
// Start a go function to make sure the route is unregistered when the context
// is done.
go func() {
<-route.context.Done()
r.IQResultRouteLock.Lock()
delete(r.IQResultRoutes, id)
r.IQResultRouteLock.Unlock()
}()
return route.result
}
func (r *Router) Match(p stanza.Packet, match *RouteMatch) bool { func (r *Router) Match(p stanza.Packet, match *RouteMatch) bool {
for _, route := range r.routes { for _, route := range r.routes {
if route.Match(p, match) { if route.Match(p, match) {
@ -89,8 +130,44 @@ func (r *Router) HandleFunc(name string, f func(s Sender, p stanza.Packet)) *Rou
return r.NewRoute().Packet(name).HandlerFunc(f) return r.NewRoute().Packet(name).HandlerFunc(f)
} }
// ============================================================================
// TimeoutHandlerFunc is a function type for handling IQ result timeouts.
type TimeoutHandlerFunc func(err error)
// IQResultRoute is a temporary route to match IQ result stanzas
type IQResultRoute struct {
context context.Context
result chan stanza.IQ
}
// NewIQResultRoute creates a new IQResultRoute instance
func NewIQResultRoute(ctx context.Context) *IQResultRoute {
return &IQResultRoute{
context: ctx,
result: make(chan stanza.IQ),
}
}
// ============================================================================
// IQ result handler
// IQResultHandler is a utility interface for IQ result handlers
type IQResultHandler interface {
HandleIQ(ctx context.Context, s Sender, iq stanza.IQ)
}
// IQResultHandlerFunc is an adapter to allow using functions as IQ result handlers.
type IQResultHandlerFunc func(ctx context.Context, s Sender, iq stanza.IQ)
// HandleIQ is a proxy function to implement IQResultHandler using a function.
func (f IQResultHandlerFunc) HandleIQ(ctx context.Context, s Sender, iq stanza.IQ) {
f(ctx, s, iq)
}
// ============================================================================ // ============================================================================
// Route // Route
type Handler interface { type Handler interface {
HandlePacket(s Sender, p stanza.Packet) HandlePacket(s Sender, p stanza.Packet)
} }

View file

@ -2,8 +2,10 @@ package xmpp
import ( import (
"bytes" "bytes"
"context"
"encoding/xml" "encoding/xml"
"testing" "testing"
"time"
"gosrc.io/xmpp/stanza" "gosrc.io/xmpp/stanza"
) )
@ -11,6 +13,47 @@ import (
// ============================================================================ // ============================================================================
// Test route & matchers // Test route & matchers
func TestIQResultRoutes(t *testing.T) {
t.Parallel()
router := NewRouter()
conn := NewSenderMock()
if router.IQResultRoutes == nil {
t.Fatal("NewRouter does not initialize isResultRoutes")
}
// Check if the IQ handler was called
ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*100)
defer cancel()
iq := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeResult, Id: "1234"})
res := router.NewIQResultRoute(ctx, "1234")
go router.route(conn, iq)
select {
case <-ctx.Done():
t.Fatal("IQ result was not matched")
case <-res:
// Success
}
// The match must only happen once, so the id should no longer be in IQResultRoutes
if _, ok := router.IQResultRoutes[iq.Attrs.Id]; ok {
t.Fatal("IQ ID was not removed from the route map")
}
// Check other IQ does not matcah
ctx, cancel = context.WithTimeout(context.Background(), time.Millisecond*100)
defer cancel()
iq.Attrs.Id = "4321"
res = router.NewIQResultRoute(ctx, "1234")
go router.route(conn, iq)
select {
case <-ctx.Done():
// Success
case <-res:
t.Fatal("IQ result with wrong ID was matched")
}
}
func TestNameMatcher(t *testing.T) { func TestNameMatcher(t *testing.T) {
router := NewRouter() router := NewRouter()
router.HandleFunc("message", func(s Sender, p stanza.Packet) { router.HandleFunc("message", func(s Sender, p stanza.Packet) {
@ -211,7 +254,8 @@ func TestCatchallMatcher(t *testing.T) {
// ============================================================================ // ============================================================================
// SenderMock // SenderMock
var successFlag = "matched" const successFlag = "matched"
const cancelledFlag = "cancelled"
type SenderMock struct { type SenderMock struct {
buffer *bytes.Buffer buffer *bytes.Buffer

View file

@ -2,6 +2,8 @@ package stanza
import ( import (
"encoding/xml" "encoding/xml"
"github.com/google/uuid"
) )
/* /*
@ -31,8 +33,12 @@ type IQPayload interface {
} }
func NewIQ(a Attrs) IQ { func NewIQ(a Attrs) IQ {
// TODO generate IQ ID if not set
// TODO ensure that type is set, as it is required // TODO ensure that type is set, as it is required
if a.Id == "" {
if id, err := uuid.NewRandom(); err == nil {
a.Id = id.String()
}
}
return IQ{ return IQ{
XMLName: xml.Name{Local: "iq"}, XMLName: xml.Name{Local: "iq"},
Attrs: a, Attrs: a,

View file

@ -34,6 +34,24 @@ func TestUnmarshalIqs(t *testing.T) {
} }
} }
func TestGenerateIqId(t *testing.T) {
t.Parallel()
iq := stanza.NewIQ(stanza.Attrs{Id: "1"})
if iq.Id != "1" {
t.Errorf("NewIQ replaced id with %s", iq.Id)
}
iq = stanza.NewIQ(stanza.Attrs{})
if iq.Id != "1" {
t.Error("NewIQ did not generate an Id")
}
otherIq := stanza.NewIQ(stanza.Attrs{})
if iq.Id == otherIq.Id {
t.Errorf("NewIQ generated two identical ids: %s", iq.Id)
}
}
func TestGenerateIq(t *testing.T) { func TestGenerateIq(t *testing.T) {
iq := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeResult, From: "admin@localhost", To: "test@localhost", Id: "1"}) iq := stanza.NewIQ(stanza.Attrs{Type: stanza.IQTypeResult, From: "admin@localhost", To: "test@localhost", Id: "1"})
payload := stanza.DiscoInfo{ payload := stanza.DiscoInfo{

View file

@ -1,6 +1,7 @@
package xmpp package xmpp
import ( import (
"context"
"errors" "errors"
"sync" "sync"
"time" "time"
@ -26,6 +27,7 @@ type StreamClient interface {
Connect() error Connect() error
Resume(state SMState) error Resume(state SMState) error
Send(packet stanza.Packet) error Send(packet stanza.Packet) error
SendIQ(ctx context.Context, iq stanza.IQ) (chan stanza.IQ, error)
SendRaw(packet string) error SendRaw(packet string) error
Disconnect() Disconnect()
SetHandler(handler EventHandler) SetHandler(handler EventHandler)
@ -35,6 +37,7 @@ type StreamClient interface {
// It is mostly use in callback to pass a limited subset of the stream client interface // It is mostly use in callback to pass a limited subset of the stream client interface
type Sender interface { type Sender interface {
Send(packet stanza.Packet) error Send(packet stanza.Packet) error
SendIQ(ctx context.Context, iq stanza.IQ) (chan stanza.IQ, error)
SendRaw(packet string) error SendRaw(packet string) error
} }