Hello! Today we will write a Slack bot for Scrum poker in Go language. We will write, if possible, without frameworks and external libraries, since our goal is to understand the Go programming language and check how convenient this language is for developing such projects.
Disclaimer
Go . Python. , Python - . , , , " " Go, .
, , ( ), "" . .
, !
. (web), Slack UI Block Kit (ui), / (storage), (config). :
config/
storage/
ui/
web/
-- clients/
-- server/
main.go
http
. Server
web -> server
:
server.go
package server
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"sync/atomic"
"time"
)
type Server struct {
// main.go
healthy int32
logger *log.Logger
}
func NewServer(logger *log.Logger) *Server {
return &Server{
logger: logger,
}
}
. . , main.go
, . . . . . , . :
server.go
func (s *Server) setupRouter() http.Handler { // TODO
router := http.NewServeMux()
return router
}
func (s *Server) Serve(address string) {
server := &http.Server{
Addr: address,
Handler: s.setupRouter(),
ErrorLog: s.logger, //
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 15 * time.Second,
}
//
done := make(chan bool)
quit := make(chan os.Signal, 1)
//
signal.Notify(quit, os.Interrupt)
go func() {
<-quit
s.logger.Println("Server is shutting down...")
// healthcheck'
atomic.StoreInt32(&s.healthy, 0)
// 30 ,
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// ,
server.SetKeepAlivesEnabled(false)
//
if err := server.Shutdown(ctx); err != nil {
s.logger.Fatalf("Could not gracefully shutdown the server: %v\n", err)
}
close(done)
}()
s.logger.Println("Server is ready to handle requests at", address)
// ,
atomic.StoreInt32(&s.healthy, 1)
//
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
s.logger.Fatalf("Could not listen on %s: %v\n", address, err)
}
// ,
<-done
s.logger.Println("Server stopped")
}
. web -> server -> handlers
:
healthcheck.go
package handlers
import (
"net/http"
)
func Healthcheck() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write("OK")
})
}
:
server.go
//
func (s *Server) setupRouter() http.Handler {
router := http.NewServeMux()
router.Handle(
"/healthcheck",
handlers.Healthcheck(),
)
return router
}
//
main.go
:
package main
import (
"log"
"os"
"go-scrum-poker-bot/web/server"
)
func main() {
// "INFO:".
// stdout
logger := log.New(os.Stdout, "INFO: ", log.LstdFlags)
app := server.NewServer(logger)
app.Serve(":8000")
}
:
go run main.go
, :8000
. . , . ;) , , Slack .
NGROK
, , ngrok. , . Slack . , , :
ngrok http 8000
, - :
ngrok by @inconshreveable (Ctrl+C to quit)
Session Status online
Account Sayakhov Ilya (Plan: Free)
Version 2.3.35
Region United States (us)
Web Interface http://127.0.0.1:4040
Forwarding http://ffd3cfcc460c.ngrok.io -> http://localhost:8000
Forwarding https://ffd3cfcc460c.ngrok.io -> http://localhost:8000
Connections ttl opn rt1 rt5 p50 p90
0 0 0.00 0.00 0.00 0.00
https://ffd3cfcc460c.ngrok.io
. .
Slash commands
Slack. -> Create New App
. GoScrumPokerBot
Workspace
. , . OAuth & Permissions -> Scopes
: chat:write
, commands
. , , slash . Reinstall to Workspace
. ! Slash commands
/poker
.
Request URL + . : https://ffd3cfcc460c.ngrok.io/play-poker
.
Slash command handler
. web -> server -> handlers
play_poker.go
:
func PlayPokerCommand() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"response_type": "ephemeral", "text": "Hello world!"}`))
})
}
:
server.go
func (s *Server) setupRouter() http.Handler {
router := http.NewServeMux()
router.Handle(
"/healthcheck",
handlers.Healthcheck(),
)
router.Handle(
"/play-poker",
handlers.PlayPokerCommand(),
)
return router
}
Slack : /poker
. - :
Slack. . . ( ). http
. web -> clients
. client.go
:
client.go
package clients
//
type Handler func(request *Request) *Response
// middleware ( )
type Middleware func(handler Handler, request *Request) Handler
// http
type Client interface {
Make(request *Request) *Response
}
//
type BasicClient struct {
client *http.Client
middleware []Middleware
}
func NewBasicClient(client *http.Client, middleware []Middleware) Client {
return &BasicClient{client: client, middleware: middleware}
}
//
func (c *BasicClient) makeRequest(request *Request) *Response {
payload, err := request.ToBytes() // TODO
if err != nil {
return &Response{Error: err}
}
// request,
req, err := http.NewRequest(request.Method, request.URL, bytes.NewBuffer(payload))
if err != nil {
return &Response{Error: err}
}
//
for name, value := range request.Headers {
req.Header.Add(name, value)
}
//
resp, err := c.client.Do(req)
if err != nil {
return &Response{Error: err}
}
defer resp.Body.Close()
//
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return &Response{Error: err}
}
err = nil
// - 20x,
if resp.StatusCode > http.StatusIMUsed || resp.StatusCode < http.StatusOK {
err = fmt.Errorf("Bad response. Status: %d, Body: %s", resp.StatusCode, string(body))
}
return &Response{
Status: resp.StatusCode,
Body: body,
Headers: resp.Header,
Error: err,
}
}
//
func (c *BasicClient) Make(request *Request) *Response {
if request.Headers == nil {
request.Headers = make(map[string]string)
}
// middleware
handler := c.makeRequest
for _, middleware := range c.middleware {
handler = middleware(handler, request)
}
return handler(request)
}
web -> clients
:
request.go
package clients
import "encoding/json"
type Request struct {
URL string
Method string
Headers map[string]string
Json interface{}
}
func (r *Request) ToBytes() ([]byte, error) {
if r.Json != nil {
result, err := json.Marshal(r.Json)
if err != nil {
return []byte{}, err
}
return result, nil
}
return []byte{}, nil
}
ToBytes()
. testify/assert, if', :) . , pytest
assert
, - :
request_test.go
package clients_test
import (
"encoding/json"
"go-scrum-poker-bot/web/clients"
"reflect"
"testing"
"github.com/stretchr/testify/assert"
)
func TestRequestToBytes(t *testing.T) {
// - pytest.parametrize (, Go , )
testCases := []struct {
json interface{}
data []byte
err error
}{
{map[string]string{"test_key": "test_value"}, []byte("{\"test_key\":\"test_value\"}"), nil},
{nil, []byte{}, nil},
{make(chan int), []byte{}, &json.UnsupportedTypeError{Type: reflect.TypeOf(make(chan int))}},
}
//
for _, testCase := range testCases {
request := clients.Request{
URL: "https://example.com",
Method: "GET",
Headers: nil,
Json: testCase.json,
}
actual, err := request.ToBytes()
//
assert.Equal(t, testCase.err, err)
assert.Equal(t, testCase.data, actual)
}
}
web -> clients:
response.go
package clients
import "encoding/json"
type Response struct {
Status int
Headers map[string][]string
Body []byte
Error error
}
// , if err != nil
func (r *Response) Json(to interface{}) error {
if r.Error != nil {
return r.Error
}
return json.Unmarshal(r.Body, to)
}
, Json(to interface{})
:
response_test.go
package clients_test
import (
"errors"
"go-scrum-poker-bot/web/clients"
"testing"
"github.com/stretchr/testify/assert"
)
//
func TestResponseJson(t *testing.T) {
to := struct {
TestKey string `json:"test_key"`
}{}
response := clients.Response{
Status: 200,
Headers: nil,
Body: []byte(`{"test_key": "test_value"}`),
Error: nil,
}
err := response.Json(&to)
assert.Equal(t, nil, err)
assert.Equal(t, "test_value", to.TestKey)
}
//
func TestResponseJsonError(t *testing.T) {
expectedErr := errors.New("Error!")
response := clients.Response{
Status: 200,
Headers: nil,
Body: nil,
Error: expectedErr,
}
err := response.Json(map[string]string{})
assert.Equal(t, expectedErr, err)
}
, , . http
. http
. , :
client_test.go
package clients_test
import (
"bytes"
"go-scrum-poker-bot/web/clients"
"io/ioutil"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
)
//
type RoundTripFunc func(request *http.Request) *http.Response
func (f RoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
return f(request), nil
}
// mock
func NewTestClient(fn RoundTripFunc) *http.Client {
return &http.Client{
Transport: RoundTripFunc(fn),
}
}
//
func TestMakeRequest(t *testing.T) {
url := "https://example.com/ok"
// mock
httpClient := NewTestClient(func(req *http.Request) *http.Response {
assert.Equal(t, req.URL.String(), url)
return &http.Response{
StatusCode: http.StatusOK,
Body: ioutil.NopCloser(bytes.NewBufferString("OK")),
Header: make(http.Header),
}
})
// http http
webClient := clients.NewBasicClient(httpClient, nil)
response := webClient.Make(&clients.Request{
URL: url,
Method: "GET",
Headers: map[string]string{"Content-Type": "application/json"},
Json: nil,
})
assert.Equal(t, http.StatusOK, response.Status)
}
// response
func TestMakeRequestError(t *testing.T) {
url := "https://example.com/error"
httpClient := NewTestClient(func(req *http.Request) *http.Response {
assert.Equal(t, req.URL.String(), url)
return &http.Response{
StatusCode: http.StatusBadGateway,
Body: ioutil.NopCloser(bytes.NewBufferString("Bad gateway")),
Header: make(http.Header),
}
})
webClient := clients.NewBasicClient(httpClient, nil)
response := webClient.Make(&clients.Request{
URL: url,
Method: "GET",
Headers: map[string]string{"Content-Type": "application/json"},
Json: nil,
})
assert.Equal(t, http.StatusBadGateway, response.Status)
}
! middleware
. , , middleware
. / API / . Slack Authorization
, OAuth & Permissions
. web -> clients -> middleware
:
auth.go
package middleware
import (
"fmt"
"go-scrum-poker-bot/web/clients"
)
// middleware
func Auth(token string) clients.Middleware {
return func(handler clients.Handler, request *clients.Request) clients.Handler {
return func(request *clients.Request) *clients.Response {
request.Headers["Authorization"] = fmt.Sprintf("Bearer %s", token)
return handler(request)
}
}
}
:
auth_test.go
package middleware_test
import (
"fmt"
"go-scrum-poker-bot/web/clients"
"go-scrum-poker-bot/web/clients/middleware"
"testing"
"github.com/stretchr/testify/assert"
)
func TestAuthMiddleware(t *testing.T) {
token := "test"
request := &clients.Request{
Headers: map[string]string{},
}
handler := middleware.Auth(token)(
func(request *clients.Request) *clients.Response {
return &clients.Response{}
},
request,
)
handler(request)
assert.Equal(t, map[string]string{"Authorization": fmt.Sprintf("Bearer %s", token)}, request.Headers)
}
middleware
Content-Type: application/json
. :).
PlayPoker
:
play_poker.go
package handlers
import (
"errors"
"go-scrum-poker-bot/ui"
"go-scrum-poker-bot/web/clients"
"go-scrum-poker-bot/web/server/models"
"net/http"
"github.com/google/uuid"
)
func PlayPokerCommand(webClient clients.Client, uiBuilder *ui.Builder) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// , POST Form ID
if r.PostFormValue("channel_id") == "" || r.PostFormValue("text") == "" {
w.Write(models.ResponseError(errors.New("Please write correct subject"))) // TODO
return
}
resp := webClient.Make(&clients.Request{
URL: "https://slack.com/api/chat.postMessage",
Method: "POST",
Json: uiBuilder.Build( // TODO: builder
r.PostFormValue("channel_id"),
uuid.New().String(),
r.PostFormValue("text"),
nil,
false,
),
})
if resp.Error != nil {
w.Write(models.ResponseError(resp.Error)) // TODO
return
}
})
}
web -> server -> models
. errors.go
:
errors.go
package models
import (
"encoding/json"
"fmt"
)
type SlackError struct {
ResponseType string `json:"response_type"`
Text string `json:"text"`
}
func ResponseError(err error) []byte {
resp, err := json.Marshal(
SlackError{
ResponseType: "ephemeral",
Text: fmt.Sprintf("Sorry, there is some error happened. Error: %s", err.Error()),
},
)
if err != nil {
return []byte("Sorry. Some error happened")
}
return resp
}
:
play_poker_test.go
package handlers_test
import (
"errors"
"go-scrum-poker-bot/config"
"go-scrum-poker-bot/ui"
"go-scrum-poker-bot/web/server/handlers"
"go-scrum-poker-bot/web/server/models"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestPlayPokerHandler(t *testing.T) {
config := config.NewConfig() // TODO
mockClient := &MockClient{}
uiBuilder := ui.NewBuilder(config) // TODO
responseRec := httptest.NewRecorder()
router := http.NewServeMux()
router.Handle("/play-poker", handlers.PlayPokerCommand(mockClient, uiBuilder))
payload := url.Values{"channel_id": {"test"}, "text": {"test"}}.Encode()
request, err := http.NewRequest("POST", "/play-poker", strings.NewReader(payload))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
router.ServeHTTP(responseRec, request)
assert.Nil(t, err)
assert.Equal(t, http.StatusOK, responseRec.Code)
assert.Empty(t, responseRec.Body.String())
assert.Equal(t, true, mockClient.Called)
}
func TestPlayPokerHandlerEmptyBodyError(t *testing.T) {
config := config.NewConfig()
mockClient := &MockClient{}
uiBuilder := ui.NewBuilder(config)
responseRec := httptest.NewRecorder()
router := http.NewServeMux()
router.Handle("/play-poker", handlers.PlayPokerCommand(mockClient, uiBuilder))
payload := url.Values{}.Encode()
request, _ := http.NewRequest("POST", "/play-poker", strings.NewReader(payload))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
router.ServeHTTP(responseRec, request)
expected := string(models.ResponseError(errors.New("Please write correct subject")))
assert.Equal(t, http.StatusOK, responseRec.Code)
assert.Equal(t, expected, responseRec.Body.String())
assert.Equal(t, false, mockClient.Called)
}
func TestPlayPokerHandlerRequestError(t *testing.T) {
errMsg := "Error msg"
config := config.NewConfig() // TODO
mockClient := &MockClient{Error: errMsg}
uiBuilder := ui.NewBuilder(config) // TODO
responseRec := httptest.NewRecorder()
router := http.NewServeMux()
router.Handle("/play-poker", handlers.PlayPokerCommand(mockClient, uiBuilder))
payload := url.Values{"channel_id": {"test"}, "text": {"test"}}.Encode()
request, _ := http.NewRequest("POST", "/play-poker", strings.NewReader(payload))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
router.ServeHTTP(responseRec, request)
expected := string(models.ResponseError(errors.New(errMsg)))
assert.Equal(t, http.StatusOK, responseRec.Code)
assert.Equal(t, expected, responseRec.Body.String())
assert.Equal(t, true, mockClient.Called)
}
mock
http
:
common_test.go
package handlers_test
import (
"errors"
"go-scrum-poker-bot/web/clients"
)
type MockClient struct {
Called bool
Error string
}
func (c *MockClient) Make(request *clients.Request) *clients.Response {
c.Called = true
var err error = nil
if c.Error != "" {
err = errors.New(c.Error)
}
return &clients.Response{Error: err}
}
, PlayPoker
.
UI Slack UI Block Kit. , . , Slack API . UI Builder
ui . , , . , , ( , ) block_id
. action_id
.
. config :
config.go
package config
type Config struct {
App *App
Slack *Slack
Redis *Redis
}
func NewConfig() *Config {
return &Config{
App: &App{
ServerAddress: getStrEnv("WEB_SERVER_ADDRESS", ":8000"),
PokerRanks: getListStrEnv("POKER_RANKS", "?,0,0.5,1,2,3,5,8,13,20,40,100"),
},
Slack: &Slack{
Token: getStrEnv("SLACK_TOKEN", "FILL_ME"),
},
//
Redis: &Redis{
Host: getStrEnv("REDIS_HOST", "0.0.0.0"),
Port: getIntEnv("REDIS_PORT", "6379"),
DB: getIntEnv("REDIS_DB", "0"),
},
}
}
// env default
func getStrEnv(key string, defaultValue string) string {
if value, ok := os.LookupEnv(key); ok {
return value
}
return defaultValue
}
// int env default
func getIntEnv(key string, defaultValue string) int {
value, err := strconv.Atoi(getStrEnv(key, defaultValue))
if err != nil {
panic(fmt.Sprintf("Incorrect env value for %s", key))
}
return value
}
// (e.g. 0,1,2,3,4,5) env default
func getListStrEnv(key string, defaultValue string) []string {
value := []string{}
for _, item := range strings.Split(getStrEnv(key, defaultValue), ",") {
value = append(value, strings.TrimSpace(item))
}
return value
}
. :
config_test.go
package config_test
import (
"go-scrum-poker-bot/config"
"os"
"testing"
"github.com/stretchr/testify/assert"
)
func TestNewConfig(t *testing.T) {
c := config.NewConfig()
assert.Equal(t, "0.0.0.0", c.Redis.Host)
assert.Equal(t, 6379, c.Redis.Port)
assert.Equal(t, 0, c.Redis.DB)
assert.Equal(t, []string{"?", "0", "0.5", "1", "2", "3", "5", "8", "13", "20", "40", "100"}, c.App.PokerRanks)
}
func TestNewConfigIncorrectIntFromEnv(t *testing.T) {
os.Setenv("REDIS_PORT", "-")
assert.Panics(t, func() { config.NewConfig() })
}
, . main.go
:
main.go
package main
import (
"fmt"
"go-scrum-poker-bot/config"
"go-scrum-poker-bot/ui"
"go-scrum-poker-bot/web/clients"
clients_middleware "go-scrum-poker-bot/web/clients/middleware"
"go-scrum-poker-bot/web/server"
"log"
"net/http"
"os"
"time"
)
func main() {
logger := log.New(os.Stdout, "INFO: ", log.LstdFlags)
config := config.NewConfig()
builder := ui.NewBuilder(config)
webClient := clients.NewBasicClient(
&http.Client{
Timeout: 5 * time.Second,
},
[]clients.Middleware{ // middleware
clients_middleware.Auth(config.Slack.Token),
clients_middleware.JsonContentType,
clients_middleware.Log(logger),
},
)
app := server.NewServer(
logger,
webClient,
builder,
)
app.Serve(config.App.ServerAddress)
}
/poker
.
Slack Interactivity
. Your apps -> -> Interactivity & Shortcuts
. Request URL :
https://ffd3cfcc460c.ngrok.io/interactivity
InteractionCallback
web -> server -> handlers
:
interaction_callback.go
package handlers
import (
"go-scrum-poker-bot/storage"
"go-scrum-poker-bot/ui"
"go-scrum-poker-bot/ui/blocks"
"go-scrum-poker-bot/web/clients"
"go-scrum-poker-bot/web/server/models"
"net/http"
)
func InteractionCallback(
userStorage storage.UserStorage,
sessionStorage storage.SessionStorage,
uiBuilder *ui.Builder,
webClient clients.Client,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var callback models.Callback
//
data, err := callback.SerializedData([]byte(r.PostFormValue("payload")))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// TODO:
users := userStorage.All(data.SessionID)
visible := sessionStorage.GetVisibility(data.SessionID)
err = nil
//
switch data.Action.ActionID {
case ui.VOTE_ACTION_ID:
users[callback.User.Username] = data.Action.SelectedOption.Value
err = userStorage.Save(data.SessionID, callback.User.Username, data.Action.SelectedOption.Value)
case ui.RESULTS_VISIBILITY_ACTION_ID:
visible = !visible
err = sessionStorage.SetVisibility(data.SessionID, visible)
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// response URL.
resp := webClient.Make(&clients.Request{
URL: callback.ResponseURL,
Method: "POST",
Json: &blocks.Interactive{
ReplaceOriginal: true,
Blocks: uiBuilder.BuildBlocks(data.Subject, users, data.SessionID, visible),
LinkNames: true,
},
})
if resp.Error != nil {
http.Error(w, resp.Error.Error(), http.StatusInternalServerError)
return
}
})
}
. . storage
:
storage.go
package storage
type UserStorage interface {
All(sessionID string) map[string]string
Save(sessionID string, username string, value string) error
}
type SessionStorage interface {
GetVisibility(sessionID string) bool
SetVisibility(sessionID string, state bool) error
}
, , , Redis ( ).
Callback
. web -> server -> models
:
callback.go
package models
import (
"encoding/json"
"errors"
"go-scrum-poker-bot/ui"
)
type User struct {
Username string `json:"username"`
}
type Text struct {
Type string `json:"type"`
Text string `json:"text"`
}
type Block struct {
Type string `json:"type"`
BlockID string `json:"block_id"`
Text *Text `json:"text,omitempty"`
}
type Message struct {
Blocks []*Block `json:"blocks,omitempty"`
}
type SelectedOption struct {
Value string `json:"value"`
}
type Action struct {
BlockID string `json:"block_id"`
ActionID string `json:"action_id"`
Value string `json:"value,omitempty"`
SelectedOption *SelectedOption `json:"selected_option,omitempty"`
}
type SerializedData struct {
SessionID string
Subject string
Action *Action
}
type Callback struct {
ResponseURL string `json:"response_url"`
User *User `json:"user"`
Actions []*Action `json:"actions"`
Message *Message `json:"message,omitempty"`
}
// ID ,
func (c *Callback) getSessionID() (string, error) {
for _, action := range c.Actions {
if action.BlockID != "" {
return action.BlockID, nil
}
}
return "", errors.New("Invalid session ID")
}
//
func (c *Callback) getSubject() (string, error) {
for _, block := range c.Message.Blocks {
if block.BlockID == ui.SUBJECT_BLOCK_ID && block.Text != nil {
return block.Text.Text, nil
}
}
return "", errors.New("Invalid subject")
}
//
func (c *Callback) getAction() (*Action, error) {
for _, action := range c.Actions {
if action.ActionID == ui.VOTE_ACTION_ID || action.ActionID == ui.RESULTS_VISIBILITY_ACTION_ID {
return action, nil
}
}
return nil, errors.New("Invalid action")
}
func (c *Callback) SerializedData(data []byte) (*SerializedData, error) {
err := json.Unmarshal(data, c)
if err != nil {
return nil, err
}
sessionID, err := c.getSessionID()
if err != nil {
return nil, err
}
subject, err := c.getSubject()
if err != nil {
return nil, err
}
action, err := c.getAction()
if err != nil {
return nil, err
}
return &SerializedData{
SessionID: sessionID,
Subject: subject,
Action: action,
}, nil
}
:
interaction_callback_test.go
package handlers_test
import (
"encoding/json"
"go-scrum-poker-bot/config"
"go-scrum-poker-bot/ui"
"go-scrum-poker-bot/web/server/handlers"
"go-scrum-poker-bot/web/server/models"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestInteractionCallbackHandlerActions(t *testing.T) {
config := config.NewConfig()
mockClient := &MockClient{}
mockUserStorage := &MockUserStorage{}
mockSessionStorage := &MockSessionStorage{}
uiBuilder := ui.NewBuilder(config)
router := http.NewServeMux()
router.Handle(
"/interactivity",
handlers.InteractionCallback(mockUserStorage, mockSessionStorage, uiBuilder, mockClient),
)
actions := []*models.Action{
{
BlockID: "test",
ActionID: ui.RESULTS_VISIBILITY_ACTION_ID,
Value: "test",
SelectedOption: nil,
},
{
BlockID: "test",
ActionID: ui.VOTE_ACTION_ID,
Value: "test",
SelectedOption: &models.SelectedOption{Value: "1"},
},
}
//
for _, action := range actions {
responseRec := httptest.NewRecorder()
data, _ := json.Marshal(models.Callback{
ResponseURL: "test",
User: &models.User{Username: "test"},
Actions: []*models.Action{action},
Message: &models.Message{
Blocks: []*models.Block{
{
Type: "test",
BlockID: ui.SUBJECT_BLOCK_ID,
Text: &models.Text{Type: "test", Text: "test"},
},
},
},
})
payload := url.Values{"payload": {string(data)}}.Encode()
request, err := http.NewRequest("POST", "/interactivity", strings.NewReader(payload))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
router.ServeHTTP(responseRec, request)
assert.Nil(t, err)
assert.Equal(t, http.StatusOK, responseRec.Code)
assert.Empty(t, responseRec.Body.String())
assert.Equal(t, true, mockClient.Called)
}
}
mock
. common_test.go
:
common_test.go
//
type MockUserStorage struct{}
func (s *MockUserStorage) All(sessionID string) map[string]string {
return map[string]string{"user": "1"}
}
func (s *MockUserStorage) Save(sessionID string, username string, value string) error {
return nil
}
type MockSessionStorage struct{}
func (s *MockSessionStorage) GetVisibility(sessionID string) bool {
return true
}
func (s *MockSessionStorage) SetVisibility(sessionID string, state bool) error {
return nil
}
:
server.go
//
func (s *Server) setupRouter() http.Handler {
router := http.NewServeMux()
router.Handle(
"/healthcheck",
handlers.Healthcheck(),
)
router.Handle(
"/play-poker",
handlers.PlayPokerCommand(s.webClient, s.uiBuilder),
)
router.Handle(
"/interactivity",
handlers.InteractionCallback(s.userStorage, s.sessionStorage, s.uiBuilder, s.webClient),
)
return router
}
//
, , + - , . middleware
. web -> server -> middleware
:
log.go
package middleware
import (
"log"
"net/http"
)
func Log(logger *log.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
logger.Printf(
"Handle request: [%s]: %s - %s - %s",
r.Method, r.URL.Path, r.RemoteAddr, r.UserAgent(),
)
}()
next.ServeHTTP(w, r)
})
}
}
:
log_test.go
package middleware_test
import (
"bytes"
"go-scrum-poker-bot/web/server/middleware"
"log"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
type logHandler struct{}
func (h *logHandler) ServeHTTP(http.ResponseWriter, *http.Request) {}
func TestLogMiddleware(t *testing.T) {
var buf bytes.Buffer
logger := log.New(os.Stdout, "INFO: ", log.LstdFlags)
// output ,
logger.SetOutput(&buf)
handler := &logHandler{}
// mock recorder Go
responseRec := httptest.NewRecorder()
router := http.NewServeMux()
router.Handle("/test", middleware.Log(logger)(handler))
request, err := http.NewRequest("GET", "/test", strings.NewReader(""))
router.ServeHTTP(responseRec, request)
assert.Nil(t, err)
assert.Equal(t, http.StatusOK, responseRec.Code)
// , - . , , middleware
assert.NotEmpty(t, buf.String())
}
. Redis, , - , . go-redis redismock .
Scrum Poker . storage
:
users.go
package storage
import (
"context"
"fmt"
"github.com/go-redis/redis/v8"
)
//
const SESSION_USERS_TPL = "SESSION:%s:USERS"
const USER_VOTE_TPL = "SESSION:%s:USERNAME:%s:VOTE"
type UserRedisStorage struct {
redis *redis.Client
context context.Context
}
func NewUserRedisStorage(redisClient *redis.Client) *UserRedisStorage {
return &UserRedisStorage{
redis: redisClient,
context: context.Background(),
}
}
func (s *UserRedisStorage) All(sessionID string) map[string]string {
users := make(map[string]string)
// set, .
//
for _, username := range s.redis.SMembers(s.context, fmt.Sprintf(SESSION_USERS_TPL, sessionID)).Val() {
users[username] = s.redis.Get(s.context, fmt.Sprintf(USER_VOTE_TPL, sessionID, username)).Val()
}
return users
}
func (s *UserRedisStorage) Save(sessionID string, username string, value string) error {
err := s.redis.SAdd(s.context, fmt.Sprintf(SESSION_USERS_TPL, sessionID), username).Err()
if err != nil {
return err
}
// .
// , , -1
err = s.redis.Set(s.context, fmt.Sprintf(USER_VOTE_TPL, sessionID, username), value, -1).Err()
if err != nil {
return err
}
return nil
}
:
users_test.go
package storage_test
import (
"errors"
"fmt"
"go-scrum-poker-bot/storage"
"testing"
"github.com/go-redis/redismock/v8"
"github.com/stretchr/testify/assert"
)
func TestAll(t *testing.T) {
sessionID, username, value := "test", "user", "1"
redisClient, mock := redismock.NewClientMock()
usersStorage := storage.NewUserRedisStorage(redisClient)
// Redis mock
mock.ExpectSMembers(
fmt.Sprintf(storage.SESSION_USERS_TPL, sessionID),
).SetVal([]string{username})
mock.ExpectGet(
fmt.Sprintf(storage.USER_VOTE_TPL, sessionID, username),
).SetVal(value)
assert.Equal(t, map[string]string{username: value}, usersStorage.All(sessionID))
}
func TestSave(t *testing.T) {
sessionID, username, value := "test", "user", "1"
redisClient, mock := redismock.NewClientMock()
usersStorage := storage.NewUserRedisStorage(redisClient)
mock.ExpectSAdd(
fmt.Sprintf(storage.SESSION_USERS_TPL, sessionID),
username,
).SetVal(1)
mock.ExpectSet(
fmt.Sprintf(storage.USER_VOTE_TPL, sessionID, username),
value,
-1,
).SetVal(value)
assert.Equal(t, nil, usersStorage.Save(sessionID, username, value))
}
func TestSaveSAddErr(t *testing.T) {
sessionID, username, value, err := "test", "user", "1", errors.New("ERROR")
redisClient, mock := redismock.NewClientMock()
usersStorage := storage.NewUserRedisStorage(redisClient)
mock.ExpectSAdd(
fmt.Sprintf(storage.SESSION_USERS_TPL, sessionID),
username,
).SetErr(err)
assert.Equal(t, err, usersStorage.Save(sessionID, username, value))
}
func TestSaveSetErr(t *testing.T) {
sessionID, username, value, err := "test", "user", "1", errors.New("ERROR")
redisClient, mock := redismock.NewClientMock()
usersStorage := storage.NewUserRedisStorage(redisClient)
mock.ExpectSAdd(
fmt.Sprintf(storage.SESSION_USERS_TPL, sessionID),
username,
).SetVal(1)
mock.ExpectSet(
fmt.Sprintf(storage.USER_VOTE_TPL, sessionID, username),
value,
-1,
).SetErr(err)
assert.Equal(t, err, usersStorage.Save(sessionID, username, value))
}
"" . :
sessions.go
package storage
import (
"context"
"fmt"
"strconv"
"github.com/go-redis/redis/v8"
)
//
const SESSION_VOTES_HIDDEN_TPL = "SESSION:%s:VOTES_HIDDEN"
type SessionRedisStorage struct {
redis *redis.Client
context context.Context
}
func NewSessionRedisStorage(redisClient *redis.Client) *SessionRedisStorage {
return &SessionRedisStorage{
redis: redisClient,
context: context.Background(),
}
}
func (s *SessionRedisStorage) GetVisibility(sessionID string) bool {
value, _ := strconv.ParseBool(
s.redis.Get(s.context, fmt.Sprintf(SESSION_VOTES_HIDDEN_TPL, sessionID)).Val(),
)
return value
}
func (s *SessionRedisStorage) SetVisibility(sessionID string, state bool) error {
return s.redis.Set(
s.context,
fmt.Sprintf(SESSION_VOTES_HIDDEN_TPL, sessionID),
strconv.FormatBool(state),
-1,
).Err()
}
:
sessions_test.go
package storage_test
import (
"errors"
"fmt"
"go-scrum-poker-bot/storage"
"strconv"
"testing"
"github.com/go-redis/redismock/v8"
"github.com/stretchr/testify/assert"
)
func TestGetVisibility(t *testing.T) {
sessionID, state := "test", true
redisClient, mock := redismock.NewClientMock()
mock.ExpectGet(
fmt.Sprintf(storage.SESSION_VOTES_HIDDEN_TPL, sessionID),
).SetVal(strconv.FormatBool(state))
sessionStorage := storage.NewSessionRedisStorage(redisClient)
assert.Equal(t, state, sessionStorage.GetVisibility(sessionID))
}
func TestSetVisibility(t *testing.T) {
sessionID, state := "test", true
redisClient, mock := redismock.NewClientMock()
mock.ExpectSet(
fmt.Sprintf(storage.SESSION_VOTES_HIDDEN_TPL, sessionID),
strconv.FormatBool(state),
-1,
).SetVal("1")
sessionStorage := storage.NewSessionRedisStorage(redisClient)
assert.Equal(t, nil, sessionStorage.SetVisibility(sessionID, state))
}
func TestSetVisibilityErr(t *testing.T) {
sessionID, state, err := "test", true, errors.New("ERROR")
redisClient, mock := redismock.NewClientMock()
mock.ExpectSet(
fmt.Sprintf(storage.SESSION_VOTES_HIDDEN_TPL, sessionID),
strconv.FormatBool(state),
-1,
).SetErr(err)
sessionStorage := storage.NewSessionRedisStorage(redisClient)
assert.Equal(t, err, sessionStorage.SetVisibility(sessionID, state))
}
! main.go server.go:
server.go
package server
import (
"context"
"go-scrum-poker-bot/storage"
"go-scrum-poker-bot/ui"
"go-scrum-poker-bot/web/clients"
"go-scrum-poker-bot/web/server/handlers"
"log"
"net/http"
"os"
"os/signal"
"sync/atomic"
"time"
)
// middleware
type Middleware func(next http.Handler) http.Handler
//
type Server struct {
healthy int32
middleware []Middleware
logger *log.Logger
webClient clients.Client
uiBuilder *ui.Builder
userStorage storage.UserStorage
sessionStorage storage.SessionStorage
}
//
func NewServer(
logger *log.Logger,
webClient clients.Client,
uiBuilder *ui.Builder,
userStorage storage.UserStorage,
sessionStorage storage.SessionStorage,
middleware []Middleware,
) *Server {
return &Server{
logger: logger,
webClient: webClient,
uiBuilder: uiBuilder,
userStorage: userStorage,
sessionStorage: sessionStorage,
middleware: middleware,
}
}
func (s *Server) setupRouter() http.Handler {
router := http.NewServeMux()
router.Handle(
"/healthcheck",
handlers.Healthcheck(),
)
router.Handle(
"/play-poker",
handlers.PlayPokerCommand(s.webClient, s.uiBuilder),
)
router.Handle(
"/interactivity",
handlers.InteractionCallback(s.userStorage, s.sessionStorage, s.uiBuilder, s.webClient),
)
return router
}
func (s *Server) setupMiddleware(router http.Handler) http.Handler {
handler := router
for _, middleware := range s.middleware {
handler = middleware(handler)
}
return handler
}
func (s *Server) Serve(address string) {
server := &http.Server{
Addr: address,
Handler: s.setupMiddleware(s.setupRouter()),
ErrorLog: s.logger,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 15 * time.Second,
}
done := make(chan bool)
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt)
go func() {
<-quit
s.logger.Println("Server is shutting down...")
atomic.StoreInt32(&s.healthy, 0)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
server.SetKeepAlivesEnabled(false)
if err := server.Shutdown(ctx); err != nil {
s.logger.Fatalf("Could not gracefully shutdown the server: %v\n", err)
}
close(done)
}()
s.logger.Println("Server is ready to handle requests at", address)
atomic.StoreInt32(&s.healthy, 1)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
s.logger.Fatalf("Could not listen on %s: %v\n", address, err)
}
<-done
s.logger.Println("Server stopped")
}
main.go
package main
import (
"fmt"
"go-scrum-poker-bot/config"
"go-scrum-poker-bot/storage"
"go-scrum-poker-bot/ui"
"go-scrum-poker-bot/web/clients"
clients_middleware "go-scrum-poker-bot/web/clients/middleware"
"go-scrum-poker-bot/web/server"
server_middleware "go-scrum-poker-bot/web/server/middleware"
"log"
"net/http"
"os"
"time"
"github.com/go-redis/redis/v8"
)
func main() {
logger := log.New(os.Stdout, "INFO: ", log.LstdFlags)
config := config.NewConfig()
// Redis
redisCLI := redis.NewClient(&redis.Options{
Addr: fmt.Sprintf("%s:%d", config.Redis.Host, config.Redis.Port),
DB: config.Redis.DB,
})
// users storage
userStorage := storage.NewUserRedisStorage(redisCLI)
// sessions storage
sessionStorage := storage.NewSessionRedisStorage(redisCLI)
builder := ui.NewBuilder(config)
webClient := clients.NewBasicClient(
&http.Client{
Timeout: 5 * time.Second,
},
[]clients.Middleware{
clients_middleware.Auth(config.Slack.Token),
clients_middleware.JsonContentType,
clients_middleware.Log(logger),
},
)
// Server middleware
app := server.NewServer(
logger,
webClient,
builder,
userStorage,
sessionStorage,
[]server.Middleware{server_middleware.Recover(logger), server_middleware.Log(logger), server_middleware.Json},
)
app.Serve(config.App.ServerAddress)
}
:
go test ./... -race -coverpkg=./... -coverprofile=coverage.txt -covermode=atomic
:
go tool cover -func coverage.txt
$ go tool cover -func coverage.txt go-scrum-poker-bot/config/config.go:9: NewConfig 100.0% go-scrum-poker-bot/config/helpers.go:10: getStrEnv 100.0% go-scrum-poker-bot/config/helpers.go:17: getIntEnv 100.0% go-scrum-poker-bot/config/helpers.go:26: getListStrEnv 100.0% go-scrum-poker-bot/main.go:22: main 0.0% go-scrum-poker-bot/storage/sessions.go:18: NewSessionRedisStorage 100.0% go-scrum-poker-bot/storage/sessions.go:25: GetVisibility 100.0% go-scrum-poker-bot/storage/sessions.go:33: SetVisibility 100.0% go-scrum-poker-bot/storage/users.go:18: NewUserRedisStorage 100.0% go-scrum-poker-bot/storage/users.go:25: All 100.0% go-scrum-poker-bot/storage/users.go:34: Save 100.0% go-scrum-poker-bot/ui/blocks/action.go:9: BlockType 100.0% go-scrum-poker-bot/ui/blocks/button.go:11: BlockType 100.0% go-scrum-poker-bot/ui/blocks/context.go:9: BlockType 100.0% go-scrum-poker-bot/ui/blocks/section.go:9: BlockType 100.0% go-scrum-poker-bot/ui/blocks/select.go:10: BlockType 100.0% go-scrum-poker-bot/ui/builder.go:14: NewBuilder 100.0% go-scrum-poker-bot/ui/builder.go:18: getGetResultsText 100.0% go-scrum-poker-bot/ui/builder.go:26: getResults 100.0% go-scrum-poker-bot/ui/builder.go:41: getOptions 100.0% go-scrum-poker-bot/ui/builder.go:50: BuildBlocks 100.0% go-scrum-poker-bot/ui/builder.go:100: Build 100.0% go-scrum-poker-bot/web/clients/client.go:22: NewBasicClient 100.0% go-scrum-poker-bot/web/clients/client.go:26: makeRequest 78.9% go-scrum-poker-bot/web/clients/client.go:65: Make 66.7% go-scrum-poker-bot/web/clients/middleware/auth.go:8: Auth 100.0% go-scrum-poker-bot/web/clients/middleware/json.go:5: JsonContentType 100.0% go-scrum-poker-bot/web/clients/middleware/log.go:8: Log 87.5% go-scrum-poker-bot/web/clients/request.go:12: ToBytes 100.0% go-scrum-poker-bot/web/clients/response.go:12: Json 100.0% go-scrum-poker-bot/web/server/handlers/healthcheck.go:10: Healthcheck 66.7% go-scrum-poker-bot/web/server/handlers/interaction_callback.go:12: InteractionCallback 71.4% go-scrum-poker-bot/web/server/handlers/play_poker.go:13: PlayPokerCommand 100.0% go-scrum-poker-bot/web/server/middleware/json.go:5: Json 100.0% go-scrum-poker-bot/web/server/middleware/log.go:8: Log 100.0% go-scrum-poker-bot/web/server/middleware/recover.go:9: Recover 100.0% go-scrum-poker-bot/web/server/models/callback.go:52: getSessionID 100.0% go-scrum-poker-bot/web/server/models/callback.go:62: getSubject 100.0% go-scrum-poker-bot/web/server/models/callback.go:72: getAction 100.0% go-scrum-poker-bot/web/server/models/callback.go:82: SerializedData 92.3% go-scrum-poker-bot/web/server/models/errors.go:13: ResponseError 75.0% go-scrum-poker-bot/web/server/server.go:31: NewServer 0.0% go-scrum-poker-bot/web/server/server.go:49: setupRouter 0.0% go-scrum-poker-bot/web/server/server.go:67: setupMiddleware 0.0% go-scrum-poker-bot/web/server/server.go:76: Serve 0.0% total: (statements) 75.1%
Not bad, but we don't need to factor in coverage main.go
(my opinion) and server.go
(here you can argue), so there is a hack :). We need to add the following line with tags to the beginning of the files that we want to exclude from the assessment:
//+build !test
Restart with the tag:
go test ./... -race -coverpkg=./... -coverprofile=coverage.txt -covermode=atomic -tags=test
Result:
go tool cover -func coverage.txt
$ go tool cover -func coverage.txt go-scrum-poker-bot/config/config.go:9: NewConfig 100.0% go-scrum-poker-bot/config/helpers.go:10: getStrEnv 100.0% go-scrum-poker-bot/config/helpers.go:17: getIntEnv 100.0% go-scrum-poker-bot/config/helpers.go:26: getListStrEnv 100.0% go-scrum-poker-bot/storage/sessions.go:18: NewSessionRedisStorage 100.0% go-scrum-poker-bot/storage/sessions.go:25: GetVisibility 100.0% go-scrum-poker-bot/storage/sessions.go:33: SetVisibility 100.0% go-scrum-poker-bot/storage/users.go:18: NewUserRedisStorage 100.0% go-scrum-poker-bot/storage/users.go:25: All 100.0% go-scrum-poker-bot/storage/users.go:34: Save 100.0% go-scrum-poker-bot/ui/blocks/action.go:9: BlockType 100.0% go-scrum-poker-bot/ui/blocks/button.go:11: BlockType 100.0% go-scrum-poker-bot/ui/blocks/context.go:9: BlockType 100.0% go-scrum-poker-bot/ui/blocks/section.go:9: BlockType 100.0% go-scrum-poker-bot/ui/blocks/select.go:10: BlockType 100.0% go-scrum-poker-bot/ui/builder.go:14: NewBuilder 100.0% go-scrum-poker-bot/ui/builder.go:18: getGetResultsText 100.0% go-scrum-poker-bot/ui/builder.go:26: getResults 100.0% go-scrum-poker-bot/ui/builder.go:41: getOptions 100.0% go-scrum-poker-bot/ui/builder.go:50: BuildBlocks 100.0% go-scrum-poker-bot/ui/builder.go:100: Build 100.0% go-scrum-poker-bot/web/clients/client.go:22: NewBasicClient 100.0% go-scrum-poker-bot/web/clients/client.go:26: makeRequest 78.9% go-scrum-poker-bot/web/clients/client.go:65: Make 66.7% go-scrum-poker-bot/web/clients/middleware/auth.go:8: Auth 100.0% go-scrum-poker-bot/web/clients/middleware/json.go:5: JsonContentType 100.0% go-scrum-poker-bot/web/clients/middleware/log.go:8: Log 87.5% go-scrum-poker-bot/web/clients/request.go:12: ToBytes 100.0% go-scrum-poker-bot/web/clients/response.go:12: Json 100.0% go-scrum-poker-bot/web/server/handlers/healthcheck.go:10: Healthcheck 66.7% go-scrum-poker-bot/web/server/handlers/interaction_callback.go:12: InteractionCallback 71.4% go-scrum-poker-bot/web/server/handlers/play_poker.go:13: PlayPokerCommand 100.0% go-scrum-poker-bot/web/server/middleware/json.go:5: Json 100.0% go-scrum-poker-bot/web/server/middleware/log.go:8: Log 100.0% go-scrum-poker-bot/web/server/middleware/recover.go:9: Recover 100.0% go-scrum-poker-bot/web/server/models/callback.go:52: getSessionID 100.0% go-scrum-poker-bot/web/server/models/callback.go:62: getSubject 100.0% go-scrum-poker-bot/web/server/models/callback.go:72: getAction 100.0% go-scrum-poker-bot/web/server/models/callback.go:82: SerializedData 92.3% go-scrum-poker-bot/web/server/models/errors.go:13: ResponseError 75.0% total: (statements) 90.9%
I like this result more :)
I’ll stop here. You can find all the code here . Thanks for attention!
UPDATE: I thought that there would be material for two articles, but it did not work out, so there will be no second part.