Refactor frontend as prep for merging sandbox and frontend

Change-Id: I2c7f5c6f11134aec1fafa5d3963adfbcbc883690
Reviewed-on: https://go-review.googlesource.com/84915
Reviewed-by: Brad Fitzpatrick <bradfitz@golang.org>
diff --git a/README.md b/README.md
index 88e2951..4b4f986 100644
--- a/README.md
+++ b/README.md
@@ -14,15 +14,17 @@
 docker build -t frontend frontend/
 ```
 
-### Dev Setup
+### Running with an in-memory store
 
 ```
+docker run --rm -d -p 8080:8080 frontend
+```
+
+### Running with the Cloud Datastore Emulator
+
+```
+# install it if needed
 gcloud components install cloud-datastore-emulator
-```
-
-### Running
-
-```
 # run the datastore emulator
 gcloud --project=golang-org beta emulators datastore start
 # set env vars
@@ -33,6 +35,11 @@
 
 Now visit localhost:8080 to ensure it worked.
 
+```
+# unset any env vars once finished
+$(gcloud beta emulators datastore env-unset)
+```
+
 ## Sandbox
 
 ### Building
diff --git a/frontend/compile.go b/frontend/compile.go
index 1d1b39a..0c4deb5 100644
--- a/frontend/compile.go
+++ b/frontend/compile.go
@@ -1,39 +1,37 @@
-// Copyright 2011 The Go Authors.  All rights reserved.
+// Copyright 2011 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"
 	"io"
 	"net/http"
-	"os"
 )
 
 const runURL = "https://golang.org/compile?output=json"
 
