A lightweight approach to Go vanity import paths

Golang forces its users to use the repository URL of the dependency in the import statement.

For instance, if we want to import the “test” package that is hosted at github.com/fale/test, we will need to use github.com/fale/test.

In one hand this is very nice since it allows anyone reading the code to immediately understand where the code is hosted and therefore finding it very quickly.

Also, this URL-based import path guarantees that no two different packages can have the same import path, preventing this kind of confusion for both programmers and the compiler itself.

On the other hand, this is a limitation, since it makes the code very reliant to the repository location.

This might be problematic in many cases, and the first one that pops in my mind (since it is the one that triggered this problem for me) is a company that has all its code hosted on a specific web site (for example, github.com), but then it decides to move it to a different website (for example, gitlab.com).

With the typical Golang configuration, this would require to walk through all the files that depend on the moving package to replace the import path.

This substitution obviously can be automated in many ways, but it would still be better to be able to unbundle those two concepts.

To unbundle the code repository hosting the package and the import path, Golang supports the idea of Vanity Import Paths.

The way this has been implemented is that, as long as the import path points to a page where Go can find the real package URL, it will follow through.

So, we will need to create a web server that can serve pages in a way that the Go toolchains can understand.

To do so, I used the following code:

package main

import (
    "html/template"
    "log"
    "net/http"
    "os"
    "strings"
)

type conversion struct {
    Vanity string
    Real   string
    Domain string
}

var conversions = []conversion{
    conversion{Domain: "go.fale.io", Vanity: "test", Real: "https://github.com/fale/test"},
}

var tpl = template.Must(template.New("main").Parse(`<!DOCTYPE html>
<html>
    <head>
        <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
        <meta name="go-import" content="{{ .Domain }}/{{ .Vanity }} git {{ .Real }}">
    </head>
</html>
`))

func main() {
    log.Println("Starting")

    wwwPort := "80"
    if os.Getenv("DEBUG") == "true" {
        wwwPort = "10080"
    }

    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        log.Printf("%s %s %s\n", r.Method, r.URL, r.Proto)
        for _, c := range conversions {
            if r.URL.Path[1:] == c.Vanity || strings.HasPrefix(r.URL.Path[1:], c.Vanity+"/") {
                w.WriteHeader(200)
                tpl.Execute(w, c)
                return
            }
        }
        http.Error(w, "Not found", 404)
        return
    })

    http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok")) })
    http.HandleFunc("/readiness", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok")) })

    log.Println("Ready to serve")
    http.ListenAndServe(":"+wwwPort, nil)
}

For every repository we have, we need to add one more item in the conversions array.