From 8bb1e2e842f6aee8a83ef25b1da21431aebef1ac Mon Sep 17 00:00:00 2001 From: Robert Findley Date: Tue, 16 Nov 2021 11:11:47 -0500 Subject: [PATCH] examples: derive examples from the examples directory With the gotip playground we may want to swap out examples with greater frequency, to demonstrate new features at Go tip. Make this easier by deriving examples directly from the examples directory via a new examplesHandler type. This also enables having dynamic content for hello.txt, which depends on the value of runtime.GoVersion(). This will impact the non-tip playground in a few ways: - We will now pre-load examples at server start up. - Examples will be sorted by their title (with the exception of Hello, playground, which is always first). - We will set a CORS header for examples. This was added for consistency with other handlers, and seems harmless. Generalize TestShare to TestServer, and use it to test the examples handler. Add a single gotip example demonstrating generics. Updates golang/go#48517 Change-Id: I7ab58eb391829d581f7aeae95c291666be5718b9 Reviewed-on: https://go-review.googlesource.com/c/playground/+/364374 Trust: Robert Findley Run-TryBot: Robert Findley TryBot-Result: Go Bot Reviewed-by: Alexander Rakoczy --- edit.go | 39 +---------- edit.html | 10 +-- examples.go | 153 +++++++++++++++++++++++++++++++++++++++++ examples/README.md | 9 +++ examples/clear.txt | 1 + examples/hello.txt | 9 --- examples/http.txt | 1 + examples/image.txt | 1 + examples/min.gotip.txt | 19 +++++ examples/multi.txt | 1 + examples/sleep.txt | 1 + examples/test.txt | 1 + main.go | 11 +++ server.go | 23 +++---- server_test.go | 39 ++++++++--- 15 files changed, 242 insertions(+), 76 deletions(-) create mode 100644 examples.go create mode 100644 examples/README.md delete mode 100644 examples/hello.txt create mode 100644 examples/min.gotip.txt diff --git a/edit.go b/edit.go index 3d63848c..500d664f 100644 --- a/edit.go +++ b/edit.go @@ -24,6 +24,7 @@ type editData struct { Analytics bool GoVersion string Gotip bool + Examples []example } func (s *server) handleEdit(w http.ResponseWriter, r *http.Request) { @@ -45,11 +46,7 @@ func (s *server) handleEdit(w http.ResponseWriter, r *http.Request) { return } - content := hello - if s.gotip { - content = helloGotip - } - snip := &snippet{Body: []byte(content)} + snip := &snippet{Body: []byte(s.examples.hello())} if strings.HasPrefix(r.URL.Path, "/p/") { if !allowShare(r) { w.WriteHeader(http.StatusUnavailableForLegalReasons) @@ -88,40 +85,10 @@ func (s *server) handleEdit(w http.ResponseWriter, r *http.Request) { Analytics: r.Host == hostname, GoVersion: runtime.Version(), Gotip: s.gotip, + Examples: s.examples.examples, } if err := editTemplate.Execute(w, data); err != nil { s.log.Errorf("editTemplate.Execute(w, %+v): %v", data, err) return } } - -const hello = `package main - -import ( - "fmt" -) - -func main() { - fmt.Println("Hello, playground") -} -` - -var helloGotip = fmt.Sprintf(`package main - -import ( - "fmt" -) - -// This playground uses a development build of Go: -// %s - -func Print[T any](s ...T) { - for _, v := range s { - fmt.Print(v) - } -} - -func main() { - Print("Hello, ", "playground\n") -} -`, runtime.Version()) diff --git a/edit.html b/edit.html index bf5e4df4..1045005e 100644 --- a/edit.html +++ b/edit.html @@ -121,13 +121,9 @@ {{end}} diff --git a/examples.go b/examples.go new file mode 100644 index 00000000..d834d1f9 --- /dev/null +++ b/examples.go @@ -0,0 +1,153 @@ +// Copyright 2021 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package main + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + "time" +) + +// examplesHandler serves example content out of the examples directory. +type examplesHandler struct { + modtime time.Time + examples []example +} + +type example struct { + Title string + Path string + Content string +} + +func (h *examplesHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + for _, e := range h.examples { + if e.Path == req.URL.Path { + http.ServeContent(w, req, e.Path, h.modtime, strings.NewReader(e.Content)) + return + } + } + http.NotFound(w, req) +} + +// hello returns the hello text for this instance, which depends on the Go +// version and whether or not we are serving Gotip examples. +func (h *examplesHandler) hello() string { + return h.examples[0].Content +} + +// newExamplesHandler reads from the examples directory, returning a handler to +// serve their content. +// +// If gotip is set, all files ending in .txt will be included in the set of +// examples. If gotip is not set, files ending in .gotip.txt are excluded. +// Examples must start with a line beginning "// Title:" that sets their title. +// +// modtime is used for content caching headers. +func newExamplesHandler(gotip bool, modtime time.Time) (*examplesHandler, error) { + const dir = "examples" + entries, err := os.ReadDir(dir) + if err != nil { + return nil, err + } + + var examples []example + for _, entry := range entries { + name := entry.Name() + + // Read examples ending in .txt, skipping those ending in .gotip.txt if + // gotip is not set. + prefix := "" // if non-empty, this is a relevant example file + if strings.HasSuffix(name, ".gotip.txt") { + if gotip { + prefix = strings.TrimSuffix(name, ".gotip.txt") + } + } else if strings.HasSuffix(name, ".txt") { + prefix = strings.TrimSuffix(name, ".txt") + } + + if prefix == "" { + continue + } + + data, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + return nil, err + } + content := string(data) + + // Extract the magic "// Title:" comment specifying the example's title. + nl := strings.IndexByte(content, '\n') + const titlePrefix = "// Title:" + if nl == -1 || !strings.HasPrefix(content, titlePrefix) { + return nil, fmt.Errorf("malformed example for %q: must start with a title line beginning %q", name, titlePrefix) + } + title := strings.TrimPrefix(content[:nl], titlePrefix) + title = strings.TrimSpace(title) + + examples = append(examples, example{ + Title: title, + Path: name, + Content: content[nl+1:], + }) + } + + // Sort by title, before prepending the hello example (we always want Hello + // to be first). + sort.Slice(examples, func(i, j int) bool { + return examples[i].Title < examples[j].Title + }) + + // For Gotip, serve hello content that includes the Go version. + hi := hello + if gotip { + hi = helloGotip + } + + examples = append([]example{ + {"Hello, playground", "hello.txt", hi}, + }, examples...) + return &examplesHandler{ + modtime: modtime, + examples: examples, + }, nil +} + +const hello = `package main + +import ( + "fmt" +) + +func main() { + fmt.Println("Hello, playground") +} +` + +var helloGotip = fmt.Sprintf(`package main + +import ( + "fmt" +) + +// This playground uses a development build of Go: +// %s + +func Print[T any](s ...T) { + for _, v := range s { + fmt.Print(v) + } +} + +func main() { + Print("Hello, ", "playground\n") +} +`, runtime.Version()) diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 00000000..1021ad30 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,9 @@ +# Playground Examples + +Add examples to the playground by adding files to this directory with the +`.txt` file extension. Examples with file names ending in `.gotip.txt` are only +displayed on the gotip playground. + +Each example must start with a line beginning with "// Title:", specifying the +title of the example in the selection menu. This title line will be stripped +from the example before serving. diff --git a/examples/clear.txt b/examples/clear.txt index c5381d71..26c767a6 100644 --- a/examples/clear.txt +++ b/examples/clear.txt @@ -1,3 +1,4 @@ +// Title: Clear package main import ( diff --git a/examples/hello.txt b/examples/hello.txt deleted file mode 100644 index 8fd43ed1..00000000 --- a/examples/hello.txt +++ /dev/null @@ -1,9 +0,0 @@ -package main - -import ( - "fmt" -) - -func main() { - fmt.Println("Hello, playground") -} diff --git a/examples/http.txt b/examples/http.txt index 7c8f6513..d35788a2 100644 --- a/examples/http.txt +++ b/examples/http.txt @@ -1,3 +1,4 @@ +// Title: HTTP server package main import ( diff --git a/examples/image.txt b/examples/image.txt index 4180a450..01c6cc1b 100644 --- a/examples/image.txt +++ b/examples/image.txt @@ -1,3 +1,4 @@ +// Title: Display image package main import ( diff --git a/examples/min.gotip.txt b/examples/min.gotip.txt new file mode 100644 index 00000000..3c719788 --- /dev/null +++ b/examples/min.gotip.txt @@ -0,0 +1,19 @@ +// Title: Generic min +package main + +import ( + "fmt" + "constraints" +) + +func min[P constraints.Ordered](x, y P) P { + if x < y { + return x + } else { + return y + } +} + +func main() { + fmt.Println(min(42, 24)) +} diff --git a/examples/multi.txt b/examples/multi.txt index ad41446f..0c2800d9 100644 --- a/examples/multi.txt +++ b/examples/multi.txt @@ -1,3 +1,4 @@ +// Title: Multiple files package main import ( diff --git a/examples/sleep.txt b/examples/sleep.txt index d68a3d8e..6377ab1e 100644 --- a/examples/sleep.txt +++ b/examples/sleep.txt @@ -1,3 +1,4 @@ +// Title: Sleep package main import ( diff --git a/examples/test.txt b/examples/test.txt index 4bf86e90..c31fcda1 100644 --- a/examples/test.txt +++ b/examples/test.txt @@ -1,3 +1,4 @@ +// Title: Test package main import ( diff --git a/main.go b/main.go index 1d6c9ac5..3adf653e 100644 --- a/main.go +++ b/main.go @@ -47,6 +47,17 @@ func main() { if gotip := os.Getenv("GOTIP"); gotip == "true" { s.gotip = true } + execpath, _ := os.Executable() + if execpath != "" { + if fi, _ := os.Stat(execpath); fi != nil { + s.modtime = fi.ModTime() + } + } + eh, err := newExamplesHandler(s.gotip, s.modtime) + if err != nil { + return err + } + s.examples = eh return nil }, enableMetrics) if err != nil { diff --git a/server.go b/server.go index ff03baae..21a92702 100644 --- a/server.go +++ b/server.go @@ -7,7 +7,6 @@ package main import ( "fmt" "net/http" - "os" "strings" "time" @@ -15,11 +14,12 @@ import ( ) type server struct { - mux *http.ServeMux - db store - log logger - cache responseCache - gotip bool // if set, server is using gotip + mux *http.ServeMux + db store + log logger + cache responseCache + gotip bool // if set, server is using gotip + examples *examplesHandler // When the executable was last modified. Used for caching headers of compiled assets. modtime time.Time @@ -38,11 +38,8 @@ func newServer(options ...func(s *server) error) (*server, error) { if s.log == nil { return nil, fmt.Errorf("must provide an option func that specifies a logger") } - execpath, _ := os.Executable() - if execpath != "" { - if fi, _ := os.Stat(execpath); fi != nil { - s.modtime = fi.ModTime() - } + if s.examples == nil { + return nil, fmt.Errorf("must provide an option func that sets the examples handler") } s.init() return s, nil @@ -60,9 +57,7 @@ func (s *server) init() { staticHandler := http.StripPrefix("/static/", http.FileServer(http.Dir("./static"))) s.mux.Handle("/static/", staticHandler) - - examplesHandler := http.StripPrefix("/doc/play/", http.FileServer(http.Dir("./examples"))) - s.mux.Handle("/doc/play/", examplesHandler) + s.mux.Handle("/doc/play/", http.StripPrefix("/doc/play/", s.examples)) } func (s *server) handlePlaygroundJS(w http.ResponseWriter, r *http.Request) { diff --git a/server_test.go b/server_test.go index ae3a9cd2..1fb178cf 100644 --- a/server_test.go +++ b/server_test.go @@ -15,6 +15,7 @@ import ( "os" "sync" "testing" + "time" "github.com/bradfitz/gomemcache/memcache" "github.com/google/go-cmp/cmp" @@ -38,6 +39,11 @@ func testingOptions(t *testing.T) func(s *server) error { return func(s *server) error { s.db = &inMemStore{} s.log = testLogger{t} + var err error + s.examples, err = newExamplesHandler(false, time.Now()) + if err != nil { + return err + } return nil } } @@ -101,30 +107,38 @@ func TestEdit(t *testing.T) { } } -func TestShare(t *testing.T) { +func TestServer(t *testing.T) { s, err := newServer(testingOptions(t)) if err != nil { t.Fatalf("newServer(testingOptions(t)): %v", err) } - const url = "https://play.golang.org/share" + const shareURL = "https://play.golang.org/share" testCases := []struct { desc string method string + url string statusCode int reqBody []byte respBody []byte }{ - {"OPTIONS no-op", http.MethodOptions, http.StatusOK, nil, nil}, - {"Non-POST request", http.MethodGet, http.StatusMethodNotAllowed, nil, nil}, - {"Standard flow", http.MethodPost, http.StatusOK, []byte("Snippy McSnipface"), []byte("N_M_YelfGeR")}, - {"Snippet too large", http.MethodPost, http.StatusRequestEntityTooLarge, make([]byte, maxSnippetSize+1), nil}, + // Share tests. + {"OPTIONS no-op", http.MethodOptions, shareURL, http.StatusOK, nil, nil}, + {"Non-POST request", http.MethodGet, shareURL, http.StatusMethodNotAllowed, nil, nil}, + {"Standard flow", http.MethodPost, shareURL, http.StatusOK, []byte("Snippy McSnipface"), []byte("N_M_YelfGeR")}, + {"Snippet too large", http.MethodPost, shareURL, http.StatusRequestEntityTooLarge, make([]byte, maxSnippetSize+1), nil}, + + // Examples tests. + {"Hello example", http.MethodGet, "https://play.golang.org/doc/play/hello.txt", http.StatusOK, nil, []byte("Hello")}, + {"HTTP example", http.MethodGet, "https://play.golang.org/doc/play/http.txt", http.StatusOK, nil, []byte("net/http")}, + // Gotip examples should not be available on the non-tip playground. + {"Gotip example", http.MethodGet, "https://play.golang.org/doc/play/min.gotip.txt", http.StatusNotFound, nil, nil}, } for _, tc := range testCases { - req := httptest.NewRequest(tc.method, url, bytes.NewReader(tc.reqBody)) + req := httptest.NewRequest(tc.method, tc.url, bytes.NewReader(tc.reqBody)) w := httptest.NewRecorder() - s.handleShare(w, req) + s.mux.ServeHTTP(w, req) resp := w.Result() corsHeader := "Access-Control-Allow-Origin" if got, want := resp.Header.Get(corsHeader), "*"; got != want { @@ -139,8 +153,8 @@ func TestShare(t *testing.T) { if err != nil { t.Errorf("%s: ioutil.ReadAll(resp.Body): %v", tc.desc, err) } - if !bytes.Equal(b, tc.respBody) { - t.Errorf("%s: got unexpected body %q; want %q", tc.desc, b, tc.respBody) + if !bytes.Contains(b, tc.respBody) { + t.Errorf("%s: got unexpected body %q; want contains %q", tc.desc, b, tc.respBody) } } } @@ -172,6 +186,11 @@ func TestCommandHandler(t *testing.T) { // instead of just printing or failing the test? s.log = newStdLogger() s.cache = new(inMemCache) + var err error + s.examples, err = newExamplesHandler(false, time.Now()) + if err != nil { + return err + } return nil }) if err != nil {