-func compile(w http.ResponseWriter, r *http.Request) {
+func (s *server) handleCompile(w http.ResponseWriter, r *http.Request) {
 	if r.Method != "POST" {
 		http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
 		return
 	}
-	if err := passThru(w, r); err != nil {
+	if err := s.passThru(w, r); err != nil {
 		http.Error(w, "Compile server error.", http.StatusInternalServerError)
 		return
 	}
 }
 
-func passThru(w io.Writer, req *http.Request) error {
+func (s *server) passThru(w io.Writer, req *http.Request) error {
 	defer req.Body.Close()
 	r, err := http.Post(runURL, req.Header.Get("Content-type"), req.Body)
 	if err != nil {
-		fmt.Fprintf(os.Stderr, "error making POST request: %v", err)
+		s.log.Errorf("error making POST request: %v", err)
 		return err
 	}
 	defer r.Body.Close()
 	if _, err := io.Copy(w, r.Body); err != nil {
-		fmt.Fprintf(os.Stderr, "error copying response Body: %v", err)
+		s.log.Errorf("error copying response Body: %v", err)
 		return err
 	}
 	return nil
diff --git a/frontend/edit.go b/frontend/edit.go
index f24fcb7..5edd633 100644
--- a/frontend/edit.go
+++ b/frontend/edit.go
@@ -1,4 +1,4 @@
-// Copyright 2011 The Go Authors.  All rights reserved.
+// Copyright 2011 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.
 
@@ -8,7 +8,6 @@
 	"fmt"
 	"html/template"
 	"net/http"
-	"os"
 	"strings"
 
 	"cloud.google.com/go/datastore"
@@ -23,7 +22,7 @@
 	Share   bool
 }
 
-func edit(w http.ResponseWriter, r *http.Request) {
+func (s *server) handleEdit(w http.ResponseWriter, r *http.Request) {
 	// Redirect foo.play.golang.org to play.golang.org.
 	if strings.HasSuffix(r.Host, "."+hostname) {
 		http.Redirect(w, r, "https://"+hostname, http.StatusFound)
@@ -44,11 +43,10 @@
 			id = id[:len(id)-3]
 			serveText = true
 		}
-		key := datastore.NameKey("Snippet", id, nil)
-		err := datastoreClient.Get(ctx, key, snip)
-		if err != nil {
+
+		if err := s.db.GetSnippet(ctx, id, snip); err != nil {
 			if err != datastore.ErrNoSuchEntity {
-				fmt.Fprintf(os.Stderr, "loading Snippet: %v", err)
+				s.log.Errorf("loading Snippet: %v", err)
 			}
 			http.Error(w, "Snippet not found", http.StatusNotFound)
 			return
@@ -59,7 +57,7 @@
 					"Content-Disposition", fmt.Sprintf(`attachment; filename="%s.go"`, id),
 				)
 			}
-			w.Header().Set("Content-type", "text/plain")
+			w.Header().Set("Content-type", "text/plain; charset=utf-8")
 			w.Write(snip.Body)
 			return
 		}
diff --git a/frontend/fmt.go b/frontend/fmt.go
index ad916fa..571c68a 100644
--- a/frontend/fmt.go
+++ b/frontend/fmt.go
@@ -1,4 +1,4 @@
-// Copyright 2012 The Go Authors.  All rights reserved.
+// Copyright 2012 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.
 
@@ -17,7 +17,7 @@
 	Error string
 }
 
-func fmtHandler(w http.ResponseWriter, r *http.Request) {
+func handleFmt(w http.ResponseWriter, r *http.Request) {
 	var (
 		in  = []byte(r.FormValue("body"))
 		out []byte
diff --git a/frontend/frontend.go b/frontend/frontend.go
deleted file mode 100644
index 55cb22e..0000000
--- a/frontend/frontend.go
+++ /dev/null
@@ -1,68 +0,0 @@
-// Copyright 2013 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 (
-	"context"
-	"flag"
-	"fmt"
-	"io"
-	"log"
-	"net/http"
-	"os"
-
-	"cloud.google.com/go/compute/metadata"
-	"cloud.google.com/go/datastore"
-	"golang.org/x/tools/godoc/static"
-)
-
-var datastoreClient *datastore.Client
-
-func main() {
-	flag.Parse()
-
-	var err error
-	datastoreClient, err = datastore.NewClient(context.Background(), projectID())
-	if err != nil {
-		log.Fatal(err)
-	}
-
-	http.Handle("/", hstsHandler(edit))
-	http.Handle("/compile", hstsHandler(compile))
-	http.Handle("/fmt", hstsHandler(fmtHandler))
-	http.Handle("/share", hstsHandler(share))
-	http.Handle("/playground.js", hstsHandler(play))
-	staticHandler := http.StripPrefix("/static/", http.FileServer(http.Dir("./static")))
-	http.Handle("/static/", hstsHandler(staticHandler.(http.HandlerFunc)))
-	http.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
-		http.ServeFile(w, r, "./static/favicon.ico")
-	})
-	http.HandleFunc("/_ah/health", func(w http.ResponseWriter, r *http.Request) {
-		fmt.Fprintf(w, "ok")
-	})
-	port := os.Getenv("PORT")
-	if port == "" {
-		port = "8080"
-	}
-	log.Printf("Listening on :%v ...", port)
-	log.Fatal(http.ListenAndServe(":"+port, nil))
-}
-
-func projectID() string {
-	id := os.Getenv("DATASTORE_PROJECT_ID")
-	if id != "" {
-		return id
-	}
-	id, err := metadata.ProjectID()
-	if err != nil {
-		log.Fatalf("Could not determine the project ID (%v); If running locally, ensure DATASTORE_PROJECT_ID is set.", err)
-	}
-	return id
-}
-
-func play(w http.ResponseWriter, r *http.Request) {
-	w.Header().Set("Content-type", "text/javascript")
-	io.WriteString(w, static.Files["playground.js"])
-}
diff --git a/frontend/hsts.go b/frontend/hsts.go
deleted file mode 100644
index c463757..0000000
--- a/frontend/hsts.go
+++ /dev/null
@@ -1,15 +0,0 @@
-// Copyright 2016 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 "net/http"
-
-// hstsHandler wraps an http.HandlerFunc such that it sets the HSTS header.
-func hstsHandler(fn http.HandlerFunc) http.Handler {
-	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		w.Header().Set("Strict-Transport-Security", "max-age=31536000; preload")
-		fn(w, r)
-	})
-}
diff --git a/frontend/logger.go b/frontend/logger.go
new file mode 100644
index 0000000..2d36289
--- /dev/null
+++ b/frontend/logger.go
@@ -0,0 +1,43 @@
+// Copyright 2017 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 (
+	stdlog "log"
+	"os"
+)
+
+type logger interface {
+	Printf(format string, args ...interface{})
+	Errorf(format string, args ...interface{})
+	Fatalf(format string, args ...interface{})
+}
+
+// stdLogger implements the logger interface using the log package.
+// There is no need to specify a date/time prefix since stdout and stderr
+// are logged in StackDriver with those values already present.
+type stdLogger struct {
+	stderr *stdlog.Logger
+	stdout *stdlog.Logger
+}
+
+func newStdLogger() *stdLogger {
+	return &stdLogger{
+		stdout: stdlog.New(os.Stdout, "", 0),
+		stderr: stdlog.New(os.Stderr, "", 0),
+	}
+}
+
+func (l *stdLogger) Printf(format string, args ...interface{}) {
+	l.stdout.Printf(format, args...)
+}
+
+func (l *stdLogger) Errorf(format string, args ...interface{}) {
+	l.stderr.Printf(format, args...)
+}
+
+func (l *stdLogger) Fatalf(format string, args ...interface{}) {
+	l.stderr.Fatalf(format, args...)
+}
diff --git a/frontend/main.go b/frontend/main.go
new file mode 100644
index 0000000..497f49c
--- /dev/null
+++ b/frontend/main.go
@@ -0,0 +1,55 @@
+// Copyright 2013 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 (
+	"context"
+	"fmt"
+	"net/http"
+	"os"
+
+	"cloud.google.com/go/compute/metadata"
+	"cloud.google.com/go/datastore"
+)
+
+var log = newStdLogger()
+
+func main() {
+	s, err := newServer(func(s *server) error {
+		pid := projectID()
+		if pid == "" {
+			s.db = &inMemStore{}
+		} else {
+			c, err := datastore.NewClient(context.Background(), pid)
+			if err != nil {
+				return fmt.Errorf("could not create cloud datastore client: %v", err)
+			}
+			s.db = cloudDatastore{client: c}
+		}
+		s.log = log
+		return nil
+	})
+	if err != nil {
+		log.Fatalf("Error creating server: %v", err)
+	}
+	port := os.Getenv("PORT")
+	if port == "" {
+		port = "8080"
+	}
+	log.Printf("Listening on :%v ...", port)
+	log.Fatalf("Error listening on :%v: %v", port, http.ListenAndServe(":"+port, s))
+}
+
+func projectID() string {
+	id := os.Getenv("DATASTORE_PROJECT_ID")
+	if id != "" {
+		return id
+	}
+	id, err := metadata.ProjectID()
+	if err != nil && os.Getenv("GAE_INSTANCE") != "" {
+		log.Fatalf("Could not determine the project ID: %v", err)
+	}
+	return id
+}
diff --git a/frontend/server.go b/frontend/server.go
new file mode 100644
index 0000000..881c6d7
--- /dev/null
+++ b/frontend/server.go
@@ -0,0 +1,81 @@
+// Copyright 2017 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"
+	"strings"
+	"time"
+
+	"golang.org/x/tools/godoc/static"
+)
+
+type server struct {
+	mux *http.ServeMux
+	db  store
+	log logger
+
+	// When the executable was last modified. Used for caching headers of compiled assets.
+	modtime time.Time
+}
+
+func newServer(options ...func(s *server) error) (*server, error) {
+	s := &server{mux: http.NewServeMux()}
+	for _, o := range options {
+		if err := o(s); err != nil {
+			return nil, err
+		}
+	}
+	if s.db == nil {
+		return nil, fmt.Errorf("must provide an option func that specifies a datastore")
+	}
+	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()
+		}
+	}
+	s.init()
+	return s, nil
+}
+
+func (s *server) init() {
+	s.mux.HandleFunc("/", s.handleEdit)
+	s.mux.HandleFunc("/compile", s.handleCompile)
+	s.mux.HandleFunc("/fmt", handleFmt)
+	s.mux.HandleFunc("/share", s.handleShare)
+	s.mux.HandleFunc("/playground.js", s.handlePlaygroundJS)
+	s.mux.HandleFunc("/favicon.ico", handleFavicon)
+	s.mux.HandleFunc("/_ah/health", handleHealthCheck)
+
+	staticHandler := http.StripPrefix("/static/", http.FileServer(http.Dir("./static")))
+	s.mux.Handle("/static/", staticHandler)
+}
+
+func (s *server) handlePlaygroundJS(w http.ResponseWriter, r *http.Request) {
+	w.Header().Set("Content-type", "text/javascript; charset=utf-8")
+	rd := strings.NewReader(static.Files["playground.js"])
+	http.ServeContent(w, r, "playground.js", s.modtime, rd)
+}
+
+func handleFavicon(w http.ResponseWriter, r *http.Request) {
+	http.ServeFile(w, r, "./static/favicon.ico")
+}
+
+func handleHealthCheck(w http.ResponseWriter, r *http.Request) {
+	fmt.Fprintf(w, "ok")
+}
+
+func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	if os.Getenv("GAE_INSTANCE") != "" {
+		w.Header().Set("Strict-Transport-Security", "max-age=31536000; preload")
+	}
+	s.mux.ServeHTTP(w, r)
+}
diff --git a/frontend/server_test.go b/frontend/server_test.go
new file mode 100644
index 0000000..19bc261
--- /dev/null
+++ b/frontend/server_test.go
@@ -0,0 +1,31 @@
+// Copyright 2017 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 "testing"
+
+type testLogger struct {
+	t *testing.T
+}
+
+func (l testLogger) Printf(format string, args ...interface{}) {
+	l.t.Logf(format, args...)
+}
+func (l testLogger) Errorf(format string, args ...interface{}) {
+	l.t.Errorf(format, args...)
+}
+func (l testLogger) Fatalf(format string, args ...interface{}) {
+	l.t.Fatalf(format, args...)
+}
+
+func testingOptions(t *testing.T) func(s *server) error {
+	return func(s *server) error {
+		s.db = &inMemStore{}
+		s.log = testLogger{t}
+		return nil
+	}
+}
+
+func TestEdit(t *testing.T) {
+}
diff --git a/frontend/share.go b/frontend/share.go
index 60c9a78..1ac139f 100644
--- a/frontend/share.go
+++ b/frontend/share.go
@@ -1,4 +1,4 @@
-// Copyright 2011 The Go Authors.  All rights reserved.
+// Copyright 2011 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.
 
