Bitbucket Connect Add-on in Go

February 11th 2016 Nicola Paolucci in Atlassian Connect, Go, Bitbucket

In the past months we've been showing how to extend and integrate with Bitbucket using different languages and technology stacks. For example check out Steve Smith's series on using Clojure. Today is Go's turn.

The focus of this piece will be creating a minimal Go add-on for Bitbucket using Atlassian Connect. I love Go's essential and pragmatic design. I'll try to keep things essential also in the design of this very simple add-on.

The screenshot below shows what we'll build: a simple embedded panel that - once installed - will show in the overview page of any of our repositories. This can be the base of more complex and useful (!) developments.

end result

High level architecture

If you are not familiar with the architecture of a third party application that wants to integrate with Bitbucket, have a look at this diagram:

Bitbucket Connect Architecture

Steve has brilliantly defined what it means to build a Connect add-on:

a Connect add-on is a web application written in any language you like, running on any stack you choose, in any location you want. Once registered with the Atlassian application your web app can progressively enhance ours with new screens, features and functions that appear directly embedded as if a part of our cloud. It can also enhance our app with new behind-the-scenes logic, all via REST APIs and Web-hooks. All of Connect's components consist of standard web protocols and conventions, and, apart from a minor extension to the JWT spec, are supported by most languages and libraries out of the box. This is part of the power of Connect, and what gives it a true cross-platform experience.

Structure of the code

With the objectives and the definitions out of the way let's show some code. The source of the entire project is available on Bitbucket. The bulk of it is less than one hundred fifty lines with comments (with a couple of added helper functions for debugging) so we can easily just go through it and explain. Our only convenience dependency is the tiny and awesome mux router to simplify creating HTTP Handlers, but we could've done without it.

Main of the add-on

The main of our application is short: we parse some optional command line parameters, inject some environment variables - which are either secret and should not be hard-coded or change per deployment environment -, initialize the application Context, and start listening on a port.

func main() {
    var (
        port          = flag.String("port", "8080", "web server port")
        templatesPath = flag.String("templates", "./templates/", "templates folder")
        staticPath    = flag.String("static", "./static/", "static assets folder")
        baseUrl       = flag.String("baseurl", os.Getenv("BASE_URL"), "local base url")
        consumerKey   = flag.String("consumerkey", os.Getenv("CONSUMER_KEY"), "consumer key")
    )
    flag.Parse()

    c := &Context{
        baseUrl:       *baseUrl,
        templatesPath: *templatesPath,
        staticPath:    *staticPath,
        consumerKey:   *consumerKey,
        tenants:       make(map[string]*TenantInfo),
    }

    log.Printf("Barebones Bitbucket integration v0.1 - running on port:%v", *port)

    r := c.routes()
    http.Handle("/", r)
    http.ListenAndServe(":"+*port, nil)
}

Application routes

We create all the routes of our add-on in a simple method, deferring each to a handler function attached to the Context:

// routes all URL routes for app add-on
func (c *Context) routes() *mux.Router {
    r := mux.NewRouter()
    // [...]

    // Root route
    r.Path("/").Methods("GET").HandlerFunc(c.atlassianConnect)

    // Descriptor for Atlassian Connect
    // "atlassian-connect.json" is the main configuration for our application and
    // will be read by Bitbucket when initialising the add-on for the first time.
    r.Path("/atlassian-connect.json").Methods("GET").HandlerFunc(c.atlassianConnect)

    // Bitbucket specific API routes

    // This route will be invoked when our add-on is installed for the first time by a tenant.
    r.Path("/installed").Methods("POST").HandlerFunc(c.installed)

    // The uninstalled route will be invoked when our add-on is removed by a tenant.
    r.Path("/uninstalled").Methods("POST").HandlerFunc(c.uninstalled)

    // This route will render the web panel inside the Bitbucket's project
    // overview page of the tenant.
    r.Path("/connect-example").Methods("GET").HandlerFunc(c.example)

    r.PathPrefix("/").Handler(http.FileServer(http.Dir(c.staticPath)))
    return r
}

