If you are a Go programmer you know how easy it is to whip up an application that speaks HTTP. Go was born for the task. So it will come as no surprise that it’s possible to create an Atlassian Connect for HipChat add-on with less than two hundred lines of commented code. What will this code accomplish? A new custom command /test_hook
, installable on any channel you are administrator of:
This post will show you the code to do just that. Our only dependency is the tiny and awesome mux router to simplify creating HTTP Handlers and Golang HipChat.
The data structures
For the bare bones app I have in mind the data structure we need is just a Context
to keep track of:
- The folder that holds the static assets to serve
- The base URL of our add-on – used for testing with ngrok and to fill the Atlassian Connect descriptor
atlassian-connect.json
- A map of room names to OAuth2 tokens so that our add-on can be installed on any HipChat room independently.
// RoomConfig holds information to send authenticated messages to all rooms
type RoomConfig struct {
token *hipchat.OAuthAccessToken
hc *hipchat.Client
name string
}
// Context keep context of the running application
type Context struct {
baseURL string
static string
rooms map[string]*RoomConfig
}
The main of our application
The starting point of our add-on, the main
, is simple: we initialise the Context
parsing some command line parameters, we create the routes we’ll respond to and start listening on a port
.
func main() {
var (
port = flag.String("port", "8080", "web server port")
static = flag.String("static", "./static/", "static folder")
baseURL = flag.String("baseurl", os.Getenv("BASE_URL"), "local base url")
)
flag.Parse()
c := &Context{
baseURL: *baseURL,
static: *static,
rooms: make(map[string]*RoomConfig),
}
log.Printf("Base HipChat integration v0.10 - running on port:%v", *port)
r := c.routes()
http.Handle("/", r)
http.ListenAndServe(":"+*port, nil)
}
The baseURL
flag can be set manually from the command line but it is initialised from the environment variable BASE_URL
, useful thing to have when deploying your application inside a container for example.
Describe the routes
The routes of our web application are defined in a method and defer to the respective methods of our Context
:
// routes all URL routes for app add-on
func (c *Context) routes() *mux.Router {
r := mux.NewRouter()
//healthcheck route required by Micros
r.Path("/healthcheck").Methods("GET").HandlerFunc(c.healthcheck)
//descriptor for Atlassian Connect
r.Path("/").Methods("GET").HandlerFunc(c.atlassianConnect)
r.Path("/atlassian-connect.json").Methods("GET").HandlerFunc(c.atlassianConnect)
// HipChat specific API routes
r.Path("/installable").Methods("POST").HandlerFunc(c.installable)
r.Path("/config").Methods("GET").HandlerFunc(c.config)
r.Path("/hook").Methods("POST").HandlerFunc(c.hook)
r.PathPrefix("/").Handler(http.FileServer(http.Dir(c.static)))
return r
}
The HipChat API version 2 will invoke several callback URLs to customise and embed our application. To do this HipChat will scan the capabilities we have specified in atlassian-connect.json
.
/installable
will be invoked when our add-on is installed to a HipChat room./config
will be invoked to display the configuration pane of our application./hook
will be invoked when a command/test_hook
is typed in the HipChat room.
A walk-through of the HTTP Handler methods
Just a simple health check to prove that our application is up and running (needed by our internal PaaS):
func (c *Context) healthcheck(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode([]string{"OK"})
}
The technique json.NewEncoder(w).Encode(...)
is a neat way to write a data structure to JSON in a HTTP Response.
Next I want to parametrise the descriptor atlassian-connect.json
with the proper baseURL so that it works both locally, with ngrok or deployed in staging and production, so we parse the JSON configuration file through the html/template library:
func (c *Context) atlassianConnect(w http.ResponseWriter, r *http.Request) {
lp := path.Join("./static", "atlassian-connect.json")
vals := map[string]string{
"LocalBaseUrl": c.baseURL,
}
tmpl, err := template.ParseFiles(lp)
if err != nil {
log.Fatalf("%v", err)
}
tmpl.ExecuteTemplate(w, "config", vals)
}
Capabilities and callbacks
The next section implements the needed web routes to respond to the HipChat API version 2 as defined in atlassian-connect.json
.
Capabilities: Installable
The /installable
callback URL is invoked when the add-on is installed and the incoming request contains a couple of critical OAuth strings that we need to store so that we can submit authenticated HipChat API calls. I use a helper function DecodePostJSON
to read the request body and store it into a Go map[string]interface{}
.
func (c *Context) installable(w http.ResponseWriter, r *http.Request) {
authPayload, err := util.DecodePostJSON(r, true)
if err != nil {
log.Fatalf("Parsed auth data failed:%vn", err)
}
credentials := hipchat.ClientCredentials{
ClientID: authPayload["oauthId"].(string),
ClientSecret: authPayload["oauthSecret"].(string),
}
roomName := strconv.Itoa(int(authPayload["roomId"].(float64)))
newClient := hipchat.NewClient("")
tok, _, err := newClient.GenerateToken(credentials, []string{hipchat.ScopeSendNotification})
if err != nil {
log.Fatalf("Client.GetAccessToken returns an error %v", err)
}
rc := &RoomConfig{
name: roomName,
hc: tok.CreateClient(),
}
c.rooms[roomName] = rc
util.PrintDump(w, r, false)
json.NewEncoder(w).Encode([]string{"OK"})
}
Capabilities: Configurable
This callback is called to display the configuration pane of your add-on:
func (c *Context) config(w http.ResponseWriter, r *http.Request) {
signedRequest := r.URL.Query().Get("signed_request")
lp := path.Join("./static", "layout.hbs")
fp := path.Join("./static", "config.hbs")
vals := map[string]string{
"LocalBaseUrl": c.baseURL,
"SignedRequest": signedRequest,
"HostScriptUrl": c.baseURL,
}
tmpl, err := template.ParseFiles(lp, fp)
if err != nil {
log.Fatalf("%v", err)
}
tmpl.ExecuteTemplate(w, "layout", vals)
}
Capabilities: Webhook
The webhook
capability allows to specify room command (/test_hook
in the example below) that will trigger a callback to the add-on. In this case we just ping back to the room a simple styled message:
func (c *Context) hook(w http.ResponseWriter, r *http.Request) {
payLoad, err := util.DecodePostJSON(r, true)
if err != nil {
log.Fatalf("Parsed auth data failed:%vn", err)
}
roomID := strconv.Itoa(int((payLoad["item"].(map[string]interface{}))["room"].(map[string]interface{})["id"].(float64)))
util.PrintDump(w, r, true)
log.Printf("Sending notification to %sn", roomID)
notifRq := &hipchat.NotificationRequest{
Message: "nice <strong>Happy Hook Day!</strong>",
MessageFormat: "html",
Color: "red",
}
if _, ok := c.rooms[roomID]; ok {
_, err = c.rooms[roomID].hc.Room.Notification(roomID, notifRq)
if err != nil {
log.Printf("Failed to notify HipChat channel:%vn", err)
}
} else {
log.Printf("Room is not registered correctly:%vn", c.rooms)
}
}
Due to us reading up payLoad
in a generic interface{}
we have to write a long winded cast to get the roomID
back.
A couple of utility functions
// PrintDump prints the dump of request, optionally writing it in the response
func PrintDump(w http.ResponseWriter, r *http.Request, write bool) {
dump, _ := httputil.DumpRequest(r, true)
log.Printf("%v", string(dump))
if write == true {
w.Write(dump)
}
}
// Decode into a ma[string]interface{} the JSON in the POST Request
func DecodePostJSON(r *http.Request, logging bool) (map[string]interface{}, error) {
var err error
var payLoad map[string]interface{}
decoder := json.NewDecoder(r.Body)
err = decoder.Decode(&payLoad)
if logging == true {
log.Printf("Parsed body:%v", payLoad)
}
return payLoad, err
}
See the entire source on Bitbucket. To run this tiny application locally do the following:
- Configure and launch ngrok with:
ngrok http 8080
- Note down the unique https URL generated by ngrok for you.
- Use the URL to pass the proper configuration to the Go program:
BASE_URL=https://808cf232.ngrok.io go run main.go
- Go to the integrations section of your HipChat room and insert the URL to your running add-on (i.e.
https://808cf232.ngrok.io/atlassian-connect.json
).
Conclusions
That’s it for now. I hope you found this piece interesting and useful. Ping me questions here in the comments or at @durdn.