From ad343a0f4ad175053f7d0da12a0587bcbb396d1c Mon Sep 17 00:00:00 2001 From: Joram Wilander Date: Tue, 12 Jul 2016 09:36:27 -0400 Subject: Added infrastructure for basic WebSocket API (#3432) --- model/client.go | 1 + model/message.go | 61 --------------------- model/message_test.go | 24 --------- model/utils.go | 12 ++--- model/websocket_client.go | 102 +++++++++++++++++++++++++++++++++++ model/websocket_message.go | 114 ++++++++++++++++++++++++++++++++++++++++ model/websocket_message_test.go | 56 ++++++++++++++++++++ model/websocket_request.go | 43 +++++++++++++++ model/websocket_request_test.go | 25 +++++++++ 9 files changed, 347 insertions(+), 91 deletions(-) delete mode 100644 model/message.go delete mode 100644 model/message_test.go create mode 100644 model/websocket_client.go create mode 100644 model/websocket_message.go create mode 100644 model/websocket_message_test.go create mode 100644 model/websocket_request.go create mode 100644 model/websocket_request_test.go (limited to 'model') diff --git a/model/client.go b/model/client.go index 2f1e846c2..0ba8913af 100644 --- a/model/client.go +++ b/model/client.go @@ -32,6 +32,7 @@ const ( HEADER_REQUESTED_WITH_XML = "XMLHttpRequest" STATUS = "status" STATUS_OK = "OK" + STATUS_FAIL = "FAIL" API_URL_SUFFIX_V1 = "/api/v1" API_URL_SUFFIX_V3 = "/api/v3" diff --git a/model/message.go b/model/message.go deleted file mode 100644 index 12f3be663..000000000 --- a/model/message.go +++ /dev/null @@ -1,61 +0,0 @@ -// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. - -package model - -import ( - "encoding/json" - "io" -) - -const ( - ACTION_TYPING = "typing" - ACTION_POSTED = "posted" - ACTION_POST_EDITED = "post_edited" - ACTION_POST_DELETED = "post_deleted" - ACTION_CHANNEL_DELETED = "channel_deleted" - ACTION_CHANNEL_VIEWED = "channel_viewed" - ACTION_DIRECT_ADDED = "direct_added" - ACTION_NEW_USER = "new_user" - ACTION_LEAVE_TEAM = "leave_team" - ACTION_USER_ADDED = "user_added" - ACTION_USER_REMOVED = "user_removed" - ACTION_PREFERENCE_CHANGED = "preference_changed" - ACTION_EPHEMERAL_MESSAGE = "ephemeral_message" -) - -type Message struct { - TeamId string `json:"team_id"` - ChannelId string `json:"channel_id"` - UserId string `json:"user_id"` - Action string `json:"action"` - Props map[string]string `json:"props"` -} - -func (m *Message) Add(key string, value string) { - m.Props[key] = value -} - -func NewMessage(teamId string, channelId string, userId string, action string) *Message { - return &Message{TeamId: teamId, ChannelId: channelId, UserId: userId, Action: action, Props: make(map[string]string)} -} - -func (o *Message) ToJson() string { - b, err := json.Marshal(o) - if err != nil { - return "" - } else { - return string(b) - } -} - -func MessageFromJson(data io.Reader) *Message { - decoder := json.NewDecoder(data) - var o Message - err := decoder.Decode(&o) - if err == nil { - return &o - } else { - return nil - } -} diff --git a/model/message_test.go b/model/message_test.go deleted file mode 100644 index 182678d8e..000000000 --- a/model/message_test.go +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. - -package model - -import ( - "strings" - "testing" -) - -func TestMessgaeJson(t *testing.T) { - m := NewMessage(NewId(), NewId(), NewId(), ACTION_TYPING) - m.Add("RootId", NewId()) - json := m.ToJson() - result := MessageFromJson(strings.NewReader(json)) - - if m.TeamId != result.TeamId { - t.Fatal("Ids do not match") - } - - if m.Props["RootId"] != result.Props["RootId"] { - t.Fatal("Ids do not match") - } -} diff --git a/model/utils.go b/model/utils.go index 27ab3e27e..a4a4208c2 100644 --- a/model/utils.go +++ b/model/utils.go @@ -34,12 +34,12 @@ type EncryptStringMap map[string]string type AppError struct { Id string `json:"id"` - Message string `json:"message"` // Message to be display to the end user without debugging information - DetailedError string `json:"detailed_error"` // Internal error string to help the developer - RequestId string `json:"request_id"` // The RequestId that's also set in the header - StatusCode int `json:"status_code"` // The http status code - Where string `json:"-"` // The function where it happened in the form of Struct.Func - IsOAuth bool `json:"is_oauth"` // Whether the error is OAuth specific + Message string `json:"message"` // Message to be display to the end user without debugging information + DetailedError string `json:"detailed_error"` // Internal error string to help the developer + RequestId string `json:"request_id,omitempty"` // The RequestId that's also set in the header + StatusCode int `json:"status_code,omitempty"` // The http status code + Where string `json:"-"` // The function where it happened in the form of Struct.Func + IsOAuth bool `json:"is_oauth,omitempty"` // Whether the error is OAuth specific params map[string]interface{} `json:"-"` } diff --git a/model/websocket_client.go b/model/websocket_client.go new file mode 100644 index 000000000..7b9dc0b50 --- /dev/null +++ b/model/websocket_client.go @@ -0,0 +1,102 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "encoding/json" + "github.com/gorilla/websocket" + "net/http" +) + +type WebSocketClient struct { + Url string // The location of the server like "ws://localhost:8065" + ApiUrl string // The api location of the server like "ws://localhost:8065/api/v3" + Conn *websocket.Conn // The WebSocket connection + AuthToken string // The token used to open the WebSocket + Sequence int64 // The ever-incrementing sequence attached to each WebSocket action + EventChannel chan *WebSocketEvent + ResponseChannel chan *WebSocketResponse +} + +// NewWebSocketClient constructs a new WebSocket client with convienence +// methods for talking to the server. +func NewWebSocketClient(url, authToken string) (*WebSocketClient, *AppError) { + header := http.Header{} + header.Set(HEADER_AUTH, "BEARER "+authToken) + conn, _, err := websocket.DefaultDialer.Dial(url+API_URL_SUFFIX+"/users/websocket", header) + if err != nil { + return nil, NewLocAppError("NewWebSocketClient", "model.websocket_client.connect_fail.app_error", nil, err.Error()) + } + + return &WebSocketClient{ + url, + url + API_URL_SUFFIX, + conn, + authToken, + 1, + make(chan *WebSocketEvent, 100), + make(chan *WebSocketResponse, 100), + }, nil +} + +func (wsc *WebSocketClient) Connect() *AppError { + header := http.Header{} + header.Set(HEADER_AUTH, "BEARER "+wsc.AuthToken) + + var err error + wsc.Conn, _, err = websocket.DefaultDialer.Dial(wsc.ApiUrl+"/users/websocket", header) + if err != nil { + return NewLocAppError("NewWebSocketClient", "model.websocket_client.connect_fail.app_error", nil, err.Error()) + } + + return nil +} + +func (wsc *WebSocketClient) Close() { + wsc.Conn.Close() +} + +func (wsc *WebSocketClient) Listen() { + go func() { + for { + var rawMsg json.RawMessage + var err error + if _, rawMsg, err = wsc.Conn.ReadMessage(); err != nil { + return + } + + var event WebSocketEvent + if err := json.Unmarshal(rawMsg, &event); err == nil && event.IsValid() { + wsc.EventChannel <- &event + continue + } + + var response WebSocketResponse + if err := json.Unmarshal(rawMsg, &response); err == nil && response.IsValid() { + wsc.ResponseChannel <- &response + continue + } + } + }() +} + +func (wsc *WebSocketClient) SendMessage(action string, data map[string]interface{}) { + req := &WebSocketRequest{} + req.Seq = wsc.Sequence + req.Action = action + req.Data = data + + wsc.Sequence++ + + wsc.Conn.WriteJSON(req) +} + +func (wsc *WebSocketClient) UserTyping(channelId, parentId string) { + data := map[string]interface{}{ + "channel_id": channelId, + "parent_id": parentId, + } + + wsc.SendMessage("user_typing", data) +} diff --git a/model/websocket_message.go b/model/websocket_message.go new file mode 100644 index 000000000..ae9a140c3 --- /dev/null +++ b/model/websocket_message.go @@ -0,0 +1,114 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "encoding/json" + "io" +) + +const ( + WEBSOCKET_EVENT_TYPING = "typing" + WEBSOCKET_EVENT_POSTED = "posted" + WEBSOCKET_EVENT_POST_EDITED = "post_edited" + WEBSOCKET_EVENT_POST_DELETED = "post_deleted" + WEBSOCKET_EVENT_CHANNEL_DELETED = "channel_deleted" + WEBSOCKET_EVENT_CHANNEL_VIEWED = "channel_viewed" + WEBSOCKET_EVENT_DIRECT_ADDED = "direct_added" + WEBSOCKET_EVENT_NEW_USER = "new_user" + WEBSOCKET_EVENT_LEAVE_TEAM = "leave_team" + WEBSOCKET_EVENT_USER_ADDED = "user_added" + WEBSOCKET_EVENT_USER_REMOVED = "user_removed" + WEBSOCKET_EVENT_PREFERENCE_CHANGED = "preference_changed" + WEBSOCKET_EVENT_EPHEMERAL_MESSAGE = "ephemeral_message" + WEBSOCKET_EVENT_STATUS_CHANGE = "status_change" +) + +type WebSocketMessage interface { + ToJson() string + IsValid() bool +} + +type WebSocketEvent struct { + TeamId string `json:"team_id"` + ChannelId string `json:"channel_id"` + UserId string `json:"user_id"` + Event string `json:"event"` + Data map[string]interface{} `json:"data"` +} + +func (m *WebSocketEvent) Add(key string, value interface{}) { + m.Data[key] = value +} + +func NewWebSocketEvent(teamId string, channelId string, userId string, event string) *WebSocketEvent { + return &WebSocketEvent{TeamId: teamId, ChannelId: channelId, UserId: userId, Event: event, Data: make(map[string]interface{})} +} + +func (o *WebSocketEvent) IsValid() bool { + return o.Event != "" +} + +func (o *WebSocketEvent) ToJson() string { + b, err := json.Marshal(o) + if err != nil { + return "" + } else { + return string(b) + } +} + +func WebSocketEventFromJson(data io.Reader) *WebSocketEvent { + decoder := json.NewDecoder(data) + var o WebSocketEvent + err := decoder.Decode(&o) + if err == nil { + return &o + } else { + return nil + } +} + +type WebSocketResponse struct { + Status string `json:"status"` + SeqReply int64 `json:"seq_reply,omitempty"` + Data map[string]interface{} `json:"data,omitempty"` + Error *AppError `json:"error,omitempty"` +} + +func (m *WebSocketResponse) Add(key string, value interface{}) { + m.Data[key] = value +} + +func NewWebSocketResponse(status string, seqReply int64, data map[string]interface{}) *WebSocketResponse { + return &WebSocketResponse{Status: status, SeqReply: seqReply, Data: data} +} + +func NewWebSocketError(seqReply int64, err *AppError) *WebSocketResponse { + return &WebSocketResponse{Status: STATUS_FAIL, SeqReply: seqReply, Error: err} +} + +func (o *WebSocketResponse) IsValid() bool { + return o.Status != "" +} + +func (o *WebSocketResponse) ToJson() string { + b, err := json.Marshal(o) + if err != nil { + return "" + } else { + return string(b) + } +} + +func WebSocketResponseFromJson(data io.Reader) *WebSocketResponse { + decoder := json.NewDecoder(data) + var o WebSocketResponse + err := decoder.Decode(&o) + if err == nil { + return &o + } else { + return nil + } +} diff --git a/model/websocket_message_test.go b/model/websocket_message_test.go new file mode 100644 index 000000000..cbc564b6c --- /dev/null +++ b/model/websocket_message_test.go @@ -0,0 +1,56 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "strings" + "testing" +) + +func TestWebSocketEvent(t *testing.T) { + m := NewWebSocketEvent(NewId(), NewId(), NewId(), "some_event") + m.Add("RootId", NewId()) + json := m.ToJson() + result := WebSocketEventFromJson(strings.NewReader(json)) + + badresult := WebSocketEventFromJson(strings.NewReader("junk")) + if badresult != nil { + t.Fatal("should not have parsed") + } + + if !m.IsValid() { + t.Fatal("should be valid") + } + + if m.TeamId != result.TeamId { + t.Fatal("Ids do not match") + } + + if m.Data["RootId"] != result.Data["RootId"] { + t.Fatal("Ids do not match") + } +} + +func TestWebSocketResponse(t *testing.T) { + m := NewWebSocketResponse("OK", 1, map[string]interface{}{}) + e := NewWebSocketError(1, &AppError{}) + m.Add("RootId", NewId()) + json := m.ToJson() + result := WebSocketResponseFromJson(strings.NewReader(json)) + json2 := e.ToJson() + WebSocketResponseFromJson(strings.NewReader(json2)) + + badresult := WebSocketResponseFromJson(strings.NewReader("junk")) + if badresult != nil { + t.Fatal("should not have parsed") + } + + if !m.IsValid() { + t.Fatal("should be valid") + } + + if m.Data["RootId"] != result.Data["RootId"] { + t.Fatal("Ids do not match") + } +} diff --git a/model/websocket_request.go b/model/websocket_request.go new file mode 100644 index 000000000..d0f35f68b --- /dev/null +++ b/model/websocket_request.go @@ -0,0 +1,43 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "encoding/json" + "io" + + goi18n "github.com/nicksnyder/go-i18n/i18n" +) + +type WebSocketRequest struct { + // Client-provided fields + Seq int64 `json:"seq"` + Action string `json:"action"` + Data map[string]interface{} `json:"data"` + + // Server-provided fields + Session Session `json:"-"` + T goi18n.TranslateFunc `json:"-"` + Locale string `json:"-"` +} + +func (o *WebSocketRequest) ToJson() string { + b, err := json.Marshal(o) + if err != nil { + return "" + } else { + return string(b) + } +} + +func WebSocketRequestFromJson(data io.Reader) *WebSocketRequest { + decoder := json.NewDecoder(data) + var o WebSocketRequest + err := decoder.Decode(&o) + if err == nil { + return &o + } else { + return nil + } +} diff --git a/model/websocket_request_test.go b/model/websocket_request_test.go new file mode 100644 index 000000000..52de82069 --- /dev/null +++ b/model/websocket_request_test.go @@ -0,0 +1,25 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +package model + +import ( + "strings" + "testing" +) + +func TestWebSocketRequest(t *testing.T) { + m := WebSocketRequest{Seq: 1, Action: "test"} + json := m.ToJson() + result := WebSocketRequestFromJson(strings.NewReader(json)) + + if result == nil { + t.Fatal("should not be nil") + } + + badresult := WebSocketRequestFromJson(strings.NewReader("junk")) + + if badresult != nil { + t.Fatal("should have been nil") + } +} -- cgit v1.2.3-1-g7c22