Skip to content

Commit

Permalink
Add support for sub-indexes (#120)
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.

Resolves #31
  • Loading branch information
abhinav authored Oct 18, 2023
1 parent f3a3b27 commit 5fbb57e
Show file tree
Hide file tree
Showing 9 changed files with 288 additions and 84 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v4
with:
go-version: 1.20.x
go-version: 1.21.x
cache: true

- name: Lint
Expand Down
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
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# It does not include a sally configuration.
# A /sally.yaml file is required for this to run.

FROM golang:1.19-alpine
FROM golang:1.21-alpine

COPY . /build
WORKDIR /build
Expand Down
4 changes: 3 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
module go.uber.org/sally

go 1.18
go 1.21

toolchain go1.21.3

require (
github.com/stretchr/testify v1.8.4
Expand Down
206 changes: 132 additions & 74 deletions handler.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package main

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

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

// CreateHandler creates a Sally http.Handler
// CreateHandler builds a new handler
// with the provided package configuration.
// The returned handler 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.
func CreateHandler(config *Config) http.Handler {
mux := http.NewServeMux()

pkgs := make([]packageInfo, 0, len(config.Packages))
pkgs := make([]*sallyPackage, 0, len(config.Packages))
for name, pkg := range config.Packages {
handler := newPackageHandler(config, name, pkg)
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)

pkg := &sallyPackage{
Name: name,
Desc: pkg.Desc,
ModulePath: modulePath,
DocURL: docURL,
GitURL: pkg.Repo,
}
pkgs = append(pkgs, pkg)

// Double-register so that "/foo"
// does not redirect to "/foo/" with a 300.
handler := &packageHandler{Pkg: pkg}
mux.Handle("/"+name, handler)
mux.Handle("/"+name+"/", handler)

pkgs = append(pkgs, packageInfo{
Desc: pkg.Desc,
ImportPath: handler.canonicalURL,
GitURL: handler.gitURL,
GodocHome: handler.godocHost + "/" + handler.canonicalURL,
})
}
sort.Slice(pkgs, func(i, j int) bool {
return pkgs[i].ImportPath < pkgs[j].ImportPath

mux.Handle("/", newIndexHandler(pkgs))
return requireMethod(http.MethodGet, mux)
}

func requireMethod(method string, handler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != method {
http.NotFound(w, r)
return
}

handler.ServeHTTP(w, r)
})
mux.Handle("/", &indexHandler{pkgs: pkgs})
}

return mux
type sallyPackage struct {
// Name of the package.
//
// This is the part after the base URL.
Name string

// Canonical import path for the package.
ModulePath string

// Description of the package, if any.
Desc string

// URL at which documentation for the package can be found.
DocURL string

// URL at which the Git repository is hosted.
GitURL string
}

type indexHandler struct {
pkgs []packageInfo
pkgs []*sallyPackage // sorted by name
}

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 = (*indexHandler)(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
}
func newIndexHandler(pkgs []*sallyPackage) *indexHandler {
slices.SortFunc(pkgs, func(a, b *sallyPackage) int {
return cmp.Compare(a.Name, b.Name)
})

data := struct{ Packages []packageInfo }{
Packages: h.pkgs,
}
if err := indexTemplate.Execute(w, data); err != nil {
http.Error(w, err.Error(), 500)
return &indexHandler{
pkgs: pkgs,
}
}

type packageHandler struct {
// Hostname of the godoc server, e.g. "godoc.org".
godocHost string
func (h *indexHandler) rangeOf(path string) (start, end int) {
if len(path) == 0 {
return 0, len(h.pkgs)
}

// Name of the package relative to the vanity base URL.
// For example, "zap" for "go.uber.org/zap".
name string
// If the packages are sorted by name,
// we can scan adjacent packages to find the range of packages
// whose name descends from path.
start, _ = slices.BinarySearchFunc(h.pkgs, path, func(pkg *sallyPackage, path string) int {
return cmp.Compare(pkg.Name, path)
})

// Path at which the Git repository is hosted.
// For example, "github.com/uber-go/zap".
gitURL string
for idx := start; idx < len(h.pkgs); idx++ {
if !descends(path, h.pkgs[idx].Name) {
// End of matching sequences.
// The next path is not a descendant of path.
return start, idx
}
}

// Canonical import path for the package.
canonicalURL string
// All packages following start are descendants of path.
// Return the rest of the packages.
return start, len(h.pkgs)
}

func newPackageHandler(cfg *Config, name string, pkg PackageConfig) *packageHandler {
baseURL := cfg.URL
if pkg.URL != "" {
baseURL = pkg.URL
func (h *indexHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(strings.TrimSuffix(r.URL.Path, "/"), "/")
start, end := h.rangeOf(path)

// If start == end, then there are no packages
if start == end {
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, "no packages found under path: %v\n", path)
return
}
canonicalURL := fmt.Sprintf("%s/%s", baseURL, name)

return &packageHandler{
godocHost: cfg.Godoc.Host,
name: name,
canonicalURL: canonicalURL,
gitURL: pkg.Repo,
err := indexTemplate.Execute(w,
struct{ Packages []*sallyPackage }{
Packages: h.pkgs[start:end],
})
if err != nil {
http.Error(w, err.Error(), 500)
}
}

func (h *packageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.NotFound(w, r)
return
}
type packageHandler struct {
Pkg *sallyPackage
}

var _ http.Handler = (*packageHandler)(nil)

func (h *packageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// 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
// "/foo/bar" => "/bar"
// "/foo" => ""
relPath := strings.TrimPrefix(r.URL.Path, "/"+h.Pkg.Name)

err := packageTemplate.Execute(w, struct {
ModulePath string
GitURL string
DocURL string
}{
Repo: h.gitURL,
CanonicalURL: h.canonicalURL,
GodocURL: fmt.Sprintf("https://%s/%s%s", h.godocHost, h.canonicalURL, relPath),
}
if err := packageTemplate.Execute(w, data); err != nil {
ModulePath: h.Pkg.ModulePath,
GitURL: h.Pkg.GitURL,
DocURL: h.Pkg.DocURL + relPath,
})
if err != nil {
http.Error(w, err.Error(), 500)
}
}

func descends(from, to string) bool {
return to == from || (strings.HasPrefix(to, from) && to[len(from)] == '/')
}
Loading

0 comments on commit 5fbb57e

Please sign in to comment.