Skip to content

Commit

Permalink
Add support for sub-indexes
Browse files Browse the repository at this point in the history
<details>
<summary>Background</summary>

Sally renders two kinds of pages:

- packages: These are for packages defined in sally.yaml
  and any route under the package path.
- indexes: These list available packages.

The latter--indexes was previously only supported at '/', the root page.

This leads to a slight UX issue:
if you have a package with a / in its name (e.g. net/metrics):

- example.com/net/metrics gives you the package page
- example.com/ lists net/metrics
- However, example.com/net fails with a 404

</details>

This adds support for index pages on all parents of package pages.
Therefore, if example.com/net/metrics exists,
example.com/net will list all packages defined under that path.

To make this possible, the internals have been rewritten to some degree:
CreateHandler no longer builds an http.ServeMux
with a bunch of pre-registered http.Handlers.
It now builds a custom http.Handler with its own routing.

Routing is backed by a new pathTree abstraction.
pathTree is a specialized string-value storage
with support for inheriting values from a parent.
It has the following methods:

- `Set(path, v)` associates v with path.
- `Lookup(path)` returns the value at path,
  or the value inherited from the closest ancestor.
- `ListByPath(path)` lists all values at or under the given path.

For additional confidence in the implementation of pathTree,
a property test based on rapid is included.

Resolves #31
  • Loading branch information
abhinav committed Oct 15, 2023
1 parent f3a3b27 commit ae280ee
Show file tree
Hide file tree
Showing 10 changed files with 564 additions and 93 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased
### Added
- Generate a package listing for sub-paths
that match a subset of the known packages.

## [1.4.0]
### Added
- Publish a Docker image to GitHub Container Registry.
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ require (
github.com/stretchr/testify v1.8.4
golang.org/x/net v0.15.0
gopkg.in/yaml.v3 v3.0.1
pgregory.net/rapid v1.1.0
)

