~sircmpwn/public-inbox

This thread contains a patchset. You're looking at the original emails, but you may wish to use the patch review UI. Review patch
1

[PATCH gddo 1/2] gddo-server: extract docServer and httpServer from main server

Details
Message ID
<20210422170916.727162-1-s@sbinet.org>
DKIM signature
pass
Download raw message
Patch: +211 -140
From: "Sebastien Binet" <s@sbinet.org>

This CL is a simple code reorganization, regrouping doc-fetching related
code into a new docServer type and http-serving operations into a new
httpServer type.

Signed-off-by: Sebastien Binet <s@sbinet.org>
---

This CL is a first stab at splitting the server type into a set of more
focused components: docServer and httpServer, respectively handling the
retrieval of packages documentation and serving HTTP requests.


 gddo-server/api.go        |  12 +--
 gddo-server/background.go |   4 +-
 gddo-server/config.go     |   2 +-
 gddo-server/crawl.go      |   6 +-
 gddo-server/docsrv.go     |  37 +++++++++
 gddo-server/http.go       | 119 +++++++++++++++++++++++++++
 gddo-server/main.go       | 167 ++++++++++----------------------------
 gddo-server/play.go       |   4 +-
 8 files changed, 211 insertions(+), 140 deletions(-)
 create mode 100644 gddo-server/docsrv.go
 create mode 100644 gddo-server/http.go