Bitbucket will invoke the above routes at various points during the life cycle of the add-on, we define the actual URLs in the atlassian-connect.json descriptor.

Data structures

In line with the common Go context pattern, we use a Context to keep relevant data available to the web application while we process requests:

// Context keep context of the running application
type Context struct {
    baseUrl       string
    templatesPath string
    staticPath    string
    consumerKey   string
    //Per tenant meta-data and security info
    tenants map[string]*TenantInfo
}

We need to define the baseUrl of our add-on as it will be used by the Connect framework as base to invoke into us. consumerKey is a unique key that Bitbucket has assigned to our add-on (you can generate it in your Bitbucket settings, see later for screenshot).

We also need a structure to store meta-data and security information for each "installation" of our add-on on different user accounts, let's call it TenantInfo:

// TenantInfo generated from http://mholt.github.io/json-to-go/
type TenantInfo struct {
    Producttype string      `json:"productType"`
    Principal   interface{} `json:"principal"`   //Owner of the add-on
    Eventtype   string      `json:"eventType"`
    Baseurl     string      `json:"baseUrl"`
    Publickey   string      `json:"publicKey"`
    User        interface{} `json:"user"`        //User installing the add-on
    Key         string      `json:"key"`
    Baseapiurl  string      `json:"baseApiUrl"`
    Clientkey   string      `json:"clientKey"`
    Consumer    struct {
        Description string      `json:"description"`
        Links       interface{} `json:"links"`
        URL         string      `json:"url"`
        Secret      string      `json:"secret"`
        Key         string      `json:"key"`
        ID          int         `json:"id"`
        Name        string      `json:"name"`
    } `json:"consumer"`
    Sharedsecret string `json:"sharedSecret"`
}

When I started I didn't know the full format of the tenant information: I used a trick to generate that neat structure: I dumped the JSON received from the /installed callback - which is called by Bitbucket when the add-on is first installed - into the fabulous JSON-to-Go tool to auto-generate a suitable Go struct. What you see above is the result, barring compressing a couple of fields to interface{} to shorten the code snippet.

json-to-go

HTTP Handlers

Responding to the various requests coming from Bitbucket is straightforward, we just need to write some standard handler functions. Let's go through them:

func (c *Context) atlassianConnect(w http.ResponseWriter, r *http.Request) {
    lp := path.Join("./templates", "atlassian-connect.json")
    vals := map[string]string{
        "LocalBaseUrl": c.baseUrl,
        "ConsumerKey":  c.consumerKey,
    }
    tmpl, err := template.ParseFiles(lp)
    if err != nil {
        log.Fatalf("%v", err)
        http.Error(w, err.Error(), 500)
        return
    }
    tmpl.ExecuteTemplate(w, "config", vals)
}

The handler above serves a template of the atlassian-connect.json descriptor, interpolating the proper baseUrl and consumerKey using the standard html/template Go library.

func (c *Context) installed(w http.ResponseWriter, r *http.Request) {
    log.Printf("Received /installed call")
    ti := &TenantInfo{}
    requestContent, err := ioutil.ReadAll(r.Body)
    if err != nil {
        log.Fatalf("Can't read request:%v\n", err)
        http.Error(w, err.Error(), 500)
        return
    }
    json.Unmarshal(requestContent, ti)

    log.Printf("Parsed /installed: %#v", ti)
    json.NewEncoder(w).Encode([]string{"OK"})
}

The installed handler is invoked when the add-on is first installed by a user, it contains important values like the OAuth keys needed to authenticate requests coming from Bitbucket and also for the add-on to authenticate itself with Bitbucket when it needs to access the REST API.

func (c *Context) uninstalled(w http.ResponseWriter, r *http.Request) {
    log.Printf("Received /uninstalled call")
    json.NewEncoder(w).Encode([]string{"OK"})
}