require (
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,5 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
pgregory.net/rapid v1.1.0 h1:CMa0sjHSru3puNx+J0MIAuiiEV4N0qj8/cMWGBBCsjw=
pgregory.net/rapid v1.1.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
188 changes: 104 additions & 84 deletions handler.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
package main

import (
"errors"
"fmt"
"html/template"
"net/http"
"sort"
"path"
"strings"

"go.uber.org/sally/templates"
Expand All @@ -17,112 +18,131 @@ var (
template.New("package.html").Parse(templates.Package))
)

// CreateHandler creates a Sally http.Handler
func CreateHandler(config *Config) http.Handler {
mux := http.NewServeMux()
// Handler handles inbound HTTP requests.
//
// It provides the following endpoints:
//
// GET /
// Index page listing all packages.
// GET /<name>
// Package page for the given package.
// GET /<dir>
// Page listing packages under the given directory,
// assuming that there's no package with the given name.
// GET /<name>/<subpkg>
// Package page for the given subpackage.
type Handler struct {
pkgs pathTree[*sallyPackage]
}

pkgs := make([]packageInfo, 0, len(config.Packages))
// CreateHandler builds a new handler
// with the provided package configuration.
func CreateHandler(config *Config) *Handler {
var pkgs pathTree[*sallyPackage]
for name, pkg := range config.Packages {
handler := newPackageHandler(config, name, pkg)
// Double-register so that "/foo"
// does not redirect to "/foo/" with a 300.
mux.Handle("/"+name, handler)
mux.Handle("/"+name+"/", handler)

pkgs = append(pkgs, packageInfo{
baseURL := config.URL
if pkg.URL != "" {
// Package-specific override for the base URL.
baseURL = pkg.URL
}
modulePath := path.Join(baseURL, name)
docURL := "https://" + path.Join(config.Godoc.Host, modulePath)

pkgs.Set(name, &sallyPackage{
Desc: pkg.Desc,
ImportPath: handler.canonicalURL,
GitURL: handler.gitURL,
GodocHome: handler.godocHost + "/" + handler.canonicalURL,
ModulePath: modulePath,
DocURL: docURL,
GitURL: pkg.Repo,
})
}
sort.Slice(pkgs, func(i, j int) bool {
return pkgs[i].ImportPath < pkgs[j].ImportPath
})
mux.Handle("/", &indexHandler{pkgs: pkgs})

return mux
}

type indexHandler struct {
pkgs []packageInfo
return &Handler{
pkgs: pkgs,
}
}

type packageInfo struct {
Desc string // package description
ImportPath string // canonical import path
GitURL string // URL of the Git repository
GodocHome string // documentation home URL
}
var _ http.Handler = (*Handler)(nil)

func (h *indexHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Index handler only supports '/'.
// ServeMux will call us for any '/foo' that is not a known package.
if r.Method != http.MethodGet || r.URL.Path != "/" {
http.NotFound(w, r)
return
}
type sallyPackage struct {
// Canonical import path for the package.
ModulePath string

data := struct{ Packages []packageInfo }{
Packages: h.pkgs,
}
if err := indexTemplate.Execute(w, data); err != nil {
http.Error(w, err.Error(), 500)
}
}
// Description of the package, if any.
Desc string

type packageHandler struct {
// Hostname of the godoc server, e.g. "godoc.org".
godocHost string
// URL at which documentation for the package can be found.
DocURL string

// Name of the package relative to the vanity base URL.
// For example, "zap" for "go.uber.org/zap".
name string
// URL at which the Git repository is hosted.
GitURL string
}

// Path at which the Git repository is hosted.
// For example, "github.com/uber-go/zap".
gitURL string
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if err := h.serveHTTP(w, r); err != nil {
if herr := new(httpError); errors.As(err, &herr) {
http.Error(w, herr.Message, herr.Code)
} else {
http.Error(w, err.Error(), 500)
}
}
}

// Canonical import path for the package.
canonicalURL string
// httpError indicates that an HTTP error occurred.
//
// The caller will write the error code and message to the response.
type httpError struct {
Code int // HTTP status code
Message string // error message
}

func newPackageHandler(cfg *Config, name string, pkg PackageConfig) *packageHandler {
baseURL := cfg.URL
if pkg.URL != "" {
baseURL = pkg.URL
func httpErrorf(code int, format string, args ...interface{}) error {
return &httpError{
Code: code,
Message: fmt.Sprintf(format, args...),
}
canonicalURL := fmt.Sprintf("%s/%s", baseURL, name)
}

return &packageHandler{
godocHost: cfg.Godoc.Host,
name: name,
canonicalURL: canonicalURL,
gitURL: pkg.Repo,
}
func (e *httpError) Error() string {
return fmt.Sprintf("status %d: %s", e.Code, e.Message)
}

func (h *packageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// serveHTTP is similar to ServeHTTP, except it returns an error.
//
// If it returns an httpError,
// the caller will write the error code and message to the response.
func (h *Handler) serveHTTP(w http.ResponseWriter, r *http.Request) error {
if r.Method != http.MethodGet {
http.NotFound(w, r)
return
return httpErrorf(http.StatusNotFound, "method %q not allowed", r.Method)
}

// Extract the relative path to subpackages, if any.
// "/foo/bar" => "/bar"
// "/foo" => ""
relPath := strings.TrimPrefix(r.URL.Path, "/"+h.name)

data := struct {
Repo string
CanonicalURL string
GodocURL string
}{
Repo: h.gitURL,
CanonicalURL: h.canonicalURL,
GodocURL: fmt.Sprintf("https://%s/%s%s", h.godocHost, h.canonicalURL, relPath),
path := strings.TrimPrefix(strings.TrimSuffix(r.URL.Path, "/"), "/")

if pkg, suffix, ok := h.pkgs.Lookup(path); ok {
return h.servePackage(w, pkg, suffix)
}
if err := packageTemplate.Execute(w, data); err != nil {
http.Error(w, err.Error(), 500)
return h.serveIndex(w, path, h.pkgs.ListByPath(path))
}

func (h *Handler) servePackage(w http.ResponseWriter, pkg *sallyPackage, suffix string) error {
return packageTemplate.Execute(w,
struct {
ModulePath string
GitURL string
DocURL string
}{
ModulePath: pkg.ModulePath,
GitURL: pkg.GitURL,
DocURL: pkg.DocURL + suffix,
})
}

func (h *Handler) serveIndex(w http.ResponseWriter, path string, pkgs []*sallyPackage) error {
if len(pkgs) == 0 {
return httpErrorf(http.StatusNotFound, "no packages found under path: %s", path)
}

return indexTemplate.Execute(w,
struct{ Packages []*sallyPackage }{
Packages: pkgs,
})
}
23 changes: 20 additions & 3 deletions handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ packages:
url: go.uberalt.org
repo: github.com/uber-go/zap
description: A fast, structured logging library.
net/metrics:
repo: github.com/yarpc/metrics
net/something:
repo: github.com/yarpc/something
`

Expand All @@ -34,6 +38,19 @@ func TestIndex(t *testing.T) {
assert.Contains(t, body, "github.com/thriftrw/thriftrw-go")
assert.Contains(t, body, "github.com/yarpc/yarpc-go")
assert.Contains(t, body, "A fast, structured logging library.")
assert.Contains(t, body, "github.com/yarpc/metrics")
assert.Contains(t, body, "github.com/yarpc/something")
}

func TestSubindex(t *testing.T) {
rr := CallAndRecord(t, config, "/net")
assert.Equal(t, 200, rr.Code)

body := rr.Body.String()
assert.NotContains(t, body, "github.com/thriftrw/thriftrw-go")
assert.NotContains(t, body, "github.com/yarpc/yarpc-go")
assert.Contains(t, body, "github.com/yarpc/metrics")
assert.Contains(t, body, "github.com/yarpc/something")
}

func TestPackageShouldExist(t *testing.T) {
Expand All @@ -55,7 +72,7 @@ func TestPackageShouldExist(t *testing.T) {
func TestNonExistentPackageShould404(t *testing.T) {
rr := CallAndRecord(t, config, "/nonexistent")
AssertResponse(t, rr, 404, `
404 page not found
no packages found under path: nonexistent
`)
}

Expand All @@ -66,10 +83,10 @@ func TestTrailingSlash(t *testing.T) {
<html>
<head>
<meta name="go-import" content="go.uber.org/yarpc git https://github.com/yarpc/yarpc-go">
<meta http-equiv="refresh" content="0; url=https://pkg.go.dev/go.uber.org/yarpc/">
<meta http-equiv="refresh" content="0; url=https://pkg.go.dev/go.uber.org/yarpc">
</head>
<body>
Nothing to see here. Please <a href="https://pkg.go.dev/go.uber.org/yarpc/">move along</a>.
Nothing to see here. Please <a href="https://pkg.go.dev/go.uber.org/yarpc">move along</a>.
</body>
</html>
`)
Expand Down
Loading

0 comments on commit ae280ee

Please sign in to comment.