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.
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:
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 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.
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:%vn", 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.