Skeleton handler invoked when the add-on is uninstalled. As we're not storing any data in this sample add-on, I leave it stubbed.

func (c *Context) example(w http.ResponseWriter, r *http.Request) {
    util.PrintDump(w, r, false)
    w.Header().Set("Access-Control-Allow-Origin", "*")
    lp := path.Join("./templates", "panel.hbs")
    vals := map[string]string{
        "Displayname": "Injected",
        "Repopath":    r.URL.Query().Get("repoPath"),
    }
    tmpl, err := template.ParseFiles(lp)
    if err != nil {
        log.Fatalf("%v", err)
        http.Error(w, err.Error(), 500)
        return
    }
    tmpl.ExecuteTemplate(w, "panel", vals)
}

The example handler serves the HTML content that will be displayed in the web panel inside Bitbucket's project overview page. The page is served an interpolated template so that we can populate the HTML dynamically with parameters, for example the repository path we received when Bitbucket called us. Here's how the template looks like:

{{define "panel"}}<!doctype html>
<html>
  <head>
    <title>Go Sample Connect Application</title>

    <link rel="stylesheet" href="//aui-cdn.atlassian.com/aui-adg/5.6.11/css/aui.css" media="all">
    [...]

    <script src="//aui-cdn.atlassian.com/aui-adg/5.6.11/js/aui-soy.js" type="text/javascript"></script>
    [...]
    <script src="https://bitbucket.org/atlassian-connect/all-debug.js" type="text/javascript"></script>
  </head>

  <body class="aui-page-hybrid">
    <section id="content" role="main">

      <img src="/gopher.png" style="float:left;width:70px;"/>

      <table>
        <tr>
          <td>This repository (from callback):</td>
          <td>{{.Repopath}}</td>
        </tr>
      </table>

    </section>
  </body>
</html>
{{end}}

Atlassian Connect descriptor

We mentioned before a couple of times the Atlassian Connect descriptor atlassian-connect.json so let's review it together. It is the main configuration file our application feeds to Bitbucket to provide information about the add-on and certify that we are authorized to be embedded. We serve it as template because we want to inject into it a few parameters like our LocalBaseUrl and the authenticated ConsumerKey:

{{define "config"}}{
    "key": "bb-golang-base-app",
    "name": "Bitbucket Golang Base App",
    "description": "An example add-on for Bitbucket written in Golang",
    "vendor": {
        "name": "Atlassian Labs",
        "url": "https://www.atlassian.com"
    },
    "baseUrl": "{{.LocalBaseUrl}}",
    "authentication": {
        "type": "jwt"
    },
    "lifecycle": {
        "installed": "/installed",
        "uninstalled": "/uninstalled"
    },
    "modules": {
        "oauthConsumer": {
            "clientId": "{{.ConsumerKey}}"
        },
        "webhooks": [
            {
                "event": "*",
                "url": "/hook"
            }
        ],
        "webPanel": [
            {
                "url": "/connect-example?repoPath={repo_path}",
                "name": {
                    "value": "Example Web Panel"
                },
                "location": "org.bitbucket.repository.overview.informationPanel",
                "key": "example-web-panel"
            }
        ]
    },
    "scopes": ["account"],
    "contexts": ["account"]
}
{{end}}

As you can see we specify some basic information about our add-on, like what's the base URL of the add-on (i.e. the URL where it's hosted), which URL should Bitbucket invoke when the add-on is installed by a user (/installed) and so on.

Run the add-on

Running the add-on from the command line is straight forward, provided we remember to export a couple of environment variables.

  • 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://<generated-for-you>.ngrok.io CONSUMER_KEY=<yoursecretkey> go run main.go
  • Go to Bitbucket settings - Manage add-ons - Install add-on from URL and insert the ngrok URL.

  • Browse to one of your repositories.
  • ??
  • Profit.

More information and next steps

For more information and examples have a look at Atlassian Connect for Bitbucket.

Also feel free to review the source of the project and let me know your thoughts at @durdn or my awesome team at @atlassiandev.