@@ -12,8 +12,6 @@
 	"io"
 	"net/http"
 	"os"
-
-	"cloud.google.com/go/datastore"
 )
 
 const salt = "[replace this with something unique]"
@@ -34,29 +32,27 @@
 	return string(b)[:10]
 }
 
-func share(w http.ResponseWriter, r *http.Request) {
+func (s *server) handleShare(w http.ResponseWriter, r *http.Request) {
 	w.Header().Set("Access-Control-Allow-Origin", "*")
 	if r.Method == "OPTIONS" {
 		// This is likely a pre-flight CORS request.
 		return
 	}
 	if r.Method != "POST" {
-		status := http.StatusMethodNotAllowed
-		http.Error(w, http.StatusText(status), status)
+		http.Error(w, "Requires POST", http.StatusMethodNotAllowed)
 		return
 	}
 	if !allowShare(r) {
-		status := http.StatusUnavailableForLegalReasons
-		http.Error(w, http.StatusText(status), status)
+		http.Error(w, "Either this isn't available in your country due to legal reasons, or our IP geolocation is wrong.",
+			http.StatusUnavailableForLegalReasons)
 		return
 	}
-	ctx := r.Context()
 
 	var body bytes.Buffer
 	_, err := io.Copy(&body, io.LimitReader(r.Body, maxSnippetSize+1))
 	r.Body.Close()
 	if err != nil {
-		fmt.Fprintf(os.Stderr, "reading Body: %v", err)
+		s.log.Errorf("reading Body: %v", err)
 		http.Error(w, "Server Error", http.StatusInternalServerError)
 		return
 	}
@@ -67,10 +63,8 @@
 
 	snip := &snippet{Body: body.Bytes()}
 	id := snip.ID()
-	key := datastore.NameKey("Snippet", id, nil)
-	_, err = datastoreClient.Put(ctx, key, snip)
-	if err != nil {
-		fmt.Fprintf(os.Stderr, "putting Snippet: %v", err)
+	if err := s.db.PutSnippet(r.Context(), id, snip); err != nil {
+		s.log.Errorf("putting Snippet: %v", err)
 		http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
 		return
 	}
diff --git a/frontend/store.go b/frontend/store.go
new file mode 100644
index 0000000..3fecbd7
--- /dev/null
+++ b/frontend/store.go
@@ -0,0 +1,61 @@
+// Copyright 2017 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 (
+	"context"
+	"sync"
+
+	"cloud.google.com/go/datastore"
+)
+
+type store interface {
+	PutSnippet(ctx context.Context, id string, snip *snippet) error
+	GetSnippet(ctx context.Context, id string, snip *snippet) error
+}
+
+type cloudDatastore struct {
+	client *datastore.Client
+}
+
+func (s cloudDatastore) PutSnippet(ctx context.Context, id string, snip *snippet) error {
+	key := datastore.NameKey("Snippet", id, nil)
+	_, err := s.client.Put(ctx, key, snip)
+	return err
+}
+
+func (s cloudDatastore) GetSnippet(ctx context.Context, id string, snip *snippet) error {
+	key := datastore.NameKey("Snippet", id, nil)
+	return s.client.Get(ctx, key, snip)
+}
+
+// inMemStore is a store backed by a map that should only be used for testing.
+type inMemStore struct {
+	sync.RWMutex
+	m map[string]*snippet // key -> snippet
+}
+
+func (s *inMemStore) PutSnippet(_ context.Context, id string, snip *snippet) error {
+	s.Lock()
+	if s.m == nil {
+		s.m = map[string]*snippet{}
+	}
+	b := make([]byte, len(snip.Body))
+	copy(b, snip.Body)
+	s.m[id] = &snippet{Body: b}
+	s.Unlock()
+	return nil
+}
+
+func (s *inMemStore) GetSnippet(_ context.Context, id string, snip *snippet) error {
+	s.RLock()
+	defer s.RUnlock()
+	v, ok := s.m[id]
+	if !ok {
+		return datastore.ErrNoSuchEntity
+	}
+	*snip = *v
+	return nil
+}