diff --git a/gddo-server/api.go b/gddo-server/api.go
index e2753b9..bca7cbe 100644
--- a/gddo-server/api.go
+++ b/gddo-server/api.go
@@ -11,15 +11,15 @@ import (

const jsonMIMEType = "application/json; charset=utf-8"

func (s *server) serveAPISearch(resp http.ResponseWriter, req *http.Request) error {
func (s *httpServer) serveAPISearch(resp http.ResponseWriter, req *http.Request) error {
	q := strings.TrimSpace(req.Form.Get("q"))

	var pkgs []database.Package

	if gosrc.IsValidRemotePath(q) || (strings.Contains(q, "/") && gosrc.IsGoRepoPath(q)) {
		pdoc, _, err := s.getDoc(req.Context(), q, apiRequest)
		pdoc, _, err := s.doc.getDoc(req.Context(), q, apiRequest)
		if e, ok := err.(gosrc.NotFoundError); ok && e.Redirect != "" {
			pdoc, _, err = s.getDoc(req.Context(), e.Redirect, robotRequest)
			pdoc, _, err = s.doc.getDoc(req.Context(), e.Redirect, robotRequest)
		}
		if err == nil && pdoc != nil {
			pkgs = []database.Package{{Path: pdoc.ImportPath, Synopsis: pdoc.Synopsis}}
@@ -28,7 +28,7 @@ func (s *server) serveAPISearch(resp http.ResponseWriter, req *http.Request) err

	if pkgs == nil {
		var err error
		pkgs, err = s.db.Search(req.Context(), q)
		pkgs, err = s.doc.db.Search(req.Context(), q)
		if err != nil {
			return err
		}
@@ -43,9 +43,9 @@ func (s *server) serveAPISearch(resp http.ResponseWriter, req *http.Request) err
	return json.NewEncoder(resp).Encode(&data)
}

func (s *server) serveAPIImporters(resp http.ResponseWriter, req *http.Request) error {
func (s *httpServer) serveAPIImporters(resp http.ResponseWriter, req *http.Request) error {
	importPath := strings.TrimPrefix(req.URL.Path, "/importers/")
	pkgs, err := s.db.Importers(importPath)
	pkgs, err := s.doc.db.Importers(importPath)
	if err != nil {
		return err
	}
diff --git a/gddo-server/background.go b/gddo-server/background.go
index 47387ea..419429b 100644
--- a/gddo-server/background.go
+++ b/gddo-server/background.go
@@ -14,7 +14,7 @@ import (
	"github.com/golang/gddo/gosrc"
)

func (s *server) doCrawl(ctx context.Context) error {
func (s *docServer) doCrawl(ctx context.Context) error {
	// Look for new package to crawl.
	importPath, hasSubdirs, err := s.db.PopNewCrawl()
	if err != nil {
@@ -48,7 +48,7 @@ func (s *server) doCrawl(ctx context.Context) error {
	return nil
}

func (s *server) readGitHubUpdates(ctx context.Context) error {
func (s *docServer) readGitHubUpdates(ctx context.Context) error {
	const key = "gitHubUpdates"
	var last string
	if err := s.db.GetGob(key, &last); err != nil {
diff --git a/gddo-server/config.go b/gddo-server/config.go
index 113b7a4..c3f732c 100644
--- a/gddo-server/config.go
+++ b/gddo-server/config.go
@@ -20,7 +20,7 @@ const (
)

const (
	// Server Config
	// HTTP server Config
	ConfigTrustProxyHeaders = "trust_proxy_headers"
	ConfigBindAddress       = "http"
	ConfigAssetsDir         = "assets"
diff --git a/gddo-server/crawl.go b/gddo-server/crawl.go
index 5f6ec96..d14729d 100644
--- a/gddo-server/crawl.go
+++ b/gddo-server/crawl.go
@@ -30,7 +30,7 @@ type crawlNote struct {
}

// crawlDoc fetches the package documentation from the VCS and updates the database.
func (s *server) crawlDoc(ctx context.Context, source string, importPath string, pdoc *doc.Package, hasSubdirs bool, nextCrawl time.Time) (*doc.Package, error) {
func (s *docServer) crawlDoc(ctx context.Context, source string, importPath string, pdoc *doc.Package, hasSubdirs bool, nextCrawl time.Time) (*doc.Package, error) {
	message := []interface{}{source}
	defer func() {
		message = append(message, importPath)
@@ -119,7 +119,7 @@ func (s *server) crawlDoc(ctx context.Context, source string, importPath string,
	}
}

func (s *server) put(ctx context.Context, pdoc *doc.Package, nextCrawl time.Time) error {
func (s *docServer) put(ctx context.Context, pdoc *doc.Package, nextCrawl time.Time) error {
	if pdoc.Status == gosrc.NoRecentCommits &&
		s.isActivePkg(pdoc.ImportPath, gosrc.NoRecentCommits) {
		pdoc.Status = gosrc.Active
@@ -132,7 +132,7 @@ func (s *server) put(ctx context.Context, pdoc *doc.Package, nextCrawl time.Time

// isActivePkg reports whether a package is considered active,
// either because its directory is active or because it is imported by another package.
func (s *server) isActivePkg(pkg string, status gosrc.DirectoryStatus) bool {
func (s *docServer) isActivePkg(pkg string, status gosrc.DirectoryStatus) bool {
	switch status {
	case gosrc.Active:
		return true
diff --git a/gddo-server/docsrv.go b/gddo-server/docsrv.go
new file mode 100644
index 0000000..89c9151
--- /dev/null
+++ b/gddo-server/docsrv.go
@@ -0,0 +1,37 @@
package main

import (
	"fmt"
	"net/http"

	"github.com/golang/gddo/database"
	"github.com/spf13/viper"
)

// docServer fetches package documentation from the database or from the version
// control system, as needed.
type docServer struct {
	v          *viper.Viper
	db         *database.Database
	httpClient *http.Client
}

func newDocServer(v *viper.Viper) (*docServer, error) {
	s := &docServer{
		v:          v,
		httpClient: newHTTPClient(v),
	}

	var err error
	s.db, err = database.New(
		v.GetString(ConfigDBServer),
		v.GetString(ConfigPGServer),
		v.GetDuration(ConfigDBIdleTimeout),
		v.GetBool(ConfigDBLog),
	)
	if err != nil {
		return nil, fmt.Errorf("open database: %v", err)
	}

	return s, nil
}
diff --git a/gddo-server/http.go b/gddo-server/http.go
new file mode 100644
index 0000000..6bfc2a1
--- /dev/null
+++ b/gddo-server/http.go
@@ -0,0 +1,119 @@
package main

import (
	"net/http"
	"time"

	"github.com/golang/gddo/httputil"
	"github.com/golang/gddo/internal/health"
	"github.com/spf13/viper"
)

type httpServer struct {
	v   *viper.Viper
	doc *docServer

	templates templateMap

	statusPNG http.Handler
	statusSVG http.Handler

	root rootHandler

	// A semaphore to limit concurrent ?import-graph requests.
	importGraphSem chan struct{}
}

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

func newHTTPServer(v *viper.Viper, doc *docServer) (*httpServer, error) {
	s := &httpServer{
		v:   v,
		doc: doc,

		importGraphSem: make(chan struct{}, 10),
	}

	assets := v.GetString(ConfigAssetsDir)
	staticServer := httputil.StaticServer{
		Dir:    assets,
		MaxAge: time.Hour,
		MIMETypes: map[string]string{
			".css": "text/css; charset=utf-8",
			".js":  "text/javascript; charset=utf-8",
		},
	}
	s.statusPNG = staticServer.FileHandler("status.png")
	s.statusSVG = staticServer.FileHandler("status.svg")

	apiHandler := func(f func(http.ResponseWriter, *http.Request) error) http.Handler {
		return requestCleaner{
			h: errorHandler{
				fn:    f,
				errFn: handleAPIError,
			},
			trustProxyHeaders: v.GetBool(ConfigTrustProxyHeaders),
		}
	}
	apiMux := http.NewServeMux()
	apiMux.Handle("/robots.txt", staticServer.FileHandler("apiRobots.txt"))
	apiMux.Handle("/search", apiHandler(s.serveAPISearch))
	apiMux.Handle("/importers/", apiHandler(s.serveAPIImporters))
	apiMux.Handle("/", apiHandler(serveAPIHome))

	mux := http.NewServeMux()
	mux.Handle("/-/site.js", staticServer.FilesHandler("site.js"))
	mux.Handle("/-/site.css", staticServer.FilesHandler("site.css"))
	mux.Handle("/-/bootstrap.min.css", staticServer.FilesHandler("bootstrap.min.css"))
	mux.Handle("/-/", http.NotFoundHandler())

	handler := func(f func(http.ResponseWriter, *http.Request) error) http.Handler {
		return requestCleaner{
			h: errorHandler{
				fn:    f,
				errFn: s.handleError,
			},
			trustProxyHeaders: v.GetBool(ConfigTrustProxyHeaders),
		}
	}
	mux.Handle("/-/about", handler(s.serveAbout))
	mux.Handle("/-/bot", handler(s.serveBot))
	mux.Handle("/-/go", handler(s.serveGoIndex))
	mux.Handle("/-/subrepo", handler(s.serveGoSubrepoIndex))
	mux.Handle("/-/refresh", handler(s.serveRefresh))
	mux.Handle("/about", http.RedirectHandler("/-/about", http.StatusMovedPermanently))
	mux.Handle("/favicon.ico", staticServer.FileHandler("favicon.ico"))
	mux.Handle("/robots.txt", staticServer.FileHandler("robots.txt"))
	mux.Handle("/C", http.RedirectHandler("http://golang.org/doc/articles/c_go_cgo.html", http.StatusMovedPermanently))
	mux.Handle("/code.jquery.com/", http.NotFoundHandler())
	mux.Handle("/", handler(s.serveHome))

	ahMux := http.NewServeMux()
	ready := new(health.Handler)
	ahMux.HandleFunc("/_ah/health", health.HandleLive)
	ahMux.Handle("/_ah/ready", ready)

	mainMux := http.NewServeMux()
	mainMux.Handle("/_ah/", ahMux)
	mainMux.Handle("/", mux)

	s.root = rootHandler{
		{"api.", httpsRedirectHandler{apiMux}},
		{"", httpsRedirectHandler{mainMux}},
	}

	var err error
	cacheBusters := &httputil.CacheBusters{Handler: mux}
	s.templates, err = parseTemplates(assets, cacheBusters, v)
	if err != nil {
		return nil, err
	}
	ready.Add(s.doc.db)
	return s, nil
}

func (s *httpServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	s.root.ServeHTTP(w, r)
}
diff --git a/gddo-server/main.go b/gddo-server/main.go
index 096d07c..5babc04 100644
--- a/gddo-server/main.go
+++ b/gddo-server/main.go
@@ -30,7 +30,6 @@ import (
	"github.com/golang/gddo/doc"
	"github.com/golang/gddo/gosrc"
	"github.com/golang/gddo/httputil"
	"github.com/golang/gddo/internal/health"
)

const (
@@ -67,7 +66,7 @@ type crawlResult struct {

// getDoc gets the package documentation from the database or from the version
// control system as needed.
func (s *server) getDoc(ctx context.Context, path string, requestType int) (*doc.Package, []database.Package, error) {
func (s *docServer) getDoc(ctx context.Context, path string, requestType int) (*doc.Package, []database.Package, error) {
	if path == "-" {
		// A hack in the database package uses the path "-" to represent the
		// next document to crawl. Block "-" here so that requests to /- always
@@ -149,7 +148,7 @@ func isView(req *http.Request, key string) bool {
}

// httpEtag returns the package entity tag used in HTTP transactions.
func (s *server) httpEtag(pdoc *doc.Package, pkgs []database.Package, importerCount int, flashMessages []flashMessage) string {
func (s *httpServer) httpEtag(pdoc *doc.Package, pkgs []database.Package, importerCount int, flashMessages []flashMessage) string {
	b := make([]byte, 0, 128)
	b = strconv.AppendInt(b, pdoc.Updated.Unix(), 16)
	b = append(b, 0)
@@ -179,7 +178,7 @@ func (s *server) httpEtag(pdoc *doc.Package, pkgs []database.Package, importerCo
	return fmt.Sprintf("\"%x\"", b)
}

func (s *server) servePackage(resp http.ResponseWriter, req *http.Request) error {
func (s *httpServer) servePackage(resp http.ResponseWriter, req *http.Request) error {
	if isView(req, "status.svg") {
		s.statusSVG.ServeHTTP(resp, req)
		return nil
@@ -193,12 +192,12 @@ func (s *server) servePackage(resp http.ResponseWriter, req *http.Request) error
	requestType := humanRequest

	importPath := strings.TrimPrefix(req.URL.Path, "/")
	pdoc, pkgs, err := s.getDoc(req.Context(), importPath, requestType)
	pdoc, pkgs, err := s.doc.getDoc(req.Context(), importPath, requestType)

	if e, ok := err.(gosrc.NotFoundError); ok && e.Redirect != "" {
		// To prevent dumb clients from following redirect loops, respond with
		// status 404 if the target document is not found.
		if _, _, err := s.getDoc(req.Context(), e.Redirect, requestType); gosrc.IsNotFound(err) {
		if _, _, err := s.doc.getDoc(req.Context(), e.Redirect, requestType); gosrc.IsNotFound(err) {
			return &httpError{status: http.StatusNotFound}
		}
		u := "/" + e.Redirect
@@ -219,7 +218,7 @@ func (s *server) servePackage(resp http.ResponseWriter, req *http.Request) error
		if len(pkgs) == 0 {
			return &httpError{status: http.StatusNotFound}
		}
		pdocChild, _, _, err := s.db.Get(req.Context(), pkgs[0].Path)
		pdocChild, _, _, err := s.doc.db.Get(req.Context(), pkgs[0].Path)
		if err != nil {
			return err
		}
@@ -236,7 +235,7 @@ func (s *server) servePackage(resp http.ResponseWriter, req *http.Request) error
		if pdoc.Name == "" {
			return &httpError{status: http.StatusNotFound}
		}
		pkgs, err = s.db.Packages(pdoc.Imports)
		pkgs, err = s.doc.db.Packages(pdoc.Imports)
		if err != nil {
			return err
		}
@@ -259,7 +258,7 @@ func (s *server) servePackage(resp http.ResponseWriter, req *http.Request) error
		if pdoc.Name == "" {
			return &httpError{status: http.StatusNotFound}
		}
		pkgs, err = s.db.Importers(importPath)
		pkgs, err = s.doc.db.Importers(importPath)
		if err != nil {
			return err
		}
@@ -289,7 +288,7 @@ func (s *server) servePackage(resp http.ResponseWriter, req *http.Request) error
		case "2":
			hide = database.HideStandardAll
		}
		pkgs, edges, err := s.db.ImportGraph(pdoc, hide)
		pkgs, edges, err := s.doc.db.ImportGraph(pdoc, hide)
		if err != nil {
			return err
		}
@@ -313,7 +312,7 @@ func (s *server) servePackage(resp http.ResponseWriter, req *http.Request) error
	default:
		importerCount := 0
		if pdoc.Name != "" {
			importerCount, err = s.db.ImporterCount(importPath)
			importerCount, err = s.doc.db.ImporterCount(importPath)
			if err != nil {
				return err
			}
@@ -343,15 +342,15 @@ func (s *server) servePackage(resp http.ResponseWriter, req *http.Request) error
	}
}

func (s *server) serveRefresh(resp http.ResponseWriter, req *http.Request) error {
func (s *httpServer) serveRefresh(resp http.ResponseWriter, req *http.Request) error {
	importPath := req.Form.Get("path")
	_, pkgs, _, err := s.db.Get(req.Context(), importPath)
	_, pkgs, _, err := s.doc.db.Get(req.Context(), importPath)
	if err != nil {
		return err
	}
	c := make(chan error, 1)
	go func() {
		_, err := s.crawlDoc(req.Context(), "rfrsh", importPath, nil, len(pkgs) > 0, time.Time{})
		_, err := s.doc.crawlDoc(req.Context(), "rfrsh", importPath, nil, len(pkgs) > 0, time.Time{})
		c <- err
	}()
	select {
@@ -370,8 +369,8 @@ func (s *server) serveRefresh(resp http.ResponseWriter, req *http.Request) error
	return nil
}

func (s *server) serveGoIndex(resp http.ResponseWriter, req *http.Request) error {
	pkgs, err := s.db.GoIndex()
func (s *httpServer) serveGoIndex(resp http.ResponseWriter, req *http.Request) error {
	pkgs, err := s.doc.db.GoIndex()
	if err != nil {
		return err
	}
@@ -380,8 +379,8 @@ func (s *server) serveGoIndex(resp http.ResponseWriter, req *http.Request) error
	})
}

func (s *server) serveGoSubrepoIndex(resp http.ResponseWriter, req *http.Request) error {
	pkgs, err := s.db.GoSubrepoIndex()
func (s *httpServer) serveGoSubrepoIndex(resp http.ResponseWriter, req *http.Request) error {
	pkgs, err := s.doc.db.GoSubrepoIndex()
	if err != nil {
		return err
	}
@@ -390,7 +389,7 @@ func (s *server) serveGoSubrepoIndex(resp http.ResponseWriter, req *http.Request
	})
}

func (s *server) serveHome(resp http.ResponseWriter, req *http.Request) error {
func (s *httpServer) serveHome(resp http.ResponseWriter, req *http.Request) error {
	if req.URL.Path != "/" {
		return s.servePackage(resp, req)
	}
@@ -401,7 +400,7 @@ func (s *server) serveHome(resp http.ResponseWriter, req *http.Request) error {
	}

	if gosrc.IsValidRemotePath(q) || (strings.Contains(q, "/") && gosrc.IsGoRepoPath(q)) {
		pdoc, pkgs, err := s.getDoc(req.Context(), q, queryRequest)
		pdoc, pkgs, err := s.doc.getDoc(req.Context(), q, queryRequest)
		if e, ok := err.(gosrc.NotFoundError); ok && e.Redirect != "" {
			http.Redirect(resp, req, "/"+e.Redirect, http.StatusFound)
			return nil
@@ -412,7 +411,7 @@ func (s *server) serveHome(resp http.ResponseWriter, req *http.Request) error {
		}
	}

	pkgs, err := s.db.Search(req.Context(), q)
	pkgs, err := s.doc.db.Search(req.Context(), q)
	if err != nil {
		return err
	}
@@ -421,12 +420,12 @@ func (s *server) serveHome(resp http.ResponseWriter, req *http.Request) error {
		map[string]interface{}{"q": q, "pkgs": pkgs})
}

func (s *server) serveAbout(resp http.ResponseWriter, req *http.Request) error {
func (s *httpServer) serveAbout(resp http.ResponseWriter, req *http.Request) error {
	return s.templates.execute(resp, "about.html", http.StatusOK, nil,
		map[string]interface{}{"Host": req.Host})
}

func (s *server) serveBot(resp http.ResponseWriter, req *http.Request) error {
func (s *httpServer) serveBot(resp http.ResponseWriter, req *http.Request) error {
	return s.templates.execute(resp, "bot.html", http.StatusOK, nil, nil)
}

@@ -501,7 +500,7 @@ func errorText(err error) string {
	return "Internal server error."
}

func (s *server) handleError(resp http.ResponseWriter, req *http.Request, status int, err error) {
func (s *httpServer) handleError(resp http.ResponseWriter, req *http.Request, status int, err error) {
	switch status {
	case http.StatusNotFound:
		s.templates.execute(resp, "notfound"+templateExt(req), status, nil, map[string]interface{}{
@@ -557,116 +556,32 @@ func defaultBase(path string) string {
}

type server struct {
	v          *viper.Viper
	db         *database.Database
	httpClient *http.Client
	templates  templateMap
	v *viper.Viper

	statusPNG http.Handler
	statusSVG http.Handler

	root rootHandler

	// A semaphore to limit concurrent ?import-graph requests.
	importGraphSem chan struct{}
	docSrv  *docServer  // docSrv retrieves package documentation.
	httpSrv *httpServer // httpSrv handles HTTP end-points.
}

func newServer(ctx context.Context, v *viper.Viper) (*server, error) {
	s := &server{
		v:              v,
		httpClient:     newHTTPClient(v),
		importGraphSem: make(chan struct{}, 10),
	}

	assets := v.GetString(ConfigAssetsDir)
	staticServer := httputil.StaticServer{
		Dir:    assets,
		MaxAge: time.Hour,
		MIMETypes: map[string]string{
			".css": "text/css; charset=utf-8",
			".js":  "text/javascript; charset=utf-8",
		},
	}
	s.statusPNG = staticServer.FileHandler("status.png")
	s.statusSVG = staticServer.FileHandler("status.svg")

	apiHandler := func(f func(http.ResponseWriter, *http.Request) error) http.Handler {
		return requestCleaner{
			h: errorHandler{
				fn:    f,
				errFn: handleAPIError,
			},
			trustProxyHeaders: v.GetBool(ConfigTrustProxyHeaders),
		}
	}
	apiMux := http.NewServeMux()
	apiMux.Handle("/robots.txt", staticServer.FileHandler("apiRobots.txt"))
	apiMux.Handle("/search", apiHandler(s.serveAPISearch))
	apiMux.Handle("/importers/", apiHandler(s.serveAPIImporters))
	apiMux.Handle("/", apiHandler(serveAPIHome))

	mux := http.NewServeMux()
	mux.Handle("/-/site.js", staticServer.FilesHandler("site.js"))
	mux.Handle("/-/site.css", staticServer.FilesHandler("site.css"))
	mux.Handle("/-/bootstrap.min.css", staticServer.FilesHandler("bootstrap.min.css"))
	mux.Handle("/-/", http.NotFoundHandler())

	handler := func(f func(http.ResponseWriter, *http.Request) error) http.Handler {
		return requestCleaner{
			h: errorHandler{
				fn:    f,
				errFn: s.handleError,
			},
			trustProxyHeaders: v.GetBool(ConfigTrustProxyHeaders),
		}
	}
	mux.Handle("/-/about", handler(s.serveAbout))
	mux.Handle("/-/bot", handler(s.serveBot))
	mux.Handle("/-/go", handler(s.serveGoIndex))
	mux.Handle("/-/subrepo", handler(s.serveGoSubrepoIndex))
	mux.Handle("/-/refresh", handler(s.serveRefresh))
	mux.Handle("/about", http.RedirectHandler("/-/about", http.StatusMovedPermanently))
	mux.Handle("/favicon.ico", staticServer.FileHandler("favicon.ico"))
	mux.Handle("/robots.txt", staticServer.FileHandler("robots.txt"))
	mux.Handle("/C", http.RedirectHandler("http://golang.org/doc/articles/c_go_cgo.html", http.StatusMovedPermanently))
	mux.Handle("/code.jquery.com/", http.NotFoundHandler())
	mux.Handle("/", handler(s.serveHome))

	ahMux := http.NewServeMux()
	ready := new(health.Handler)
	ahMux.HandleFunc("/_ah/health", health.HandleLive)
	ahMux.Handle("/_ah/ready", ready)

	mainMux := http.NewServeMux()
	mainMux.Handle("/_ah/", ahMux)
	mainMux.Handle("/", mux)

	s.root = rootHandler{
		{"api.", httpsRedirectHandler{apiMux}},
		{"", httpsRedirectHandler{mainMux}},
	}

	var err error
	cacheBusters := &httputil.CacheBusters{Handler: mux}
	s.templates, err = parseTemplates(assets, cacheBusters, v)
	docSrv, err := newDocServer(v)
	if err != nil {
		return nil, err
	}
	s.db, err = database.New(
		v.GetString(ConfigDBServer),
		v.GetString(ConfigPGServer),
		v.GetDuration(ConfigDBIdleTimeout),
		v.GetBool(ConfigDBLog),
	)
		return nil, fmt.Errorf("could not create documentation server: %w", err)
	}

	httpSrv, err := newHTTPServer(v, docSrv)
	if err != nil {
		return nil, fmt.Errorf("open database: %v", err)
		return nil, fmt.Errorf("could not create HTTP server: %w", err)
	}
	ready.Add(s.db)
	return s, nil

	return &server{
		v:       v,
		docSrv:  docSrv,
		httpSrv: httpSrv,
	}, nil
}

func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	s.root.ServeHTTP(w, r)
	s.httpSrv.ServeHTTP(w, r)
}

func main() {
@@ -684,14 +599,14 @@ func main() {

	go func() {
		for range time.Tick(s.v.GetDuration(ConfigCrawlInterval)) {
			if err := s.doCrawl(ctx); err != nil {
			if err := s.docSrv.doCrawl(ctx); err != nil {
				log.Printf("Task Crawl: %v", err)
			}
		}
	}()
	go func() {
		for range time.Tick(s.v.GetDuration(ConfigGithubInterval)) {
			if err := s.readGitHubUpdates(ctx); err != nil {
			if err := s.docSrv.readGitHubUpdates(ctx); err != nil {
				log.Printf("Task GitHub updates: %v", err)
			}
		}
diff --git a/gddo-server/play.go b/gddo-server/play.go
index fdd97aa..0447a45 100644
--- a/gddo-server/play.go
+++ b/gddo-server/play.go
@@ -57,7 +57,7 @@ func findExample(pdoc *doc.Package, export, method, name string) *doc.Example {

var exampleIDPat = regexp.MustCompile(`([^-]+)(?:-([^-]*)(?:-(.*))?)?`)

func (s *server) playURL(pdoc *doc.Package, id string) (string, error) {
func (s *httpServer) playURL(pdoc *doc.Package, id string) (string, error) {
	if m := exampleIDPat.FindStringSubmatch(id); m != nil {
		if e := findExample(pdoc, m[1], m[2], m[3]); e != nil && e.Play != "" {
			req, err := http.NewRequest("POST", "https://play.golang.org/share", strings.NewReader(e.Play))
@@ -65,7 +65,7 @@ func (s *server) playURL(pdoc *doc.Package, id string) (string, error) {
				return "", err
			}
			req.Header.Set("Content-Type", "text/plain")
			resp, err := s.httpClient.Do(req)
			resp, err := s.doc.httpClient.Do(req)
			if err != nil {
				return "", err
			}
-- 
2.31.1

[PATCH gddo 2/2] gddo-server: implement a Gemini server

Details
Message ID
<20210422170916.727162-2-s@sbinet.org>
In-Reply-To
<20210422170916.727162-1-s@sbinet.org> (view parent)
DKIM signature
pass
Download raw message
Patch: +523 -9
From: "Sebastien Binet" <s@sbinet.org>

This CL implements a Gemini server listening on the default Gemini port,
displaying the documentation of the requested package.

Signed-off-by: Sebastien Binet <s@sbinet.org>
---

This CL adds the Gemini server parts.
This really is a first stab.

Missing:
 - integration of the package documentation generation with the
   'templateMap' type. I've just reused what I had written for my PoC in
   griket. If deemed necessary, this could of course be done.
 - handling of the /imports end-point
 - handling of the /importers end-point
 - handling of the /import-graph end-point (although this one isn't that
   interesting in a Gemini context)
 - handling of the /play end-point (ditto)
 - handling of the package query/search. I don't know (yet?) how to
   handle this. Probably with the gemini.StatusInput.
 
 gddo-server/config.go |   7 +
 gddo-server/gmni.go   | 487 ++++++++++++++++++++++++++++++++++++++++++
 gddo-server/main.go   |  14 ++
 go.mod                |   7 +-
 go.sum                |  17 +-
 5 files changed, 523 insertions(+), 9 deletions(-)
 create mode 100644 gddo-server/gmni.go

diff --git a/gddo-server/config.go b/gddo-server/config.go
index c3f732c..f9c8e70 100644
--- a/gddo-server/config.go
+++ b/gddo-server/config.go
@@ -56,6 +56,10 @@ const (

	// Pub/Sub Config
	ConfigCrawlPubSubTopic = "crawl-events"

	// Gemini server config
	ConfigGeminiBindAddress = "gmni"
	ConfigGeminiCertsDir    = "gmni-certs"
)

func loadConfig(ctx context.Context, args []string) (*viper.Viper, error) {
@@ -116,6 +120,9 @@ func buildFlags() *pflag.FlagSet {
	flags.Float64(ConfigTraceSamplerFraction, 0.1, "Fraction of the requests sampled by the trace API.")
	flags.Float64(ConfigTraceSamplerMaxQPS, 5, "Max number of requests sampled every second by the trace API.")

	flags.String(ConfigGeminiBindAddress, ":1965", "Listen for Gemini connections on this address.")
	flags.String(ConfigGeminiCertsDir, filepath.Join(defaultBase("github.com/golang/gddo/gddo-server"), "certs"), "Path to directory holding Gemini server certificate")

	return flags
}

diff --git a/gddo-server/gmni.go b/gddo-server/gmni.go
new file mode 100644
index 0000000..f5031e4
--- /dev/null
+++ b/gddo-server/gmni.go
@@ -0,0 +1,487 @@
package main

import (
	"context"
	"errors"
	"fmt"
	"io"
	"net"
	"os"
	"path/filepath"
	"strings"
	"time"

	"git.sr.ht/~adnano/go-gemini"
	"git.sr.ht/~adnano/go-gemini/certificate"
	"github.com/dustin/go-humanize"
	"github.com/golang/gddo/doc"
	"github.com/golang/gddo/gosrc"
	"github.com/spf13/viper"
)

type gmniHandler func(context.Context, gemini.ResponseWriter, *gemini.Request) error

type gmniError struct {
	status gemini.Status // Gemini status code.
	err    error         // Optional reason for the Gemini error.
}

func (ge *gmniError) Error() string {
	if ge.err != nil {
		return fmt.Sprintf("status %d, reason %s", ge.status, ge.err.Error())
	}
	return fmt.Sprintf("Status %d", ge.status)
}

type gmniServer struct {
	v   *viper.Viper
	doc *docServer
	srv *gemini.Server
}

var (
	_ gemini.Handler = (*gmniServer)(nil)
)

func newGeminiServer(v *viper.Viper, doc *docServer) (*gmniServer, error) {
	s := &gmniServer{
		v:   v,
		doc: doc,
	}

	certs := &certificate.Store{}
	host, port, err := net.SplitHostPort(v.GetString(ConfigGeminiBindAddress))
	if err != nil {
		return nil, fmt.Errorf("could not infer Gemini host:port: %w", err)
	}

	if host == "" {
		host = "127.0.0.1" // FIXME(sbinet): what would be a better fallback?
	}

	if port == "" {
		port = "1965"
	}

	certs.Register(host)
	if err := certs.Load(v.GetString(ConfigGeminiCertsDir)); err != nil {
		return nil, fmt.Errorf(
			"could not load Gemini certificate for host %q: %w",
			host, err,
		)
	}

	assetsDir := v.GetString(ConfigAssetsDir)
	assetsFS := os.DirFS(assetsDir)
	serveFile := func(name string) gmniHandler {
		return func(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request) error {
			f, err := assetsFS.Open(filepath.Join(assetsDir, name))
			if err != nil {
				return err
			}
			defer f.Close()
			gemini.ServeContent(w, r, name, f)
			return nil
		}
	}

	mux := &gemini.Mux{}
	mux.HandleFunc("/favicon.ico", s.handle(serveFile("favicon.ico")))
	mux.HandleFunc("/robots.txt", s.handle(serveFile("robots.txt")))
	mux.HandleFunc("/", s.handle(s.serveRoot))

	s.srv = &gemini.Server{
		Addr:           ":" + port,
		Handler:        gemini.LoggingMiddleware(mux),
		ReadTimeout:    30 * time.Second,
		WriteTimeout:   1 * time.Minute,
		GetCertificate: certs.Get,
	}

	return s, nil
}

func (s *gmniServer) ServeGemini(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request) {
	s.srv.Handler.ServeGemini(ctx, w, r)
}

func (s *gmniServer) handle(h gmniHandler) gemini.HandlerFunc {
	return func(ctx context.Context, w gemini.ResponseWriter, r *gemini.Request) {
		err := h(ctx, w, r)
		if err == nil {
			return
		}
		var ge gmniError
		switch {
		case errors.Is(err, &ge):
			w.WriteHeader(ge.status, ge.err.Error())
		case gosrc.IsNotFound(err):
			w.WriteHeader(gemini.StatusNotFound, "Not found")
		default:
			w.WriteHeader(gemini.StatusServerUnavailable, err.Error())
		}
	}
}

func (s *gmniServer) serveRoot(ctx context.Context, w gemini.ResponseWriter, req *gemini.Request) error {
	if req.URL.Path != "/" {
		return s.servePackage(ctx, w, req)
	}

	q := strings.TrimSpace(req.URL.Query().Get("q"))
	if q == "" {
		return s.serveHome(ctx, w, req)
	}

	if gosrc.IsValidRemotePath(q) || (strings.Contains(q, "/") && gosrc.IsGoRepoPath(q)) {
		pdoc, pkgs, err := s.doc.getDoc(ctx, q, queryRequest)
		if e, ok := err.(gosrc.NotFoundError); ok && e.Redirect != "" {
			w.WriteHeader(gemini.StatusRedirect, "/"+e.Redirect)
			return nil
		}
		if err == nil && (pdoc != nil || len(pkgs) > 0) {
			w.WriteHeader(gemini.StatusRedirect, "/"+q)
			return nil
		}
	}

	// FIXME(sbinet): handle pkg search.
	return s.serveHome(ctx, w, req)

	//	pkgs, err := s.doc.db.Search(ctx, q)
	//	if err != nil {
	//		return err
	//	}
	//
	//	return s.templates.execute(resp, "results"+templateExt(req), http.StatusOK, nil,
	//		map[string]interface{}{"q": q, "pkgs": pkgs})
}

func (s *gmniServer) serveHome(ctx context.Context, w gemini.ResponseWriter, req *gemini.Request) error {
	// TODO(sbinet)
	_, err := w.Write([]byte(`# Godocs.io`))
	return err
}

func (s *gmniServer) servePackage(ctx context.Context, w gemini.ResponseWriter, req *gemini.Request) error {
	// FIXME(sbinet): does this make sense in a Gemini context?
	// if isView(req, "status.svg") {
	// 	s.statusSVG.ServeHTTP(resp, req)
	// 	return nil
	// }

	// if isView(req, "status.png") {
	// 	s.statusPNG.ServeHTTP(resp, req)
	// 	return nil
	// }

	requestType := humanRequest

	importPath := strings.TrimPrefix(req.URL.Path, "/")
	pdoc, pkgs, err := s.doc.getDoc(ctx, importPath, requestType)

	if e, ok := err.(gosrc.NotFoundError); ok && e.Redirect != "" {
		// To prevent dumb clients from following redirect loops, respond with
		// status 404 if the target document is not found.
		if _, _, err := s.doc.getDoc(ctx, e.Redirect, requestType); gosrc.IsNotFound(err) {
			return &gmniError{status: gemini.StatusNotFound}
		}
		u := "/" + e.Redirect
		if req.URL.RawQuery != "" {
			u += "?" + req.URL.RawQuery
		}
		//setFlashMessages(resp, []flashMessage{{ID: "redir", Args: []string{importPath}}})
		//http.Redirect(resp, req, u, http.StatusFound)
		panic("redirect not implemented: " + u) // FIXME(sbinet)
		return nil
	}
	if err != nil {
		// FIXME(sbinet): test whether docServer.getDoc returned
		// an httpError and turn this into a legible gmniError ?
		return err
	}

	if pdoc == nil {
		if len(pkgs) == 0 {
			return &gmniError{status: gemini.StatusNotFound}
		}
		pdocChild, _, _, err := s.doc.db.Get(ctx, pkgs[0].Path)
		if err != nil {
			return err
		}
		pdoc = &doc.Package{
			ProjectName: pdocChild.ProjectName,
			ProjectRoot: pdocChild.ProjectRoot,
			ProjectURL:  pdocChild.ProjectURL,
			ImportPath:  importPath,
		}
	}

	switch {
	//	case isView(req, "imports"):
	//		if pdoc.Name == "" {
	//			return &gmniError{status: gemini.StatusNotFound}
	//		}
	//		pkgs, err = s.doc.db.Packages(pdoc.Imports)
	//		if err != nil {
	//			return err
	//		}
	//		return s.templates.execute(resp, "imports.html", http.StatusOK, nil, map[string]interface{}{
	//			"flashMessages": flashMessages,
	//			"pkgs":          pkgs,
	//			"pdoc":          newTDoc(s.v, pdoc),
	//		})
	//	case isView(req, "tools"):
	//		proto := "http"
	//		if req.Host == "godocs.io" {
	//			proto = "https"
	//		}
	//		return s.templates.execute(resp, "tools.html", http.StatusOK, nil, map[string]interface{}{
	//			"flashMessages": flashMessages,
	//			"uri":           fmt.Sprintf("%s://%s/%s", proto, req.Host, importPath),
	//			"pdoc":          newTDoc(s.v, pdoc),
	//		})
	//	case isView(req, "importers"):
	//		if pdoc.Name == "" {
	//			return &gmniError{status: gemini.StatusNotFound}
	//		}
	//		pkgs, err = s.doc.db.Importers(importPath)
	//		if err != nil {
	//			return err
	//		}
	//		template := "importers.html"
	//		return s.templates.execute(resp, template, http.StatusOK, nil, map[string]interface{}{
	//			"flashMessages": flashMessages,
	//			"pkgs":          pkgs,
	//			"pdoc":          newTDoc(s.v, pdoc),
	//		})
	//	case isView(req, "import-graph"):
	//		if pdoc.Name == "" {
	//			return &gmniError{status: gemini.StatusNotFound}
	//		}
	//
	//		// Throttle ?import-graph requests.
	//		select {
	//		case s.importGraphSem <- struct{}{}:
	//		default:
	//			return &gmniError{status: gemini.StatusSlowDown}
	//		}
	//		defer func() { <-s.importGraphSem }()
	//
	//		hide := database.ShowAllDeps
	//		switch req.Form.Get("hide") {
	//		case "1":
	//			hide = database.HideStandardDeps
	//		case "2":
	//			hide = database.HideStandardAll
	//		}
	//		pkgs, edges, err := s.doc.db.ImportGraph(pdoc, hide)
	//		if err != nil {
	//			return err
	//		}
	//		b, err := renderGraph(pdoc, pkgs, edges)
	//		if err != nil {
	//			return err
	//		}
	//		return s.templates.execute(resp, "graph.html", http.StatusOK, nil, map[string]interface{}{
	//			"flashMessages": flashMessages,
	//			"svg":           template.HTML(b),
	//			"pdoc":          newTDoc(s.v, pdoc),
	//			"hide":          hide,
	//		})
	//	case isView(req, "play"):
	//		u, err := s.playURL(pdoc, req.Form.Get("play"))
	//		if err != nil {
	//			return err
	//		}
	//		http.Redirect(resp, req, u, http.StatusMovedPermanently)
	//		return nil
	default:
		importerCount := 0
		if pdoc.Name != "" {
			importerCount, err = s.doc.db.ImporterCount(importPath)
			if err != nil {
				return err
			}
		}

		//	etag := s.httpEtag(pdoc, pkgs, importerCount, flashMessages)
		//	status := http.StatusOK
		//	if req.Header.Get("If-None-Match") == etag {
		//		status = http.StatusNotModified
		//	}

		const showExamples = false
		return s.gen(w, pdoc, importerCount, showExamples)
	}
}

func (s *gmniServer) gen(w io.Writer, pkg *doc.Package, numImports int, showExamples bool) error {
	return newWriter(w, numImports, showExamples).write(pkg)
}

type gmniWriter struct {
	w   io.Writer
	err error

	NumImports int
	Examples   bool
}

func newWriter(w io.Writer, numImports int, showExamples bool) *gmniWriter {
	return &gmniWriter{w: w, NumImports: numImports, Examples: showExamples}
}

func (w *gmniWriter) write(pkg *doc.Package) error {
	pkgType := "Package"
	if pkg.IsCmd {
		pkgType = "Command"
	}
	w.h1(pkgType + " " + pkg.Name)
	w.code("import \"" + pkg.ImportPath + "\"")
	w.text(pkg.Doc)

	if !w.Examples {
		w.line(gemini.LineLink{
			Name: "Show examples",
			URL:  "/" + pkg.ImportPath + "?ex=1",
		})
	}

	if w.Examples && len(pkg.Examples) > 0 {
		w.h2("Examples")
		for _, v := range pkg.Examples {
			w.genExample(v)
		}
	}

	if len(pkg.Consts) > 0 {
		w.h2("Constants")
		for _, v := range pkg.Consts {
			w.code(v.Decl.Text)
			w.text(v.Doc)
		}
	}

	if len(pkg.Vars) > 0 {
		w.h2("Variables")
		for _, v := range pkg.Vars {
			w.code(v.Decl.Text)
			w.text(v.Doc)
		}
	}

	if len(pkg.Funcs) > 0 {
		for _, v := range pkg.Funcs {
			w.genFunc(pkg, v)
		}
	}

	if len(pkg.Types) > 0 {
		for _, v := range pkg.Types {
			w.genType(pkg, v)
		}
	}

	w.h2("Files")
	for _, f := range pkg.Files {
		w.line(gemini.LineLink{
			Name: f.Name,
			URL:  fmt.Sprintf(pkg.LineFmt, f.URL, 1),
		})
	}

	if len(pkg.Subdirectories) > 0 {
		w.h2("Directories")
		for _, dir := range pkg.Subdirectories {
			w.line(gemini.LineLink{
				URL:  pkg.Name + "/" + dir,
				Name: pkg.ImportPath + "/" + dir,
			})
		}
	}
	w.text(fmt.Sprintf(
		"%s %s imports %d packages and is imported by %d packages.",
		pkgType, pkg.Name, len(pkg.Imports), w.NumImports,
	))
	if !pkg.Updated.IsZero() {
		w.text(fmt.Sprintf("Updated %s.", humanize.Time(pkg.Updated)))
	}
	w.text("")
	w.text("---")
	w.text("generated at: " + time.Now().UTC().Format(time.RFC3339Nano))

	return w.err
}

func (w *gmniWriter) line(v gemini.Line) {
	if w.err != nil {
		return
	}
	_, w.err = w.w.Write([]byte(v.String() + "\n"))
}

func (w *gmniWriter) h1(v string)   { w.line(gemini.LineHeading1(v)) }
func (w *gmniWriter) h2(v string)   { w.line(gemini.LineHeading2(v)) }
func (w *gmniWriter) h3(v string)   { w.line(gemini.LineHeading3(v)) }
func (w *gmniWriter) text(v string) { w.line(gemini.LineText(v)) }
func (w *gmniWriter) code(v string) {
	w.line(gemini.LinePreformattingToggle(""))
	w.line(gemini.LinePreformattedText(v))
	w.line(gemini.LinePreformattingToggle(""))
}

func (w *gmniWriter) link(pkg *doc.Package, pos doc.Pos) {
	var (
		file = pkg.Files[pos.File]
		url  = fmt.Sprintf(pkg.LineFmt, file.URL, pos.Line)
	)
	w.line(gemini.LineLink{URL: url})
}

func (w *gmniWriter) genExample(ex *doc.Example) {
	w.h3("Example " + ex.Name)
	w.code(ex.Code.Text)
	if ex.Output != "" {
		w.text("Output:")
		w.code(ex.Output)
	}
	w.text(ex.Doc)
}

func (w *gmniWriter) genFunc(pkg *doc.Package, fct *doc.Func) {
	recv := fct.Recv
	if recv != "" {
		recv = " (" + recv + ") "
	}
	w.h3("func " + recv + fct.Name)
	w.code(fct.Decl.Text)
	w.text(fct.Doc)
	w.link(pkg, fct.Pos)

	if w.Examples && len(fct.Examples) > 0 {
		for _, ex := range fct.Examples {
			w.genExample(ex)
		}
	}
}

func (w *gmniWriter) genType(pkg *doc.Package, typ *doc.Type) {
	w.h2("type " + typ.Name)
	w.code(typ.Decl.Text)
	w.text(typ.Doc)

	for _, f := range typ.Funcs {
		w.genFunc(pkg, f)
	}

	for _, m := range typ.Methods {
		w.genFunc(pkg, m)
	}

	if w.Examples && len(typ.Examples) > 0 {
		for _, v := range typ.Examples {
			w.genExample(v)
		}
	}
}
diff --git a/gddo-server/main.go b/gddo-server/main.go
index 5babc04..86d35de 100644
--- a/gddo-server/main.go
+++ b/gddo-server/main.go
@@ -560,6 +560,7 @@ type server struct {

	docSrv  *docServer  // docSrv retrieves package documentation.
	httpSrv *httpServer // httpSrv handles HTTP end-points.
	gmniSrv *gmniServer // gmniSrv handles Gemini end-points.
}

func newServer(ctx context.Context, v *viper.Viper) (*server, error) {
@@ -573,10 +574,16 @@ func newServer(ctx context.Context, v *viper.Viper) (*server, error) {
		return nil, fmt.Errorf("could not create HTTP server: %w", err)
	}

	gmniSrv, err := newGeminiServer(v, docSrv)
	if err != nil {
		return nil, fmt.Errorf("could not create Gemini server: %w", err)
	}

	return &server{
		v:       v,
		docSrv:  docSrv,
		httpSrv: httpSrv,
		gmniSrv: gmniSrv,
	}, nil
}

@@ -611,6 +618,13 @@ func main() {
			}
		}
	}()
	go func() {
		err := s.gmniSrv.srv.ListenAndServe(ctx)
		if err != nil {
			log.Printf("error running Gemini server: %+v", err)
		}
	}()

	http.Handle("/", s)
	log.Fatal(http.ListenAndServe(s.v.GetString(ConfigBindAddress), s))
}
diff --git a/go.mod b/go.mod
index 51cc1b8..e7690b6 100644
--- a/go.mod
+++ b/go.mod
@@ -1,8 +1,9 @@
module github.com/golang/gddo

go 1.13
go 1.16

require (
	git.sr.ht/~adnano/go-gemini v0.2.0
	github.com/BurntSushi/toml v0.3.1 // indirect
	github.com/bradfitz/gomemcache v0.0.0-20170208213004-1952afaa557d // indirect
	github.com/davecgh/go-spew v1.1.1 // indirect
@@ -24,8 +25,6 @@ require (
	github.com/spf13/pflag v1.0.1-0.20170901120850-7aff26db30c1
	github.com/spf13/viper v1.0.0
	github.com/stretchr/testify v1.4.0 // indirect
	golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a // indirect
	golang.org/x/text v0.3.2 // indirect
	golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e
	golang.org/x/net v0.0.0-20210421230115-4e50805a0758 // indirect
	gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
)
diff --git a/go.sum b/go.sum
index 490803d..f46d2bf 100644
--- a/go.sum
+++ b/go.sum
@@ -1,3 +1,5 @@
git.sr.ht/~adnano/go-gemini v0.2.0 h1:24rGvovyXhUX8X51EJq8JeHMxxbO362KzCBtz6QNH7o=
git.sr.ht/~adnano/go-gemini v0.2.0/go.mod h1:hQ75Y0i5jSFL+FQ7AzWVAYr5LQsaFC7v3ZviNyj46dY=
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/bradfitz/gomemcache v0.0.0-20170208213004-1952afaa557d h1:7IjN4QP3c38xhg6wz8R3YjoU+6S9e7xBc0DAVLLIpHE=
@@ -47,11 +49,16 @@ github.com/spf13/viper v1.0.0/go.mod h1:A8kyI5cUJhb8N+3pkfONlcEcZbueH6nhAm0Fq7Sr
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e h1:FDhOuMEY4JVRztM/gsbk+IKUQ8kj74bxZrgw87eMMVc=
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210421230115-4e50805a0758 h1:aEpZnXcAmXkd6AvLb2OPt+EN1Zu/8Ne3pCqPjja5PXY=
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe h1:WdX7u8s3yOigWAhHEaDl8r9G+4XwFQEQFtBMYyN+kXQ=
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
-- 
2.31.1
Reply to thread Export thread (mbox)