From 1ec9d828b58010f3bce5a5ed55f1716ef1b3c335 Mon Sep 17 00:00:00 2001 From: Tyler Bui-Palsulich Date: Wed, 11 Sep 2019 09:35:11 -0400 Subject: [PATCH] Revert "getting-started/bookshelf: initial rewrite (#951)" This reverts commit 0e0de939565b6bbebf327599ad2db9060fc7d892. --- .../bookshelf/{main.go => app/app.go} | 231 +++++++----- getting-started/bookshelf/app/app.yaml | 30 ++ getting-started/bookshelf/app/app_test.go | 155 ++++++++ getting-started/bookshelf/app/auth.go | 200 ++++++++++ getting-started/bookshelf/app/index.yaml | 28 ++ .../bookshelf/{ => app}/template.go | 23 +- .../bookshelf/{ => app}/templates/base.html | 23 ++ .../bookshelf/{ => app}/templates/detail.html | 1 + .../bookshelf/{ => app}/templates/edit.html | 2 + .../bookshelf/{ => app}/templates/list.html | 0 getting-started/bookshelf/book.go | 68 ++++ getting-started/bookshelf/book_test.go | 40 ++ getting-started/bookshelf/config.go | 222 ++++++++--- getting-started/bookshelf/db_datastore.go | 147 ++++++++ getting-started/bookshelf/db_firestore.go | 115 ------ getting-started/bookshelf/db_memory.go | 72 ++-- getting-started/bookshelf/db_mongo.go | 122 ++++++ getting-started/bookshelf/db_mysql.go | 350 ++++++++++++++++++ getting-started/bookshelf/db_test.go | 67 ++-- getting-started/bookshelf/doc.go | 16 + .../bookshelf/gce_deployment/deploy-binary.sh | 49 +++ .../bookshelf/gce_deployment/deploy.sh | 155 ++++++++ .../gce_deployment/startup-script.sh | 62 ++++ .../bookshelf/gce_deployment/teardown.sh | 42 +++ .../bookshelf/gke_deployment/Dockerfile | 21 ++ .../gke_deployment/bookshelf-frontend.yaml | 49 +++ .../gke_deployment/bookshelf-service.yaml | 36 ++ .../gke_deployment/bookshelf-worker.yaml | 45 +++ getting-started/bookshelf/go.mod | 11 - getting-started/bookshelf/go.sum | 125 ------- getting-started/bookshelf/main_test.go | 212 ----------- .../bookshelf/{ => pubsub_worker}/app.yaml | 16 +- .../bookshelf/pubsub_worker/worker.go | 164 ++++++++ .../internal => internal}/webtest/webtest.go | 0 34 files changed, 2241 insertions(+), 658 deletions(-) rename getting-started/bookshelf/{main.go => app/app.go} (50%) create mode 100644 getting-started/bookshelf/app/app.yaml create mode 100644 getting-started/bookshelf/app/app_test.go create mode 100644 getting-started/bookshelf/app/auth.go create mode 100644 getting-started/bookshelf/app/index.yaml rename getting-started/bookshelf/{ => app}/template.go (70%) rename getting-started/bookshelf/{ => app}/templates/base.html (62%) rename getting-started/bookshelf/{ => app}/templates/detail.html (96%) rename getting-started/bookshelf/{ => app}/templates/edit.html (92%) rename getting-started/bookshelf/{ => app}/templates/list.html (100%) create mode 100644 getting-started/bookshelf/book.go create mode 100644 getting-started/bookshelf/book_test.go create mode 100644 getting-started/bookshelf/db_datastore.go delete mode 100644 getting-started/bookshelf/db_firestore.go create mode 100644 getting-started/bookshelf/db_mongo.go create mode 100644 getting-started/bookshelf/db_mysql.go create mode 100644 getting-started/bookshelf/doc.go create mode 100755 getting-started/bookshelf/gce_deployment/deploy-binary.sh create mode 100755 getting-started/bookshelf/gce_deployment/deploy.sh create mode 100644 getting-started/bookshelf/gce_deployment/startup-script.sh create mode 100755 getting-started/bookshelf/gce_deployment/teardown.sh create mode 100644 getting-started/bookshelf/gke_deployment/Dockerfile create mode 100644 getting-started/bookshelf/gke_deployment/bookshelf-frontend.yaml create mode 100644 getting-started/bookshelf/gke_deployment/bookshelf-service.yaml create mode 100644 getting-started/bookshelf/gke_deployment/bookshelf-worker.yaml delete mode 100644 getting-started/bookshelf/go.mod delete mode 100644 getting-started/bookshelf/go.sum delete mode 100644 getting-started/bookshelf/main_test.go rename getting-started/bookshelf/{ => pubsub_worker}/app.yaml (70%) create mode 100644 getting-started/bookshelf/pubsub_worker/worker.go rename {getting-started/bookshelf/internal => internal}/webtest/webtest.go (100%) diff --git a/getting-started/bookshelf/main.go b/getting-started/bookshelf/app/app.go similarity index 50% rename from getting-started/bookshelf/main.go rename to getting-started/bookshelf/app/app.go index 2c2ac3cb7b..9de7175226 100644 --- a/getting-started/bookshelf/main.go +++ b/getting-started/bookshelf/app/app.go @@ -12,14 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. -// The bookshelf command starts the bookshelf server, a sample app -// demonstrating several Google Cloud APIs, including App Engine, Firestore, and -// Cloud Storage. -// See https://cloud.google.com/go/getting-started/tutorial-app. +// Sample bookshelf is a fully-featured app demonstrating several Google Cloud APIs, including Datastore, Cloud SQL, Cloud Storage. +// See https://cloud.google.com/go/getting-started/tutorial-app package main import ( "context" + "encoding/json" "errors" "fmt" "io" @@ -27,16 +26,20 @@ import ( "net/http" "os" "path" + "strconv" - "cloud.google.com/go/firestore" + "cloud.google.com/go/pubsub" "cloud.google.com/go/storage" - "github.com/gofrs/uuid" + + uuid "github.com/gofrs/uuid" "github.com/gorilla/handlers" "github.com/gorilla/mux" + + "github.com/GoogleCloudPlatform/golang-samples/getting-started/bookshelf" ) var ( - // See template.go. + // See template.go listTmpl = parseTemplate("list.html") editTmpl = parseTemplate("edit.html") detailTmpl = parseTemplate("detail.html") @@ -47,55 +50,43 @@ func main() { if port == "" { port = "8080" } - projectID := os.Getenv("GOOGLE_CLOUD_PROJECT") - if projectID == "" { - log.Fatal("GOOGLE_CLOUD_PROJECT must be set") - } - - ctx := context.Background() - - client, err := firestore.NewClient(ctx, projectID) - if err != nil { - log.Fatalf("firestore.NewClient: %v", err) - } - db, err := newFirestoreDB(client) - if err != nil { - log.Fatalf("newFirestoreDB: %v", err) - } - - shelf, err := NewBookshelf(projectID, db) - if err != nil { - log.Fatalf("NewBookshelf: %v", err) - } - - shelf.registerHandlers() - - log.Printf("Listening on localhost:%s", port) + registerHandlers() log.Fatal(http.ListenAndServe(fmt.Sprintf(":%s", port), nil)) } -func (b *Bookshelf) registerHandlers() { +func registerHandlers() { // Use gorilla/mux for rich routing. - // See https://www.gorillatoolkit.org/pkg/mux. + // See http://www.gorillatoolkit.org/pkg/mux r := mux.NewRouter() r.Handle("/", http.RedirectHandler("/books", http.StatusFound)) r.Methods("GET").Path("/books"). - Handler(appHandler(b.listHandler)) + Handler(appHandler(listHandler)) + r.Methods("GET").Path("/books/mine"). + Handler(appHandler(listMineHandler)) + r.Methods("GET").Path("/books/{id:[0-9]+}"). + Handler(appHandler(detailHandler)) r.Methods("GET").Path("/books/add"). - Handler(appHandler(b.addFormHandler)) - r.Methods("GET").Path("/books/{id:[0-9a-zA-Z]+}"). - Handler(appHandler(b.detailHandler)) - r.Methods("GET").Path("/books/{id:[0-9a-zA-Z]+}/edit"). - Handler(appHandler(b.editFormHandler)) + Handler(appHandler(addFormHandler)) + r.Methods("GET").Path("/books/{id:[0-9]+}/edit"). + Handler(appHandler(editFormHandler)) r.Methods("POST").Path("/books"). - Handler(appHandler(b.createHandler)) - r.Methods("POST", "PUT").Path("/books/{id:[0-9a-zA-Z]+}"). - Handler(appHandler(b.updateHandler)) - r.Methods("POST").Path("/books/{id:[0-9a-zA-Z]+}:delete"). - Handler(appHandler(b.deleteHandler)).Name("delete") + Handler(appHandler(createHandler)) + r.Methods("POST", "PUT").Path("/books/{id:[0-9]+}"). + Handler(appHandler(updateHandler)) + r.Methods("POST").Path("/books/{id:[0-9]+}:delete"). + Handler(appHandler(deleteHandler)).Name("delete") + + // The following handlers are defined in auth.go and used in the + // "Authenticating Users" part of the Getting Started guide. + r.Methods("GET").Path("/login"). + Handler(appHandler(loginHandler)) + r.Methods("POST").Path("/logout"). + Handler(appHandler(logoutHandler)) + r.Methods("GET").Path("/oauth2callback"). + Handler(appHandler(oauthCallbackHandler)) // Respond to App Engine and Compute Engine health checks. // Indicate the server is healthy. @@ -107,14 +98,30 @@ func (b *Bookshelf) registerHandlers() { // [START request_logging] // Delegate all of the HTTP routing and serving to the gorilla/mux router. // Log all requests using the standard Apache format. - http.Handle("/", handlers.CombinedLoggingHandler(b.logWriter, r)) + http.Handle("/", handlers.CombinedLoggingHandler(os.Stderr, r)) // [END request_logging] } // listHandler displays a list with summaries of books in the database. -func (b *Bookshelf) listHandler(w http.ResponseWriter, r *http.Request) *appError { - ctx := r.Context() - books, err := b.DB.ListBooks(ctx) +func listHandler(w http.ResponseWriter, r *http.Request) *appError { + books, err := bookshelf.DB.ListBooks() + if err != nil { + return appErrorf(err, "could not list books: %v", err) + } + + return listTmpl.Execute(w, r, books) +} + +// listMineHandler displays a list of books created by the currently +// authenticated user. +func listMineHandler(w http.ResponseWriter, r *http.Request) *appError { + user := profileFromSession(r) + if user == nil { + http.Redirect(w, r, "/login?redirect=/books/mine", http.StatusFound) + return nil + } + + books, err := bookshelf.DB.ListBooksCreatedBy(user.ID) if err != nil { return appErrorf(err, "could not list books: %v", err) } @@ -124,13 +131,12 @@ func (b *Bookshelf) listHandler(w http.ResponseWriter, r *http.Request) *appErro // bookFromRequest retrieves a book from the database given a book ID in the // URL's path. -func (b *Bookshelf) bookFromRequest(r *http.Request) (*Book, error) { - ctx := r.Context() - id := mux.Vars(r)["id"] - if id == "" { - return nil, errors.New("no book with empty ID") +func bookFromRequest(r *http.Request) (*bookshelf.Book, error) { + id, err := strconv.ParseInt(mux.Vars(r)["id"], 10, 64) + if err != nil { + return nil, fmt.Errorf("bad book id: %v", err) } - book, err := b.DB.GetBook(ctx, id) + book, err := bookshelf.DB.GetBook(id) if err != nil { return nil, fmt.Errorf("could not find book: %v", err) } @@ -138,8 +144,8 @@ func (b *Bookshelf) bookFromRequest(r *http.Request) (*Book, error) { } // detailHandler displays the details of a given book. -func (b *Bookshelf) detailHandler(w http.ResponseWriter, r *http.Request) *appError { - book, err := b.bookFromRequest(r) +func detailHandler(w http.ResponseWriter, r *http.Request) *appError { + book, err := bookFromRequest(r) if err != nil { return appErrorf(err, "%v", err) } @@ -149,14 +155,14 @@ func (b *Bookshelf) detailHandler(w http.ResponseWriter, r *http.Request) *appEr // addFormHandler displays a form that captures details of a new book to add to // the database. -func (b *Bookshelf) addFormHandler(w http.ResponseWriter, r *http.Request) *appError { +func addFormHandler(w http.ResponseWriter, r *http.Request) *appError { return editTmpl.Execute(w, r, nil) } // editFormHandler displays a form that allows the user to edit the details of // a given book. -func (b *Bookshelf) editFormHandler(w http.ResponseWriter, r *http.Request) *appError { - book, err := b.bookFromRequest(r) +func editFormHandler(w http.ResponseWriter, r *http.Request) *appError { + book, err := bookFromRequest(r) if err != nil { return appErrorf(err, "%v", err) } @@ -166,9 +172,8 @@ func (b *Bookshelf) editFormHandler(w http.ResponseWriter, r *http.Request) *app // bookFromForm populates the fields of a Book from form values // (see templates/edit.html). -func (b *Bookshelf) bookFromForm(r *http.Request) (*Book, error) { - ctx := r.Context() - imageURL, err := b.uploadFileFromForm(ctx, r) +func bookFromForm(r *http.Request) (*bookshelf.Book, error) { + imageURL, err := uploadFileFromForm(r) if err != nil { return nil, fmt.Errorf("could not upload file: %v", err) } @@ -176,19 +181,35 @@ func (b *Bookshelf) bookFromForm(r *http.Request) (*Book, error) { imageURL = r.FormValue("imageURL") } - book := &Book{ + book := &bookshelf.Book{ Title: r.FormValue("title"), Author: r.FormValue("author"), PublishedDate: r.FormValue("publishedDate"), ImageURL: imageURL, Description: r.FormValue("description"), + CreatedBy: r.FormValue("createdBy"), + CreatedByID: r.FormValue("createdByID"), + } + + // If the form didn't carry the user information for the creator, populate it + // from the currently logged in user (or mark as anonymous). + if book.CreatedByID == "" { + user := profileFromSession(r) + if user != nil { + // Logged in. + book.CreatedBy = user.DisplayName + book.CreatedByID = user.ID + } else { + // Not logged in. + book.SetCreatorAnonymous() + } } return book, nil } // uploadFileFromForm uploads a file if it's present in the "image" form field. -func (b *Bookshelf) uploadFileFromForm(ctx context.Context, r *http.Request) (url string, err error) { +func uploadFileFromForm(r *http.Request) (url string, err error) { f, fh, err := r.FormFile("image") if err == http.ErrMissingFile { return "", nil @@ -197,20 +218,15 @@ func (b *Bookshelf) uploadFileFromForm(ctx context.Context, r *http.Request) (ur return "", err } - if b.StorageBucket == nil { - return "", errors.New("storage bucket is missing: check config.go") - } - if _, err := b.StorageBucket.Attrs(ctx); err != nil { - if err == storage.ErrBucketNotExist { - return "", fmt.Errorf("bucket %q does not exist: check config.go", b.StorageBucketName) - } - return "", fmt.Errorf("could not get bucket: %v", err) + if bookshelf.StorageBucket == nil { + return "", errors.New("storage bucket is missing - check config.go") } // random filename, retaining existing extension. name := uuid.Must(uuid.NewV4()).String() + path.Ext(fh.Filename) - w := b.StorageBucket.Object(name).NewWriter(ctx) + ctx := context.Background() + w := bookshelf.StorageBucket.Object(name).NewWriter(ctx) // Warning: storage.AllUsers gives public read access to anyone. w.ACL = []storage.ACLRule{{Entity: storage.AllUsers, Role: storage.RoleReader}} @@ -227,56 +243,79 @@ func (b *Bookshelf) uploadFileFromForm(ctx context.Context, r *http.Request) (ur } const publicURL = "https://storage.googleapis.com/%s/%s" - return fmt.Sprintf(publicURL, b.StorageBucketName, name), nil + return fmt.Sprintf(publicURL, bookshelf.StorageBucketName, name), nil } // createHandler adds a book to the database. -func (b *Bookshelf) createHandler(w http.ResponseWriter, r *http.Request) *appError { - ctx := r.Context() - book, err := b.bookFromForm(r) +func createHandler(w http.ResponseWriter, r *http.Request) *appError { + book, err := bookFromForm(r) if err != nil { return appErrorf(err, "could not parse book from form: %v", err) } - id, err := b.DB.AddBook(ctx, book) + id, err := bookshelf.DB.AddBook(book) if err != nil { return appErrorf(err, "could not save book: %v", err) } - http.Redirect(w, r, fmt.Sprintf("/books/%s", id), http.StatusFound) + go publishUpdate(id) + http.Redirect(w, r, fmt.Sprintf("/books/%d", id), http.StatusFound) return nil } // updateHandler updates the details of a given book. -func (b *Bookshelf) updateHandler(w http.ResponseWriter, r *http.Request) *appError { - ctx := r.Context() - id := mux.Vars(r)["id"] - if id == "" { - return appErrorf(errors.New("no book with empty ID"), "no book with empty ID") +func updateHandler(w http.ResponseWriter, r *http.Request) *appError { + id, err := strconv.ParseInt(mux.Vars(r)["id"], 10, 64) + if err != nil { + return appErrorf(err, "bad book id: %v", err) } - book, err := b.bookFromForm(r) + + book, err := bookFromForm(r) if err != nil { return appErrorf(err, "could not parse book from form: %v", err) } book.ID = id - if err := b.DB.UpdateBook(ctx, book); err != nil { - return appErrorf(err, "UpdateBook: %v", err) + err = bookshelf.DB.UpdateBook(book) + if err != nil { + return appErrorf(err, "could not save book: %v", err) } - http.Redirect(w, r, fmt.Sprintf("/books/%s", book.ID), http.StatusFound) + go publishUpdate(book.ID) + http.Redirect(w, r, fmt.Sprintf("/books/%d", book.ID), http.StatusFound) return nil } // deleteHandler deletes a given book. -func (b *Bookshelf) deleteHandler(w http.ResponseWriter, r *http.Request) *appError { - ctx := r.Context() - id := mux.Vars(r)["id"] - if err := b.DB.DeleteBook(ctx, id); err != nil { - return appErrorf(err, "DeleteBook: %v", err) +func deleteHandler(w http.ResponseWriter, r *http.Request) *appError { + id, err := strconv.ParseInt(mux.Vars(r)["id"], 10, 64) + if err != nil { + return appErrorf(err, "bad book id: %v", err) + } + err = bookshelf.DB.DeleteBook(id) + if err != nil { + return appErrorf(err, "could not delete book: %v", err) } http.Redirect(w, r, "/books", http.StatusFound) return nil } -// https://blog.golang.org/error-handling-and-go +// publishUpdate notifies Pub/Sub subscribers that the book identified with +// the given ID has been added/modified. +func publishUpdate(bookID int64) { + if bookshelf.PubsubClient == nil { + return + } + + ctx := context.Background() + + b, err := json.Marshal(bookID) + if err != nil { + return + } + topic := bookshelf.PubsubClient.Topic(bookshelf.PubsubTopicID) + _, err = topic.Publish(ctx, &pubsub.Message{Data: b}).Get(ctx) + log.Printf("Published update to Pub/Sub for Book ID %d: %v", bookID, err) +} + +// http://blog.golang.org/error-handling-and-go type appHandler func(http.ResponseWriter, *http.Request) *appError type appError struct { @@ -287,7 +326,9 @@ type appError struct { func (fn appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if e := fn(w, r); e != nil { // e is *appError, not os.Error. - log.Printf("Handler error: status code: %d, message: %s, underlying err: %#v", e.Code, e.Message, e.Error) + log.Printf("Handler error: status code: %d, message: %s, underlying err: %#v", + e.Code, e.Message, e.Error) + http.Error(w, e.Message, e.Code) } } diff --git a/getting-started/bookshelf/app/app.yaml b/getting-started/bookshelf/app/app.yaml new file mode 100644 index 0000000000..3bf8ad0b30 --- /dev/null +++ b/getting-started/bookshelf/app/app.yaml @@ -0,0 +1,30 @@ +# Copyright 2019 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +runtime: go +env: flex + +env_variables: + OAUTH2_CALLBACK: https://.appspot.com/oauth2callback + +# [START cloudsql_settings] +# Replace INSTANCE_CONNECTION_NAME with the value obtained when configuring your +# Cloud SQL instance, available from the Google Cloud Console or from the Cloud SDK. +# For SQL v2 instances, this should be in the form of "project:region:instance". +# Cloud SQL v1 instances are not supported. +# +# This should match the value in config.go +beta_settings: +# cloud_sql_instances: INSTANCE_CONNECTION_NAME +# [END cloudsql_settings] diff --git a/getting-started/bookshelf/app/app_test.go b/getting-started/bookshelf/app/app_test.go new file mode 100644 index 0000000000..b239c18045 --- /dev/null +++ b/getting-started/bookshelf/app/app_test.go @@ -0,0 +1,155 @@ +// Copyright 2019 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "bytes" + "fmt" + "mime/multipart" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/GoogleCloudPlatform/golang-samples/getting-started/bookshelf" + "github.com/GoogleCloudPlatform/golang-samples/internal/testutil" + "github.com/GoogleCloudPlatform/golang-samples/internal/webtest" +) + +var wt *webtest.W + +func TestMain(m *testing.M) { + serv := httptest.NewServer(nil) + wt = webtest.New(nil, serv.Listener.Addr().String()) + registerHandlers() + + os.Exit(m.Run()) +} + +// This function verifies compilation occurs without error. +// It may not be possible to run the application without +// satisfying appengine environmental dependencies such as +// the presence of a GCE metadata server. +func TestBuildable(t *testing.T) { + m := testutil.BuildMain(t) + defer m.Cleanup() + if !m.Built() { + t.Fatal("failed to compile application.") + } +} + +func TestNoBooks(t *testing.T) { + bodyContains(t, wt, "/", "No books found") +} + +func TestBookDetail(t *testing.T) { + const title = "book mcbook" + id, err := bookshelf.DB.AddBook(&bookshelf.Book{ + Title: title, + }) + if err != nil { + t.Fatal(err) + } + + bodyContains(t, wt, "/", title) + + bookPath := fmt.Sprintf("/books/%d", id) + bodyContains(t, wt, bookPath, title) + + if err := bookshelf.DB.DeleteBook(id); err != nil { + t.Fatal(err) + } + + bodyContains(t, wt, "/", "No books found") +} + +func TestEditBook(t *testing.T) { + const title = "book mcbook" + id, err := bookshelf.DB.AddBook(&bookshelf.Book{ + Title: title, + }) + if err != nil { + t.Fatal(err) + } + + bookPath := fmt.Sprintf("/books/%d", id) + editPath := bookPath + "/edit" + bodyContains(t, wt, editPath, "Edit book") + bodyContains(t, wt, editPath, title) + + var body bytes.Buffer + m := multipart.NewWriter(&body) + m.WriteField("title", "simpsons") + m.WriteField("author", "homer") + m.Close() + + resp, err := wt.Post(bookPath, "multipart/form-data; boundary="+m.Boundary(), &body) + if err != nil { + t.Fatal(err) + } + if got, want := resp.Request.URL.Path, bookPath; got != want { + t.Errorf("got %s, want %s", got, want) + } + + bodyContains(t, wt, bookPath, "simpsons") + bodyContains(t, wt, bookPath, "homer") + + if err := bookshelf.DB.DeleteBook(id); err != nil { + t.Fatalf("got err %v, want nil", err) + } +} + +func TestAddAndDelete(t *testing.T) { + bodyContains(t, wt, "/books/add", "Add book") + + bookPath := fmt.Sprintf("/books") + + var body bytes.Buffer + m := multipart.NewWriter(&body) + m.WriteField("title", "simpsons") + m.WriteField("author", "homer") + m.Close() + + resp, err := wt.Post(bookPath, "multipart/form-data; boundary="+m.Boundary(), &body) + if err != nil { + t.Fatal(err) + } + + gotPath := resp.Request.URL.Path + if wantPrefix := "/books"; !strings.HasPrefix(gotPath, wantPrefix) { + t.Fatalf("redirect: got %q, want prefix %q", gotPath, wantPrefix) + } + + bodyContains(t, wt, gotPath, "simpsons") + bodyContains(t, wt, gotPath, "homer") + + _, err = wt.Post(gotPath+":delete", "", nil) + if err != nil { + t.Fatal(err) + } +} + +func bodyContains(t *testing.T, wt *webtest.W, path, contains string) (ok bool) { + body, _, err := wt.GetBody(path) + if err != nil { + t.Error(err) + return false + } + if !strings.Contains(body, contains) { + t.Errorf("want %s to contain %s", body, contains) + return false + } + return true +} diff --git a/getting-started/bookshelf/app/auth.go b/getting-started/bookshelf/app/auth.go new file mode 100644 index 0000000000..2b918d1ba6 --- /dev/null +++ b/getting-started/bookshelf/app/auth.go @@ -0,0 +1,200 @@ +// Copyright 2019 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "encoding/gob" + "errors" + "net/http" + "net/url" + + plus "google.golang.org/api/plus/v1" + + "golang.org/x/oauth2" + + "github.com/GoogleCloudPlatform/golang-samples/getting-started/bookshelf" + uuid "github.com/gofrs/uuid" +) + +const ( + defaultSessionID = "default" + // The following keys are used for the default session. For example: + // session, _ := bookshelf.SessionStore.New(r, defaultSessionID) + // session.Values[oauthTokenSessionKey] + googleProfileSessionKey = "google_profile" + oauthTokenSessionKey = "oauth_token" + + // This key is used in the OAuth flow session to store the URL to redirect the + // user to after the OAuth flow is complete. + oauthFlowRedirectKey = "redirect" +) + +func init() { + // Gob encoding for gorilla/sessions + gob.Register(&oauth2.Token{}) + gob.Register(&Profile{}) +} + +// loginHandler initiates an OAuth flow to authenticate the user. +func loginHandler(w http.ResponseWriter, r *http.Request) *appError { + sessionID := uuid.Must(uuid.NewV4()).String() + + oauthFlowSession, err := bookshelf.SessionStore.New(r, sessionID) + if err != nil { + return appErrorf(err, "could not create oauth session: %v", err) + } + oauthFlowSession.Options.MaxAge = 10 * 60 // 10 minutes + + redirectURL, err := validateRedirectURL(r.FormValue("redirect")) + if err != nil { + return appErrorf(err, "invalid redirect URL: %v", err) + } + oauthFlowSession.Values[oauthFlowRedirectKey] = redirectURL + + if err := oauthFlowSession.Save(r, w); err != nil { + return appErrorf(err, "could not save session: %v", err) + } + + // Use the session ID for the "state" parameter. + // This protects against CSRF (cross-site request forgery). + // See https://godoc.org/golang.org/x/oauth2#Config.AuthCodeURL for more detail. + url := bookshelf.OAuthConfig.AuthCodeURL(sessionID, oauth2.ApprovalForce, + oauth2.AccessTypeOnline) + http.Redirect(w, r, url, http.StatusFound) + return nil +} + +// validateRedirectURL checks that the URL provided is valid. +// If the URL is missing, redirect the user to the application's root. +// The URL must not be absolute (i.e., the URL must refer to a path within this +// application). +func validateRedirectURL(path string) (string, error) { + if path == "" { + return "/", nil + } + + // Ensure redirect URL is valid and not pointing to a different server. + parsedURL, err := url.Parse(path) + if err != nil { + return "/", err + } + if parsedURL.IsAbs() { + return "/", errors.New("URL must not be absolute") + } + return path, nil +} + +// oauthCallbackHandler completes the OAuth flow, retreives the user's profile +// information and stores it in a session. +func oauthCallbackHandler(w http.ResponseWriter, r *http.Request) *appError { + oauthFlowSession, err := bookshelf.SessionStore.Get(r, r.FormValue("state")) + if err != nil { + return appErrorf(err, "invalid state parameter. try logging in again.") + } + + redirectURL, ok := oauthFlowSession.Values[oauthFlowRedirectKey].(string) + // Validate this callback request came from the app. + if !ok { + return appErrorf(err, "invalid state parameter. try logging in again.") + } + + code := r.FormValue("code") + tok, err := bookshelf.OAuthConfig.Exchange(context.Background(), code) + if err != nil { + return appErrorf(err, "could not get auth token: %v", err) + } + + session, err := bookshelf.SessionStore.New(r, defaultSessionID) + if err != nil { + return appErrorf(err, "could not get default session: %v", err) + } + + ctx := context.Background() + profile, err := fetchProfile(ctx, tok) + if err != nil { + return appErrorf(err, "could not fetch Google profile: %v", err) + } + + session.Values[oauthTokenSessionKey] = tok + // Strip the profile to only the fields we need. Otherwise the struct is too big. + session.Values[googleProfileSessionKey] = stripProfile(profile) + if err := session.Save(r, w); err != nil { + return appErrorf(err, "could not save session: %v", err) + } + + http.Redirect(w, r, redirectURL, http.StatusFound) + return nil +} + +// fetchProfile retrieves the Google+ profile of the user associated with the +// provided OAuth token. +func fetchProfile(ctx context.Context, tok *oauth2.Token) (*plus.Person, error) { + client := oauth2.NewClient(ctx, bookshelf.OAuthConfig.TokenSource(ctx, tok)) + plusService, err := plus.New(client) + if err != nil { + return nil, err + } + return plusService.People.Get("me").Do() +} + +// logoutHandler clears the default session. +func logoutHandler(w http.ResponseWriter, r *http.Request) *appError { + session, err := bookshelf.SessionStore.New(r, defaultSessionID) + if err != nil { + return appErrorf(err, "could not get default session: %v", err) + } + session.Options.MaxAge = -1 // Clear session. + if err := session.Save(r, w); err != nil { + return appErrorf(err, "could not save session: %v", err) + } + redirectURL := r.FormValue("redirect") + if redirectURL == "" { + redirectURL = "/" + } + http.Redirect(w, r, redirectURL, http.StatusFound) + return nil +} + +// profileFromSession retreives the Google+ profile from the default session. +// Returns nil if the profile cannot be retreived (e.g. user is logged out). +func profileFromSession(r *http.Request) *Profile { + session, err := bookshelf.SessionStore.Get(r, defaultSessionID) + if err != nil { + return nil + } + tok, ok := session.Values[oauthTokenSessionKey].(*oauth2.Token) + if !ok || !tok.Valid() { + return nil + } + profile, ok := session.Values[googleProfileSessionKey].(*Profile) + if !ok { + return nil + } + return profile +} + +type Profile struct { + ID, DisplayName, ImageURL string +} + +// stripProfile returns a subset of a plus.Person. +func stripProfile(p *plus.Person) *Profile { + return &Profile{ + ID: p.Id, + DisplayName: p.DisplayName, + ImageURL: p.Image.Url, + } +} diff --git a/getting-started/bookshelf/app/index.yaml b/getting-started/bookshelf/app/index.yaml new file mode 100644 index 0000000000..96cf1d29bc --- /dev/null +++ b/getting-started/bookshelf/app/index.yaml @@ -0,0 +1,28 @@ +# Copyright 2019 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# To create these indexes, run: +# $ gcloud preview datastore create-indexes index.yaml +# +# It will take some time for the indices to be created. + +indexes: + +# This index enables filtering by "CreatedByID" and sort by "Title". +- kind: Book + properties: + - name: CreatedByID + direction: asc + - name: Title + direction: asc diff --git a/getting-started/bookshelf/template.go b/getting-started/bookshelf/app/template.go similarity index 70% rename from getting-started/bookshelf/template.go rename to getting-started/bookshelf/app/template.go index 99c02f3363..c861f3d07d 100644 --- a/getting-started/bookshelf/template.go +++ b/getting-started/bookshelf/app/template.go @@ -20,6 +20,8 @@ import ( "io/ioutil" "net/http" "path/filepath" + + "github.com/GoogleCloudPlatform/golang-samples/getting-started/bookshelf" ) // parseTemplate applies a given file to the body of the base template. @@ -37,17 +39,30 @@ func parseTemplate(filename string) *appTemplate { return &appTemplate{tmpl.Lookup("base.html")} } -// appTemplate is an appError-aware wrapper for a html/template. +// appTemplate is a user login-aware wrapper for a html/template. type appTemplate struct { t *template.Template } -// Execute writes the template using the provided data. +// Execute writes the template using the provided data, adding login and user +// information to the base template. func (tmpl *appTemplate) Execute(w http.ResponseWriter, r *http.Request, data interface{}) *appError { d := struct { - Data interface{} + Data interface{} + AuthEnabled bool + Profile *Profile + LoginURL string + LogoutURL string }{ - Data: data, + Data: data, + AuthEnabled: bookshelf.OAuthConfig != nil, + LoginURL: "/login?redirect=" + r.URL.RequestURI(), + LogoutURL: "/logout?redirect=" + r.URL.RequestURI(), + } + + if d.AuthEnabled { + // Ignore any errors. + d.Profile = profileFromSession(r) } if err := tmpl.t.Execute(w, d); err != nil { diff --git a/getting-started/bookshelf/templates/base.html b/getting-started/bookshelf/app/templates/base.html similarity index 62% rename from getting-started/bookshelf/templates/base.html rename to getting-started/bookshelf/app/templates/base.html index 813713e76e..3124ff381c 100644 --- a/getting-started/bookshelf/templates/base.html +++ b/getting-started/bookshelf/app/templates/base.html @@ -30,7 +30,30 @@ + + + {{if .AuthEnabled}} + {{if .Profile}} + + + {{else}} + + {{end}} + {{end}} +
diff --git a/getting-started/bookshelf/templates/detail.html b/getting-started/bookshelf/app/templates/detail.html similarity index 96% rename from getting-started/bookshelf/templates/detail.html rename to getting-started/bookshelf/app/templates/detail.html index b9b55f7ae3..d6af994147 100644 --- a/getting-started/bookshelf/templates/detail.html +++ b/getting-started/bookshelf/app/templates/detail.html @@ -36,5 +36,6 @@

Book

{{.Title}} {{.PublishedDate}}

By {{if .Author}}{{.Author}}{{else}}unknown{{end}}

{{.Description}}

+ Added by {{.CreatedByDisplayName}}
diff --git a/getting-started/bookshelf/templates/edit.html b/getting-started/bookshelf/app/templates/edit.html similarity index 92% rename from getting-started/bookshelf/templates/edit.html rename to getting-started/bookshelf/app/templates/edit.html index 4db36199f8..2449bf9a56 100644 --- a/getting-started/bookshelf/templates/edit.html +++ b/getting-started/bookshelf/app/templates/edit.html @@ -38,4 +38,6 @@

{{if .}}Edit{{else}}Add{{end}} book

+ + diff --git a/getting-started/bookshelf/templates/list.html b/getting-started/bookshelf/app/templates/list.html similarity index 100% rename from getting-started/bookshelf/templates/list.html rename to getting-started/bookshelf/app/templates/list.html diff --git a/getting-started/bookshelf/book.go b/getting-started/bookshelf/book.go new file mode 100644 index 0000000000..3c1b98a838 --- /dev/null +++ b/getting-started/bookshelf/book.go @@ -0,0 +1,68 @@ +// Copyright 2019 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bookshelf + +// Book holds metadata about a book. +type Book struct { + ID int64 + Title string + Author string + PublishedDate string + ImageURL string + Description string + CreatedBy string + CreatedByID string +} + +// CreatedByDisplayName returns a string appropriate for displaying the name of +// the user who created this book object. +func (b *Book) CreatedByDisplayName() string { + if b.CreatedByID == "anonymous" { + return "Anonymous" + } + return b.CreatedBy +} + +// SetCreatorAnonymous sets the CreatedByID field to the "anonymous" ID. +func (b *Book) SetCreatorAnonymous() { + b.CreatedBy = "" + b.CreatedByID = "anonymous" +} + +// BookDatabase provides thread-safe access to a database of books. +type BookDatabase interface { + // ListBooks returns a list of books, ordered by title. + ListBooks() ([]*Book, error) + + // ListBooksCreatedBy returns a list of books, ordered by title, filtered by + // the user who created the book entry. + ListBooksCreatedBy(userID string) ([]*Book, error) + + // GetBook retrieves a book by its ID. + GetBook(id int64) (*Book, error) + + // AddBook saves a given book, assigning it a new ID. + AddBook(b *Book) (id int64, err error) + + // DeleteBook removes a given book by its ID. + DeleteBook(id int64) error + + // UpdateBook updates the entry for a given book. + UpdateBook(b *Book) error + + // Close closes the database, freeing up any available resources. + // TODO(cbro): Close() should return an error. + Close() +} diff --git a/getting-started/bookshelf/book_test.go b/getting-started/bookshelf/book_test.go new file mode 100644 index 0000000000..3ef9dfbf65 --- /dev/null +++ b/getting-started/bookshelf/book_test.go @@ -0,0 +1,40 @@ +// Copyright 2019 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bookshelf + +import "testing" + +func TestCreatedByName(t *testing.T) { + b := &Book{ + CreatedByID: "homer", + CreatedBy: "Homer Simpson", + } + + if got, want := b.CreatedByDisplayName(), b.CreatedBy; got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestAnonymous(t *testing.T) { + b := &Book{ + CreatedByID: "homer", + CreatedBy: "Homer Simpson", + } + b.SetCreatorAnonymous() + + if got, want := b.CreatedByDisplayName(), "Anonymous"; got != want { + t.Errorf("got %q, want %q", got, want) + } +} diff --git a/getting-started/bookshelf/config.go b/getting-started/bookshelf/config.go index 3d4dc7deab..9c5e480a54 100644 --- a/getting-started/bookshelf/config.go +++ b/getting-started/bookshelf/config.go @@ -12,79 +12,201 @@ // See the License for the specific language governing permissions and // limitations under the License. -package main +package bookshelf import ( "context" - "fmt" - "io" + "errors" + "log" "os" + "cloud.google.com/go/datastore" + "cloud.google.com/go/pubsub" "cloud.google.com/go/storage" + + "gopkg.in/mgo.v2" + + "github.com/gorilla/sessions" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" ) -// Book holds metadata about a book. -type Book struct { - ID string - Title string - Author string - PublishedDate string - ImageURL string - Description string -} +var ( + DB BookDatabase + OAuthConfig *oauth2.Config -// BookDatabase provides thread-safe access to a database of books. -type BookDatabase interface { - // ListBooks returns a list of books, ordered by title. - ListBooks(context.Context) ([]*Book, error) + StorageBucket *storage.BucketHandle + StorageBucketName string - // GetBook retrieves a book by its ID. - GetBook(ctx context.Context, id string) (*Book, error) + SessionStore sessions.Store - // AddBook saves a given book, assigning it a new ID. - AddBook(ctx context.Context, b *Book) (id string, err error) + PubsubClient *pubsub.Client - // DeleteBook removes a given book by its ID. - DeleteBook(ctx context.Context, id string) error + // Force import of mgo library. + _ mgo.Session +) - // UpdateBook updates the entry for a given book. - UpdateBook(ctx context.Context, b *Book) error +const PubsubTopicID = "fill-book-details" + +func init() { + var err error + + // To use the in-memory test database, uncomment the next line. + DB = newMemoryDB() + + // [START cloudsql] + // To use Cloud SQL, uncomment the following lines, and update the username, + // password and instance connection string. When running locally, + // localhost:3306 is used, and the instance name is ignored. + // DB, err = configureCloudSQL(cloudSQLConfig{ + // Username: "root", + // Password: "", + // // The connection name of the Cloud SQL v2 instance, i.e., + // // "project:region:instance-id" + // // Cloud SQL v1 instances are not supported. + // Instance: "", + // }) + // [END cloudsql] + + // [START mongo] + // To use Mongo, uncomment the next lines and update the address string and + // optionally, the credentials. + // + // var cred *mgo.Credential + // DB, err = newMongoDB("localhost", cred) + // [END mongo] + + // [START datastore] + // To use Cloud Datastore, uncomment the following lines and update the + // project ID. + // More options can be set, see the google package docs for details: + // http://godoc.org/golang.org/x/oauth2/google + // + // DB, err = configureDatastoreDB("") + // [END datastore] - // Close closes the database, freeing up any available resources. - Close(ctx context.Context) error -} + if err != nil { + log.Fatal(err) + } -// Bookshelf holds a BookDatabase and storage info. -type Bookshelf struct { - DB BookDatabase + // [START storage] + // To configure Cloud Storage, uncomment the following lines and update the + // bucket name. + // + // StorageBucketName = "" + // StorageBucket, err = configureStorage(StorageBucketName) + // [END storage] - StorageBucket *storage.BucketHandle - StorageBucketName string + if err != nil { + log.Fatal(err) + } + + // [START auth] + // To enable user sign-in, uncomment the following lines and update the + // Client ID and Client Secret. + // You will also need to update OAUTH2_CALLBACK in app.yaml when pushing to + // production. + // + // OAuthConfig = configureOAuthClient("clientid", "clientsecret") + // [END auth] + + // [START sessions] + // Configure storage method for session-wide information. + // Update "something-very-secret" with a hard to guess string or byte sequence. + cookieStore := sessions.NewCookieStore([]byte("something-very-secret")) + cookieStore.Options = &sessions.Options{ + HttpOnly: true, + } + SessionStore = cookieStore + // [END sessions] + + // [START pubsub] + // To configure Pub/Sub, uncomment the following lines and update the project ID. + // + // PubsubClient, err = configurePubsub("") + // [END pubsub] + + if err != nil { + log.Fatal(err) + } +} - // logWriter is used for request logging and can be overridden for tests. - logWriter io.Writer +func configureDatastoreDB(projectID string) (BookDatabase, error) { + ctx := context.Background() + client, err := datastore.NewClient(ctx, projectID) + if err != nil { + return nil, err + } + return newDatastoreDB(client) } -// NewBookshelf creates a new Bookshelf. -func NewBookshelf(projectID string, db BookDatabase) (*Bookshelf, error) { +func configureStorage(bucketID string) (*storage.BucketHandle, error) { ctx := context.Background() + client, err := storage.NewClient(ctx) + if err != nil { + return nil, err + } + return client.Bucket(bucketID), nil +} - // This Cloud Storage bucket must exist to be able to upload book pictures. - // You can create it and make it public by running: - // gsutil mb my-project_bucket - // gsutil defacl set public-read gs://my-project_bucket - // replacing my-project with your project ID. - bucketName := projectID + "_bucket" - storageClient, err := storage.NewClient(ctx) +func configurePubsub(projectID string) (*pubsub.Client, error) { + if _, ok := DB.(*memoryDB); ok { + return nil, errors.New("Pub/Sub worker doesn't work with the in-memory DB " + + "(worker does not share its memory as the main app). Configure another " + + "database in bookshelf/config.go first (e.g. MySQL, Cloud Datastore, etc)") + } + + ctx := context.Background() + client, err := pubsub.NewClient(ctx, projectID) if err != nil { - return nil, fmt.Errorf("storage.NewClient: %v", err) + return nil, err } - b := &Bookshelf{ - logWriter: os.Stderr, - DB: db, - StorageBucketName: bucketName, - StorageBucket: storageClient.Bucket(bucketName), + // Create the topic if it doesn't exist. + if exists, err := client.Topic(PubsubTopicID).Exists(ctx); err != nil { + return nil, err + } else if !exists { + if _, err := client.CreateTopic(ctx, PubsubTopicID); err != nil { + return nil, err + } } - return b, nil + return client, nil +} + +func configureOAuthClient(clientID, clientSecret string) *oauth2.Config { + redirectURL := os.Getenv("OAUTH2_CALLBACK") + if redirectURL == "" { + redirectURL = "http://localhost:8080/oauth2callback" + } + return &oauth2.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + RedirectURL: redirectURL, + Scopes: []string{"email", "profile"}, + Endpoint: google.Endpoint, + } +} + +type cloudSQLConfig struct { + Username, Password, Instance string +} + +func configureCloudSQL(config cloudSQLConfig) (BookDatabase, error) { + if os.Getenv("GAE_INSTANCE") != "" { + // Running in production. + return newMySQLDB(MySQLConfig{ + Username: config.Username, + Password: config.Password, + UnixSocket: "/cloudsql/" + config.Instance, + }) + } + + // Running locally. + return newMySQLDB(MySQLConfig{ + Username: config.Username, + Password: config.Password, + Host: "localhost", + Port: 3306, + }) } diff --git a/getting-started/bookshelf/db_datastore.go b/getting-started/bookshelf/db_datastore.go new file mode 100644 index 0000000000..53e1f4eaee --- /dev/null +++ b/getting-started/bookshelf/db_datastore.go @@ -0,0 +1,147 @@ +// Copyright 2019 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bookshelf + +import ( + "context" + "fmt" + + "cloud.google.com/go/datastore" +) + +// datastoreDB persists books to Cloud Datastore. +// https://cloud.google.com/datastore/docs/concepts/overview +type datastoreDB struct { + client *datastore.Client +} + +// Ensure datastoreDB conforms to the BookDatabase interface. +var _ BookDatabase = &datastoreDB{} + +// newDatastoreDB creates a new BookDatabase backed by Cloud Datastore. +// See the datastore and google packages for details on creating a suitable Client: +// https://godoc.org/cloud.google.com/go/datastore +func newDatastoreDB(client *datastore.Client) (BookDatabase, error) { + ctx := context.Background() + // Verify that we can communicate and authenticate with the datastore service. + t, err := client.NewTransaction(ctx) + if err != nil { + return nil, fmt.Errorf("datastoredb: could not connect: %v", err) + } + if err := t.Rollback(); err != nil { + return nil, fmt.Errorf("datastoredb: could not connect: %v", err) + } + return &datastoreDB{ + client: client, + }, nil +} + +// Close closes the database. +func (db *datastoreDB) Close() { + // No op. +} + +func (db *datastoreDB) datastoreKey(id int64) *datastore.Key { + return datastore.IDKey("Book", id, nil) +} + +// GetBook retrieves a book by its ID. +func (db *datastoreDB) GetBook(id int64) (*Book, error) { + ctx := context.Background() + k := db.datastoreKey(id) + book := &Book{} + if err := db.client.Get(ctx, k, book); err != nil { + return nil, fmt.Errorf("datastoredb: could not get Book: %v", err) + } + book.ID = id + return book, nil +} + +// AddBook saves a given book, assigning it a new ID. +func (db *datastoreDB) AddBook(b *Book) (id int64, err error) { + ctx := context.Background() + k := datastore.IncompleteKey("Book", nil) + k, err = db.client.Put(ctx, k, b) + if err != nil { + return 0, fmt.Errorf("datastoredb: could not put Book: %v", err) + } + return k.ID, nil +} + +// DeleteBook removes a given book by its ID. +func (db *datastoreDB) DeleteBook(id int64) error { + ctx := context.Background() + k := db.datastoreKey(id) + if err := db.client.Delete(ctx, k); err != nil { + return fmt.Errorf("datastoredb: could not delete Book: %v", err) + } + return nil +} + +// UpdateBook updates the entry for a given book. +func (db *datastoreDB) UpdateBook(b *Book) error { + ctx := context.Background() + k := db.datastoreKey(b.ID) + if _, err := db.client.Put(ctx, k, b); err != nil { + return fmt.Errorf("datastoredb: could not update Book: %v", err) + } + return nil +} + +// ListBooks returns a list of books, ordered by title. +func (db *datastoreDB) ListBooks() ([]*Book, error) { + ctx := context.Background() + books := make([]*Book, 0) + q := datastore.NewQuery("Book"). + Order("Title") + + keys, err := db.client.GetAll(ctx, q, &books) + + if err != nil { + return nil, fmt.Errorf("datastoredb: could not list books: %v", err) + } + + for i, k := range keys { + books[i].ID = k.ID + } + + return books, nil +} + +// ListBooksCreatedBy returns a list of books, ordered by title, filtered by +// the user who created the book entry. +func (db *datastoreDB) ListBooksCreatedBy(userID string) ([]*Book, error) { + ctx := context.Background() + if userID == "" { + return db.ListBooks() + } + + books := make([]*Book, 0) + q := datastore.NewQuery("Book"). + Filter("CreatedByID =", userID). + Order("Title") + + keys, err := db.client.GetAll(ctx, q, &books) + + if err != nil { + return nil, fmt.Errorf("datastoredb: could not list books: %v", err) + } + + for i, k := range keys { + books[i].ID = k.ID + } + + return books, nil +} diff --git a/getting-started/bookshelf/db_firestore.go b/getting-started/bookshelf/db_firestore.go deleted file mode 100644 index 6bc65e11db..0000000000 --- a/getting-started/bookshelf/db_firestore.go +++ /dev/null @@ -1,115 +0,0 @@ -// Copyright 2019 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// https://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package main - -import ( - "context" - "fmt" - "log" - - "cloud.google.com/go/firestore" - "google.golang.org/api/iterator" -) - -// firestoreDB persists books to Cloud Firestore. -// See https://cloud.google.com/firestore/docs. -type firestoreDB struct { - client *firestore.Client -} - -// Ensure firestoreDB conforms to the BookDatabase interface. -var _ BookDatabase = &firestoreDB{} - -// newFirestoreDB creates a new BookDatabase backed by Cloud Firestore. -// See the firestore and google packages for details on creating a suitable -// firestore.Client: https://godoc.org/cloud.google.com/go/firestore. -func newFirestoreDB(client *firestore.Client) (*firestoreDB, error) { - ctx := context.Background() - // Verify that we can communicate and authenticate with the Firestore - // service. - err := client.RunTransaction(ctx, func(ctx context.Context, t *firestore.Transaction) error { - return nil - }) - if err != nil { - return nil, fmt.Errorf("firestoredb: could not connect: %v", err) - } - return &firestoreDB{ - client: client, - }, nil -} - -// Close closes the database. -func (db *firestoreDB) Close(context.Context) error { - return db.client.Close() -} - -// GetBook retrieves a book by its ID. -func (db *firestoreDB) GetBook(ctx context.Context, id string) (*Book, error) { - ds, err := db.client.Collection("books").Doc(id).Get(ctx) - if err != nil { - return nil, fmt.Errorf("firestoredb: Get: %v", err) - } - b := &Book{} - ds.DataTo(b) - return b, nil -} - -// AddBook saves a given book, assigning it a new ID. -func (db *firestoreDB) AddBook(ctx context.Context, b *Book) (id string, err error) { - ref := db.client.Collection("books").NewDoc() - b.ID = ref.ID - if _, err := ref.Create(ctx, b); err != nil { - return "", fmt.Errorf("Create: %v", err) - } - return ref.ID, nil -} - -// DeleteBook removes a given book by its ID. -func (db *firestoreDB) DeleteBook(ctx context.Context, id string) error { - if _, err := db.client.Collection("books").Doc(id).Delete(ctx); err != nil { - return fmt.Errorf("firestore: Delete: %v", err) - } - return nil -} - -// UpdateBook updates the entry for a given book. -func (db *firestoreDB) UpdateBook(ctx context.Context, b *Book) error { - if _, err := db.client.Collection("books").Doc(b.ID).Set(ctx, b); err != nil { - return fmt.Errorf("firestsore: Set: %v", err) - } - return nil -} - -// ListBooks returns a list of books, ordered by title. -func (db *firestoreDB) ListBooks(ctx context.Context) ([]*Book, error) { - books := make([]*Book, 0) - iter := db.client.Collection("books").Query.OrderBy("Title", firestore.Asc).Documents(ctx) - defer iter.Stop() - for { - doc, err := iter.Next() - if err == iterator.Done { - break - } - if err != nil { - return nil, fmt.Errorf("firestoredb: could not list books: %v", err) - } - b := &Book{} - doc.DataTo(b) - log.Printf("Book %q ID: %q", b.Title, b.ID) - books = append(books, b) - } - - return books, nil -} diff --git a/getting-started/bookshelf/db_memory.go b/getting-started/bookshelf/db_memory.go index 17fdb398bc..4cd21be487 100644 --- a/getting-started/bookshelf/db_memory.go +++ b/getting-started/bookshelf/db_memory.go @@ -12,61 +12,58 @@ // See the License for the specific language governing permissions and // limitations under the License. -package main +package bookshelf import ( - "context" "errors" "fmt" "sort" - "strconv" "sync" ) +// Ensure memoryDB conforms to the BookDatabase interface. var _ BookDatabase = &memoryDB{} // memoryDB is a simple in-memory persistence layer for books. type memoryDB struct { mu sync.Mutex - nextID int64 // next ID to assign to a book. - books map[string]*Book // maps from Book ID to Book. + nextID int64 // next ID to assign to a book. + books map[int64]*Book // maps from Book ID to Book. } func newMemoryDB() *memoryDB { return &memoryDB{ - books: make(map[string]*Book), + books: make(map[int64]*Book), nextID: 1, } } // Close closes the database. -func (db *memoryDB) Close(context.Context) error { +func (db *memoryDB) Close() { db.mu.Lock() defer db.mu.Unlock() db.books = nil - - return nil } // GetBook retrieves a book by its ID. -func (db *memoryDB) GetBook(_ context.Context, id string) (*Book, error) { +func (db *memoryDB) GetBook(id int64) (*Book, error) { db.mu.Lock() defer db.mu.Unlock() book, ok := db.books[id] if !ok { - return nil, fmt.Errorf("memorydb: book not found with ID %q", id) + return nil, fmt.Errorf("memorydb: book not found with ID %d", id) } return book, nil } // AddBook saves a given book, assigning it a new ID. -func (db *memoryDB) AddBook(_ context.Context, b *Book) (id string, err error) { +func (db *memoryDB) AddBook(b *Book) (id int64, err error) { db.mu.Lock() defer db.mu.Unlock() - b.ID = strconv.FormatInt(db.nextID, 10) + b.ID = db.nextID db.books[b.ID] = b db.nextID++ @@ -75,25 +72,25 @@ func (db *memoryDB) AddBook(_ context.Context, b *Book) (id string, err error) { } // DeleteBook removes a given book by its ID. -func (db *memoryDB) DeleteBook(_ context.Context, id string) error { - if id == "" { - return errors.New("memorydb: book with unassigned ID passed into DeleteBook") +func (db *memoryDB) DeleteBook(id int64) error { + if id == 0 { + return errors.New("memorydb: book with unassigned ID passed into deleteBook") } db.mu.Lock() defer db.mu.Unlock() if _, ok := db.books[id]; !ok { - return fmt.Errorf("memorydb: could not delete book with ID %q, does not exist", id) + return fmt.Errorf("memorydb: could not delete book with ID %d, does not exist", id) } delete(db.books, id) return nil } // UpdateBook updates the entry for a given book. -func (db *memoryDB) UpdateBook(_ context.Context, b *Book) error { - if b.ID == "" { - return errors.New("memorydb: book with unassigned ID passed into UpdateBook") +func (db *memoryDB) UpdateBook(b *Book) error { + if b.ID == 0 { + return errors.New("memorydb: book with unassigned ID passed into updateBook") } db.mu.Lock() @@ -103,8 +100,16 @@ func (db *memoryDB) UpdateBook(_ context.Context, b *Book) error { return nil } +// booksByTitle implements sort.Interface, ordering books by Title. +// https://golang.org/pkg/sort/#example__sortWrapper +type booksByTitle []*Book + +func (s booksByTitle) Less(i, j int) bool { return s[i].Title < s[j].Title } +func (s booksByTitle) Len() int { return len(s) } +func (s booksByTitle) Swap(i, j int) { s[i], s[j] = s[j], s[i] } + // ListBooks returns a list of books, ordered by title. -func (db *memoryDB) ListBooks(_ context.Context) ([]*Book, error) { +func (db *memoryDB) ListBooks() ([]*Book, error) { db.mu.Lock() defer db.mu.Unlock() @@ -113,8 +118,27 @@ func (db *memoryDB) ListBooks(_ context.Context) ([]*Book, error) { books = append(books, b) } - sort.Slice(books, func(i, j int) bool { - return books[i].Title < books[j].Title - }) + sort.Sort(booksByTitle(books)) + return books, nil +} + +// ListBooksCreatedBy returns a list of books, ordered by title, filtered by +// the user who created the book entry. +func (db *memoryDB) ListBooksCreatedBy(userID string) ([]*Book, error) { + if userID == "" { + return db.ListBooks() + } + + db.mu.Lock() + defer db.mu.Unlock() + + var books []*Book + for _, b := range db.books { + if b.CreatedByID == userID { + books = append(books, b) + } + } + + sort.Sort(booksByTitle(books)) return books, nil } diff --git a/getting-started/bookshelf/db_mongo.go b/getting-started/bookshelf/db_mongo.go new file mode 100644 index 0000000000..6c1e12297c --- /dev/null +++ b/getting-started/bookshelf/db_mongo.go @@ -0,0 +1,122 @@ +// Copyright 2019 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bookshelf + +import ( + "crypto/rand" + "fmt" + "math/big" + + "gopkg.in/mgo.v2" + "gopkg.in/mgo.v2/bson" +) + +type mongoDB struct { + conn *mgo.Session + c *mgo.Collection +} + +// Ensure mongoDB conforms to the BookDatabase interface. +var _ BookDatabase = &mongoDB{} + +// newMongoDB creates a new BookDatabase backed by a given Mongo server, +// authenticated with given credentials. +func newMongoDB(addr string, cred *mgo.Credential) (BookDatabase, error) { + conn, err := mgo.Dial(addr) + if err != nil { + return nil, fmt.Errorf("mongo: could not dial: %v", err) + } + + if cred != nil { + if err := conn.Login(cred); err != nil { + return nil, err + } + } + + return &mongoDB{ + conn: conn, + c: conn.DB("bookshelf").C("books"), + }, nil +} + +// Close closes the database. +func (db *mongoDB) Close() { + db.conn.Close() +} + +// GetBook retrieves a book by its ID. +func (db *mongoDB) GetBook(id int64) (*Book, error) { + b := &Book{} + if err := db.c.Find(bson.D{{Name: "id", Value: id}}).One(b); err != nil { + return nil, err + } + return b, nil +} + +var maxRand = big.NewInt(1<<63 - 1) + +// randomID returns a positive number that fits within an int64. +func randomID() (int64, error) { + // Get a random number within the range [0, 1<<63-1) + n, err := rand.Int(rand.Reader, maxRand) + if err != nil { + return 0, err + } + // Don't assign 0. + return n.Int64() + 1, nil +} + +// AddBook saves a given book, assigning it a new ID. +func (db *mongoDB) AddBook(b *Book) (id int64, err error) { + id, err = randomID() + if err != nil { + return 0, fmt.Errorf("mongodb: could not assign a new ID: %v", err) + } + + b.ID = id + if err := db.c.Insert(b); err != nil { + return 0, fmt.Errorf("mongodb: could not add book: %v", err) + } + return id, nil +} + +// DeleteBook removes a given book by its ID. +func (db *mongoDB) DeleteBook(id int64) error { + return db.c.Remove(bson.D{{Name: "id", Value: id}}) +} + +// UpdateBook updates the entry for a given book. +func (db *mongoDB) UpdateBook(b *Book) error { + return db.c.Update(bson.D{{Name: "id", Value: b.ID}}, b) +} + +// ListBooks returns a list of books, ordered by title. +func (db *mongoDB) ListBooks() ([]*Book, error) { + var result []*Book + if err := db.c.Find(nil).Sort("title").All(&result); err != nil { + return nil, err + } + return result, nil +} + +// ListBooksCreatedBy returns a list of books, ordered by title, filtered by +// the user who created the book entry. +func (db *mongoDB) ListBooksCreatedBy(userID string) ([]*Book, error) { + var result []*Book + if err := db.c.Find(bson.D{{Name: "createdbyid", Value: userID}}).Sort("title").All(&result); err != nil { + return nil, err + } + return result, nil +} diff --git a/getting-started/bookshelf/db_mysql.go b/getting-started/bookshelf/db_mysql.go new file mode 100644 index 0000000000..4d3d08d6ea --- /dev/null +++ b/getting-started/bookshelf/db_mysql.go @@ -0,0 +1,350 @@ +// Copyright 2019 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bookshelf + +import ( + "database/sql" + "database/sql/driver" + "errors" + "fmt" + + "github.com/go-sql-driver/mysql" +) + +var createTableStatements = []string{ + `CREATE DATABASE IF NOT EXISTS library DEFAULT CHARACTER SET = 'utf8' DEFAULT COLLATE 'utf8_general_ci';`, + `USE library;`, + `CREATE TABLE IF NOT EXISTS books ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT, + title VARCHAR(255) NULL, + author VARCHAR(255) NULL, + publishedDate VARCHAR(255) NULL, + imageUrl VARCHAR(255) NULL, + description TEXT NULL, + createdBy VARCHAR(255) NULL, + createdById VARCHAR(255) NULL, + PRIMARY KEY (id) + )`, +} + +// mysqlDB persists books to a MySQL instance. +type mysqlDB struct { + conn *sql.DB + + list *sql.Stmt + listBy *sql.Stmt + insert *sql.Stmt + get *sql.Stmt + update *sql.Stmt + delete *sql.Stmt +} + +// Ensure mysqlDB conforms to the BookDatabase interface. +var _ BookDatabase = &mysqlDB{} + +type MySQLConfig struct { + // Optional. + Username, Password string + + // Host of the MySQL instance. + // + // If set, UnixSocket should be unset. + Host string + + // Port of the MySQL instance. + // + // If set, UnixSocket should be unset. + Port int + + // UnixSocket is the filepath to a unix socket. + // + // If set, Host and Port should be unset. + UnixSocket string +} + +// dataStoreName returns a connection string suitable for sql.Open. +func (c MySQLConfig) dataStoreName(databaseName string) string { + var cred string + // [username[:password]@] + if c.Username != "" { + cred = c.Username + if c.Password != "" { + cred = cred + ":" + c.Password + } + cred = cred + "@" + } + + if c.UnixSocket != "" { + return fmt.Sprintf("%sunix(%s)/%s", cred, c.UnixSocket, databaseName) + } + return fmt.Sprintf("%stcp([%s]:%d)/%s", cred, c.Host, c.Port, databaseName) +} + +// newMySQLDB creates a new BookDatabase backed by a given MySQL server. +func newMySQLDB(config MySQLConfig) (BookDatabase, error) { + // Check database and table exists. If not, create it. + if err := config.ensureTableExists(); err != nil { + return nil, err + } + + conn, err := sql.Open("mysql", config.dataStoreName("library")) + if err != nil { + return nil, fmt.Errorf("mysql: could not get a connection: %v", err) + } + if err := conn.Ping(); err != nil { + conn.Close() + return nil, fmt.Errorf("mysql: could not establish a good connection: %v", err) + } + + db := &mysqlDB{ + conn: conn, + } + + // Prepared statements. The actual SQL queries are in the code near the + // relevant method (e.g. addBook). + if db.list, err = conn.Prepare(listStatement); err != nil { + return nil, fmt.Errorf("mysql: prepare list: %v", err) + } + if db.listBy, err = conn.Prepare(listByStatement); err != nil { + return nil, fmt.Errorf("mysql: prepare listBy: %v", err) + } + if db.get, err = conn.Prepare(getStatement); err != nil { + return nil, fmt.Errorf("mysql: prepare get: %v", err) + } + if db.insert, err = conn.Prepare(insertStatement); err != nil { + return nil, fmt.Errorf("mysql: prepare insert: %v", err) + } + if db.update, err = conn.Prepare(updateStatement); err != nil { + return nil, fmt.Errorf("mysql: prepare update: %v", err) + } + if db.delete, err = conn.Prepare(deleteStatement); err != nil { + return nil, fmt.Errorf("mysql: prepare delete: %v", err) + } + + return db, nil +} + +// Close closes the database, freeing up any resources. +func (db *mysqlDB) Close() { + db.conn.Close() +} + +// rowScanner is implemented by sql.Row and sql.Rows +type rowScanner interface { + Scan(dest ...interface{}) error +} + +// scanBook reads a book from a sql.Row or sql.Rows +func scanBook(s rowScanner) (*Book, error) { + var ( + id int64 + title sql.NullString + author sql.NullString + publishedDate sql.NullString + imageURL sql.NullString + description sql.NullString + createdBy sql.NullString + createdByID sql.NullString + ) + if err := s.Scan(&id, &title, &author, &publishedDate, &imageURL, + &description, &createdBy, &createdByID); err != nil { + return nil, err + } + + book := &Book{ + ID: id, + Title: title.String, + Author: author.String, + PublishedDate: publishedDate.String, + ImageURL: imageURL.String, + Description: description.String, + CreatedBy: createdBy.String, + CreatedByID: createdByID.String, + } + return book, nil +} + +const listStatement = `SELECT * FROM books ORDER BY title` + +// ListBooks returns a list of books, ordered by title. +func (db *mysqlDB) ListBooks() ([]*Book, error) { + rows, err := db.list.Query() + if err != nil { + return nil, err + } + defer rows.Close() + + var books []*Book + for rows.Next() { + book, err := scanBook(rows) + if err != nil { + return nil, fmt.Errorf("mysql: could not read row: %v", err) + } + + books = append(books, book) + } + + return books, nil +} + +const listByStatement = ` + SELECT * FROM books + WHERE createdById = ? ORDER BY title` + +// ListBooksCreatedBy returns a list of books, ordered by title, filtered by +// the user who created the book entry. +func (db *mysqlDB) ListBooksCreatedBy(userID string) ([]*Book, error) { + if userID == "" { + return db.ListBooks() + } + + rows, err := db.listBy.Query(userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var books []*Book + for rows.Next() { + book, err := scanBook(rows) + if err != nil { + return nil, fmt.Errorf("mysql: could not read row: %v", err) + } + + books = append(books, book) + } + + return books, nil +} + +const getStatement = "SELECT * FROM books WHERE id = ?" + +// GetBook retrieves a book by its ID. +func (db *mysqlDB) GetBook(id int64) (*Book, error) { + book, err := scanBook(db.get.QueryRow(id)) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("mysql: could not find book with id %d", id) + } + if err != nil { + return nil, fmt.Errorf("mysql: could not get book: %v", err) + } + return book, nil +} + +const insertStatement = ` + INSERT INTO books ( + title, author, publishedDate, imageUrl, description, createdBy, createdById + ) VALUES (?, ?, ?, ?, ?, ?, ?)` + +// AddBook saves a given book, assigning it a new ID. +func (db *mysqlDB) AddBook(b *Book) (id int64, err error) { + r, err := execAffectingOneRow(db.insert, b.Title, b.Author, b.PublishedDate, + b.ImageURL, b.Description, b.CreatedBy, b.CreatedByID) + if err != nil { + return 0, err + } + + lastInsertID, err := r.LastInsertId() + if err != nil { + return 0, fmt.Errorf("mysql: could not get last insert ID: %v", err) + } + return lastInsertID, nil +} + +const deleteStatement = `DELETE FROM books WHERE id = ?` + +// DeleteBook removes a given book by its ID. +func (db *mysqlDB) DeleteBook(id int64) error { + if id == 0 { + return errors.New("mysql: book with unassigned ID passed into deleteBook") + } + _, err := execAffectingOneRow(db.delete, id) + return err +} + +const updateStatement = ` + UPDATE books + SET title=?, author=?, publishedDate=?, imageUrl=?, description=?, + createdBy=?, createdById=? + WHERE id = ?` + +// UpdateBook updates the entry for a given book. +func (db *mysqlDB) UpdateBook(b *Book) error { + if b.ID == 0 { + return errors.New("mysql: book with unassigned ID passed into updateBook") + } + + _, err := execAffectingOneRow(db.update, b.Title, b.Author, b.PublishedDate, + b.ImageURL, b.Description, b.CreatedBy, b.CreatedByID, b.ID) + return err +} + +// ensureTableExists checks the table exists. If not, it creates it. +func (config MySQLConfig) ensureTableExists() error { + conn, err := sql.Open("mysql", config.dataStoreName("")) + if err != nil { + return fmt.Errorf("mysql: could not get a connection: %v", err) + } + defer conn.Close() + + // Check the connection. + if conn.Ping() == driver.ErrBadConn { + return fmt.Errorf("mysql: could not connect to the database. " + + "could be bad address, or this address is not whitelisted for access.") + } + + if _, err := conn.Exec("USE library"); err != nil { + // MySQL error 1049 is "database does not exist" + if mErr, ok := err.(*mysql.MySQLError); ok && mErr.Number == 1049 { + return createTable(conn) + } + } + + if _, err := conn.Exec("DESCRIBE books"); err != nil { + // MySQL error 1146 is "table does not exist" + if mErr, ok := err.(*mysql.MySQLError); ok && mErr.Number == 1146 { + return createTable(conn) + } + // Unknown error. + return fmt.Errorf("mysql: could not connect to the database: %v", err) + } + return nil +} + +// createTable creates the table, and if necessary, the database. +func createTable(conn *sql.DB) error { + for _, stmt := range createTableStatements { + _, err := conn.Exec(stmt) + if err != nil { + return err + } + } + return nil +} + +// execAffectingOneRow executes a given statement, expecting one row to be affected. +func execAffectingOneRow(stmt *sql.Stmt, args ...interface{}) (sql.Result, error) { + r, err := stmt.Exec(args...) + if err != nil { + return r, fmt.Errorf("mysql: could not execute statement: %v", err) + } + rowsAffected, err := r.RowsAffected() + if err != nil { + return r, fmt.Errorf("mysql: could not get rows affected: %v", err) + } else if rowsAffected != 1 { + return r, fmt.Errorf("mysql: expected 1 row affected, got %d", rowsAffected) + } + return r, nil +} diff --git a/getting-started/bookshelf/db_test.go b/getting-started/bookshelf/db_test.go index efdfb890ad..ae2f446b75 100644 --- a/getting-started/bookshelf/db_test.go +++ b/getting-started/bookshelf/db_test.go @@ -12,24 +12,23 @@ // See the License for the specific language governing permissions and // limitations under the License. -package main +package bookshelf import ( "context" "fmt" "os" + "strconv" "testing" "time" - "cloud.google.com/go/firestore" + "cloud.google.com/go/datastore" + + "github.com/GoogleCloudPlatform/golang-samples/internal/testutil" ) func testDB(t *testing.T, db BookDatabase) { - t.Helper() - - ctx := context.Background() - - defer db.Close(ctx) + defer db.Close() b := &Book{ Author: "testy mc testface", @@ -37,18 +36,18 @@ func testDB(t *testing.T, db BookDatabase) { Description: "desc", } - id, err := db.AddBook(ctx, b) + id, err := db.AddBook(b) if err != nil { t.Fatal(err) } b.ID = id b.Description = "newdesc" - if err := db.UpdateBook(ctx, b); err != nil { + if err := db.UpdateBook(b); err != nil { t.Error(err) } - gotBook, err := db.GetBook(ctx, id) + gotBook, err := db.GetBook(id) if err != nil { t.Error(err) } @@ -56,11 +55,11 @@ func testDB(t *testing.T, db BookDatabase) { t.Errorf("Update description: got %q, want %q", got, want) } - if err := db.DeleteBook(ctx, id); err != nil { + if err := db.DeleteBook(id); err != nil { t.Error(err) } - if _, err := db.GetBook(ctx, id); err == nil { + if _, err := db.GetBook(id); err == nil { t.Error("want non-nil err") } } @@ -69,22 +68,48 @@ func TestMemoryDB(t *testing.T) { testDB(t, newMemoryDB()) } -func TestFirestoreDB(t *testing.T) { - projectID := os.Getenv("GOLANG_SAMPLES_FIRESTORE_PROJECT") - if projectID == "" { - t.Skip("GOLANG_SAMPLES_FIRESTORE_PROJECT not set") - } +func TestDatastoreDB(t *testing.T) { + tc := testutil.SystemTest(t) ctx := context.Background() - client, err := firestore.NewClient(ctx, projectID) + + client, err := datastore.NewClient(ctx, tc.ProjectID) if err != nil { - t.Fatalf("firestore.NewClient: %v", err) + t.Fatal(err) } defer client.Close() - db, err := newFirestoreDB(client) + db, err := newDatastoreDB(client) if err != nil { - t.Fatalf("newFirestoreDB: %v", err) + t.Fatal(err) } + testDB(t, db) +} + +func TestMySQLDB(t *testing.T) { + t.Parallel() + host := os.Getenv("GOLANG_SAMPLES_MYSQL_HOST") + port := os.Getenv("GOLANG_SAMPLES_MYSQL_PORT") + + if host == "" { + t.Skip("GOLANG_SAMPLES_MYSQL_HOST not set.") + } + if port == "" { + port = "3306" + } + + p, err := strconv.Atoi(port) + if err != nil { + t.Fatalf("Could not parse port: %v", err) + } + + db, err := newMySQLDB(MySQLConfig{ + Username: "root", + Host: host, + Port: p, + }) + if err != nil { + t.Fatal(err) + } testDB(t, db) } diff --git a/getting-started/bookshelf/doc.go b/getting-started/bookshelf/doc.go new file mode 100644 index 0000000000..bd943a73f6 --- /dev/null +++ b/getting-started/bookshelf/doc.go @@ -0,0 +1,16 @@ +// Copyright 2019 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package bookshelf contains the bookshelf database and app configuration, shared by the main app module and the worker module. +package bookshelf diff --git a/getting-started/bookshelf/gce_deployment/deploy-binary.sh b/getting-started/bookshelf/gce_deployment/deploy-binary.sh new file mode 100755 index 0000000000..d9c5694630 --- /dev/null +++ b/getting-started/bookshelf/gce_deployment/deploy-binary.sh @@ -0,0 +1,49 @@ +#! /bin/bash + +# Copyright 2019 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -ex + +if [ ! $(dirname $0) = "." ]; then + echo "Must run $(basename $0) from the gce_deployment directory." + exit 1 +fi + +if [ -z "$BOOKSHELF_DEPLOY_LOCATION" ]; then + echo "Must set \$BOOKSHELF_DEPLOY_LOCATION. For example: BOOKSHELF_DEPLOY_LOCATION=gs://my-bucket/bookshelf-VERSION.tar" + exit 1 +fi + +TMP=$(mktemp -d -t gce-deploy-XXXXXX) + +# [START cross_compile] +# Cross compile the app for linux/amd64 +GOOS=linux GOARCH=amd64 go build -v -o $TMP/app ../app +# [END cross_compile] + +# [START tar] +# Add the app binary +tar -c -f $TMP/bundle.tar -C $TMP app + +# Add static files. +tar -u -f $TMP/bundle.tar -C ../app templates +# [END tar] + +# [START gcs_push] +# BOOKSHELF_DEPLOY_LOCATION is something like "gs://my-bucket/bookshelf-VERSION.tar". +gsutil cp $TMP/bundle.tar $BOOKSHELF_DEPLOY_LOCATION +# [END gcs_push] + +rm -rf $TMP diff --git a/getting-started/bookshelf/gce_deployment/deploy.sh b/getting-started/bookshelf/gce_deployment/deploy.sh new file mode 100755 index 0000000000..caa5f61f15 --- /dev/null +++ b/getting-started/bookshelf/gce_deployment/deploy.sh @@ -0,0 +1,155 @@ +#! /bin/bash + +# Copyright 2019 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -ex + +if [ -z "$BOOKSHELF_DEPLOY_LOCATION" ]; then + echo "Must set \$BOOKSHELF_DEPLOY_LOCATION. For example: BOOKSHELF_DEPLOY_TARGET=gs://my-bucket/bookshelf-VERSION.tar" + exit 1 +fi + +ZONE=us-central1-f + +GROUP=frontend-group +TEMPLATE=$GROUP-tmpl +MACHINE_TYPE=f1-micro +IMAGE=debian-8 +STARTUP_SCRIPT=startup-script.sh +SCOPES="userinfo-email,cloud-platform" +TAGS=http-server + +MIN_INSTANCES=1 +MAX_INSTANCES=10 +TARGET_UTILIZATION=0.6 + +SERVICE=frontend-web-service + +# +# Instance group setup +# + +# First we have to create an instance template. +# This template will be used by the instance group +# to create new instances. + +# [START create_template] +gcloud compute instance-templates create $TEMPLATE \ + --image $IMAGE \ + --machine-type $MACHINE_TYPE \ + --scopes $SCOPES \ + --metadata-from-file startup-script=$STARTUP_SCRIPT \ + --metadata app-location=$BOOKSHELF_DEPLOY_LOCATION \ + --tags $TAGS +# [END create_template] + +# Create the managed instance group. + +# [START create_group] +gcloud compute instance-groups managed \ + create $GROUP \ + --base-instance-name $GROUP \ + --size $MIN_INSTANCES \ + --template $TEMPLATE \ + --zone $ZONE +# [END create_group] + +# [START create_named_port] +gcloud compute instance-groups managed set-named-ports \ + $GROUP \ + --named-ports http:8080 \ + --zone $ZONE +# [END create_named_port] + +# +# Load Balancer Setup +# + +# A complete HTTP load balancer is structured as follows: +# +# 1) A global forwarding rule directs incoming requests to a target HTTP proxy. +# 2) The target HTTP proxy checks each request against a URL map to determine the +# appropriate backend service for the request. +# 3) The backend service directs each request to an appropriate backend based on +# serving capacity, zone, and instance health of its attached backends. The +# health of each backend instance is verified using either a health check. +# +# We'll create these resources in reverse order: +# service, health check, backend service, url map, proxy. + +# Create a health check +# The load balancer will use this check to keep track of which instances to send traffic to. +# Note that health checks will not cause the load balancer to shutdown any instances. + +# [START create_health_check] +gcloud compute http-health-checks create ah-health-check \ + --request-path /_ah/health \ + --port 8080 +# [END create_health_check] + +# Create a backend service, associate it with the health check and instance group. +# The backend service serves as a target for load balancing. + +# [START create_backend_service] +gcloud compute backend-services create $SERVICE \ + --http-health-checks ah-health-check +# [END create_backend_service] + +# [START add_backend_service] +gcloud compute backend-services add-backend $SERVICE \ + --instance-group $GROUP \ + --zone $ZONE +# [END add_backend_service] + +# Create a URL map and web Proxy. The URL map will send all requests to the +# backend service defined above. + +# [START create_url_map] +gcloud compute url-maps create $SERVICE-map \ + --default-service $SERVICE +# [END create_url_map] + +# [START create_http_proxy] +gcloud compute target-http-proxies create $SERVICE-proxy \ + --url-map $SERVICE-map +# [END create_http_proxy] + +# Create a global forwarding rule to send all traffic to our proxy + +# [START create_forwarding_rule] +gcloud compute forwarding-rules create $SERVICE-http-rule \ + --global \ + --target-http-proxy $SERVICE-proxy \ + --port-range 80 +# [END create_forwarding_rule] + +# +# Autoscaler configuration +# +# [START set_autoscaling] +gcloud compute instance-groups managed set-autoscaling \ + $GROUP \ + --max-num-replicas $MAX_INSTANCES \ + --target-load-balancing-utilization $TARGET_UTILIZATION \ + --zone $ZONE +# [END set_autoscaling] + +# [START create_firewall] +gcloud compute firewall-rules create default-allow-http-8080 \ + --allow tcp:8080 \ + --source-ranges 0.0.0.0/0 \ + --target-tags http-server \ + --description "Allow port 8080 access to http-server" +# [END create_firewall] diff --git a/getting-started/bookshelf/gce_deployment/startup-script.sh b/getting-started/bookshelf/gce_deployment/startup-script.sh new file mode 100644 index 0000000000..3bfd110e02 --- /dev/null +++ b/getting-started/bookshelf/gce_deployment/startup-script.sh @@ -0,0 +1,62 @@ +#! /bin/bash + +# Copyright 2019 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# [START startup] +set -ex + +# Talk to the metadata server to get the project id and location of application binary. +PROJECTID=$(curl -s "http://metadata.google.internal/computeMetadata/v1/project/project-id" -H "Metadata-Flavor: Google") +BOOKSHELF_DEPLOY_LOCATION=$(curl -s "http://metadata.google.internal/computeMetadata/v1/instance/attributes/app-location" -H "Metadata-Flavor: Google") + +# Install logging monitor. The monitor will automatically pickup logs send to +# syslog. +# [START logging] +curl -s "https://storage.googleapis.com/signals-agents/logging/google-fluentd-install.sh" | bash +service google-fluentd restart & +# [END logging] + +# Install dependencies from apt +apt-get update +apt-get install -yq ca-certificates supervisor + +# Get the application tar from the GCS bucket. +gsutil cp $BOOKSHELF_DEPLOY_LOCATION /app.tar +mkdir -p /app +tar -x -f /app.tar -C /app +chmod +x /app/app + +# Create a goapp user. The application will run as this user. +getent passwd goapp || useradd -m -d /home/goapp goapp +chown -R goapp:goapp /app + +# Configure supervisor to run the Go app. +cat >/etc/supervisor/conf.d/goapp.conf << EOF +[program:goapp] +directory=/app +command=/app/app +autostart=true +autorestart=true +user=goapp +environment=HOME="/home/goapp",USER="goapp" +stdout_logfile=syslog +stderr_logfile=syslog +EOF + +supervisorctl reread +supervisorctl update + +# Application should now be running under supervisor +# [END startup] diff --git a/getting-started/bookshelf/gce_deployment/teardown.sh b/getting-started/bookshelf/gce_deployment/teardown.sh new file mode 100755 index 0000000000..028ddc9621 --- /dev/null +++ b/getting-started/bookshelf/gce_deployment/teardown.sh @@ -0,0 +1,42 @@ +#! /bin/bash + +# Copyright 2019 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -x + +ZONE=us-central1-f +gcloud config set compute/zone $ZONE + +GROUP=frontend-group +TEMPLATE=$GROUP-tmpl +SERVICE=frontend-web-service + +gcloud compute instance-groups managed stop-autoscaling $GROUP --zone $ZONE + +gcloud compute forwarding-rules delete $SERVICE-http-rule --global + +gcloud compute target-http-proxies delete $SERVICE-proxy + +gcloud compute url-maps delete $SERVICE-map + +gcloud compute backend-services delete $SERVICE + +gcloud compute http-health-checks delete ah-health-check + +gcloud compute instance-groups managed delete $GROUP + +gcloud compute instance-templates delete $TEMPLATE + +gcloud compute firewall-rules delete default-allow-http-8080 diff --git a/getting-started/bookshelf/gke_deployment/Dockerfile b/getting-started/bookshelf/gke_deployment/Dockerfile new file mode 100644 index 0000000000..283e05b7df --- /dev/null +++ b/getting-started/bookshelf/gke_deployment/Dockerfile @@ -0,0 +1,21 @@ +FROM golang:alpine + +ARG pkg=github.com/GoogleCloudPlatform/golang-samples/getting-started/bookshelf + +RUN apk add --no-cache ca-certificates + +COPY . $GOPATH/src/$pkg + +RUN set -ex \ + && apk add --no-cache --virtual .build-deps \ + git \ + && go get -v $pkg/... \ + && apk del .build-deps + +RUN go install $pkg/... + +# Needed for templates for the front-end app. +WORKDIR $GOPATH/src/$pkg/app + +# Users of the image should invoke either of the commands. +CMD echo "Use the app or pubsub_worker commands."; exit 1 diff --git a/getting-started/bookshelf/gke_deployment/bookshelf-frontend.yaml b/getting-started/bookshelf/gke_deployment/bookshelf-frontend.yaml new file mode 100644 index 0000000000..2c6ada2ddd --- /dev/null +++ b/getting-started/bookshelf/gke_deployment/bookshelf-frontend.yaml @@ -0,0 +1,49 @@ +# Copyright 2019 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This file configures the bookshelf application frontend. The frontend serves +# public web traffic. + +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: bookshelf-frontend + labels: + app: bookshelf + tier: frontend +# The bookshelf frontend replica set ensures that at least 3 +# instances of the bookshelf app are running on the cluster. +# For more info about Pods see: +# https://kubernetes.io/docs/concepts/workloads/pods/pod-overview/ +spec: + replicas: 3 + template: + metadata: + labels: + app: bookshelf + tier: frontend + spec: + containers: + - name: bookshelf-app + # TODO: Replace [YOUR_PROJECT_ID] with your project ID. + image: gcr.io/[YOUR_PROJECT_ID]/bookshelf:latest + command: ["app"] + # This setting makes nodes pull the docker image every time before + # starting the pod. This is useful when debugging, but should be turned + # off in production. + imagePullPolicy: Always + # The bookshelf process listens on port 8080 for web traffic by default. + ports: + - name: http-server + containerPort: 8080 diff --git a/getting-started/bookshelf/gke_deployment/bookshelf-service.yaml b/getting-started/bookshelf/gke_deployment/bookshelf-service.yaml new file mode 100644 index 0000000000..0f6c919779 --- /dev/null +++ b/getting-started/bookshelf/gke_deployment/bookshelf-service.yaml @@ -0,0 +1,36 @@ +# Copyright 2019 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# The bookshelf service provides a load-balancing proxy over the bookshelf +# frontend pods. By specifying the type as a 'LoadBalancer', Kubernetes Engine +# will create an external HTTP load balancer. +# For more information about Services see: +# https://cloud.google.com/kubernetes-engine/docs/services/ +# For more information about external HTTP load balancing see: +# https://cloud.google.com/kubernetes-engine/docs/load-balancer +apiVersion: v1 +kind: Service +metadata: + name: bookshelf-frontend + labels: + app: bookshelf + tier: frontend +spec: + type: LoadBalancer + ports: + - port: 80 + targetPort: http-server + selector: + app: bookshelf + tier: frontend diff --git a/getting-started/bookshelf/gke_deployment/bookshelf-worker.yaml b/getting-started/bookshelf/gke_deployment/bookshelf-worker.yaml new file mode 100644 index 0000000000..4f27846da7 --- /dev/null +++ b/getting-started/bookshelf/gke_deployment/bookshelf-worker.yaml @@ -0,0 +1,45 @@ +# Copyright 2019 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This file configures the bookshelf application frontend. The frontend serves +# public web traffic. + +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: bookshelf-worker + labels: + app: bookshelf + tier: worker +# The bookshelf frontend replica set ensures that at least 3 +# instances of the bookshelf app are running on the cluster. +# For more info about Pods see: +# https://kubernetes.io/docs/concepts/workloads/pods/pod-overview/ +spec: + replicas: 2 + template: + metadata: + labels: + app: bookshelf + tier: worker + spec: + containers: + - name: bookshelf-worker + # TODO: Replace [YOUR_PROJECT_ID] with your project ID. + image: gcr.io/[YOUR_PROJECT_ID]/bookshelf:latest + command: ["pubsub_worker"] + # This setting makes nodes pull the docker image every time before + # starting the pod. This is useful when debugging, but should be turned + # off in production. + imagePullPolicy: Always diff --git a/getting-started/bookshelf/go.mod b/getting-started/bookshelf/go.mod deleted file mode 100644 index a112818b15..0000000000 --- a/getting-started/bookshelf/go.mod +++ /dev/null @@ -1,11 +0,0 @@ -module github.com/GoogleCloudPlatform/golang-samples/getting-started/bookshelf - -go 1.13 - -require ( - cloud.google.com/go v0.44.3 - github.com/gofrs/uuid v3.2.0+incompatible - github.com/gorilla/handlers v1.4.2 - github.com/gorilla/mux v1.7.3 - google.golang.org/api v0.9.0 -) diff --git a/getting-started/bookshelf/go.sum b/getting-started/bookshelf/go.sum deleted file mode 100644 index 489572c9a1..0000000000 --- a/getting-started/bookshelf/go.sum +++ /dev/null @@ -1,125 +0,0 @@ -cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= -cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= -cloud.google.com/go v0.44.3 h1:0sMegbmn/8uTwpNkB0q9cLEpZ2W5a6kl+wtBQgPWBJQ= -cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= -cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= -github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= -github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE= -github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b h1:VKtxabqXZkF25pY9ekfRL6a582T4P37/31XEstQ5p58= -github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs= -github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= -github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no= -github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= -github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= -github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= -github.com/googleapis/gax-go/v2 v2.0.5 h1:sjZBwGj9Jlw33ImPtvFviGYvseOtDM7hkSKB7+Tv3SM= -github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= -github.com/gorilla/handlers v1.4.2 h1:0QniY0USkHQ1RGCLfKxeNHK9bkDHGRYGNDFBCS+YARg= -github.com/gorilla/handlers v1.4.2/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ= -github.com/gorilla/mux v1.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw= -github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= -github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU= -github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= -go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= -go.opencensus.io v0.22.0 h1:C9hSCOW830chIVkdja34wa6Ky+IzWllkUinR+BtRZd4= -go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= -golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= -golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= -golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= -golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= -golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= -golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 h1:SVwTIAaPC2U/AvvLNZ2a7OVsmBpC8L5BlwK1whH3hm0= -golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0 h1:HyfiK1WMnHj5FXFXatD+Qs1A/xC2Run6RzeW1SyHxpc= -golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -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/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= -golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= -google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= -google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= -google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.9.0 h1:jbyannxz0XFD3zdjgrSUsaJbgpH4eTrkdhRChkHPfO8= -google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= -google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= -google.golang.org/appengine v1.6.1 h1:QzqyMA1tlu6CgqCDUtU9V+ZKhLFT2dkJuANu5QaxI3I= -google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= -google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= -google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= -google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64 h1:iKtrH9Y8mcbADOP0YFaEMth7OfuHY9xHOwNj4znpM1A= -google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= -google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= -google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= -google.golang.org/grpc v1.21.1 h1:j6XxA85m/6txkUCHvzlV5f+HBNl/1r5cZ2A/3IEFOO8= -google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= -honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= diff --git a/getting-started/bookshelf/main_test.go b/getting-started/bookshelf/main_test.go deleted file mode 100644 index a05a7eb94f..0000000000 --- a/getting-started/bookshelf/main_test.go +++ /dev/null @@ -1,212 +0,0 @@ -// Copyright 2019 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// https://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package main - -import ( - "bytes" - "context" - "fmt" - "io/ioutil" - "log" - "mime/multipart" - "net/http/httptest" - "os" - "strings" - "testing" - - "cloud.google.com/go/firestore" - "github.com/GoogleCloudPlatform/golang-samples/getting-started/bookshelf/internal/webtest" -) - -var ( - wt *webtest.W - b *Bookshelf - - testDBs = map[string]BookDatabase{} -) - -func TestMain(m *testing.M) { - ctx := context.Background() - - projectID := os.Getenv("GOLANG_SAMPLES_PROJECT_ID") - if projectID == "" { - log.Println("GOLANG_SAMPLES_PROJECT_ID not set. Skipping.") - return - } - - memoryDB := newMemoryDB() - testDBs["memory"] = memoryDB - - if firestoreProjectID := os.Getenv("GOLANG_SAMPLES_FIRESTORE_PROJECT"); firestoreProjectID != "" { - projectID = firestoreProjectID - - client, err := firestore.NewClient(ctx, projectID) - if err != nil { - log.Fatalf("firestore.NewClient: %v", err) - } - db, err := newFirestoreDB(client) - if err != nil { - log.Fatalf("newFirestoreDB: %v", err) - } - testDBs["firestore"] = db - } else { - log.Println("GOLANG_SAMPES_FIRESTORE_PROJECT not set. Skipping Firestore database tests.") - } - - var err error - b, err = NewBookshelf(projectID, memoryDB) - if err != nil { - log.Fatalf("NewBookshelf: %v", err) - } - - // Don't log anything during testing. - log.SetOutput(ioutil.Discard) - b.logWriter = ioutil.Discard - - serv := httptest.NewServer(nil) - wt = webtest.New(nil, serv.Listener.Addr().String()) - - b.registerHandlers() - - os.Exit(m.Run()) -} - -func TestNoBooks(t *testing.T) { - for name, db := range testDBs { - t.Run(name, func(t *testing.T) { - b.DB = db - bodyContains(t, wt, "/", "No books found") - }) - } -} - -func TestBookDetail(t *testing.T) { - for name, db := range testDBs { - t.Run(name, func(t *testing.T) { - b.DB = db - ctx := context.Background() - const title = "book mcbook" - id, err := b.DB.AddBook(ctx, &Book{ - Title: title, - }) - if err != nil { - t.Fatal(err) - } - - bodyContains(t, wt, "/", title) - - bookPath := fmt.Sprintf("/books/%s", id) - bodyContains(t, wt, bookPath, title) - - if err := b.DB.DeleteBook(ctx, id); err != nil { - t.Fatal(err) - } - - bodyContains(t, wt, "/", "No books found") - }) - } - -} - -func TestEditBook(t *testing.T) { - for name, db := range testDBs { - t.Run(name, func(t *testing.T) { - b.DB = db - ctx := context.Background() - const title = "book mcbook" - id, err := b.DB.AddBook(ctx, &Book{ - Title: title, - }) - if err != nil { - t.Fatal(err) - } - - bookPath := fmt.Sprintf("/books/%s", id) - editPath := bookPath + "/edit" - bodyContains(t, wt, editPath, "Edit book") - bodyContains(t, wt, editPath, title) - - var body bytes.Buffer - m := multipart.NewWriter(&body) - m.WriteField("title", "simpsons") - m.WriteField("author", "homer") - m.Close() - - resp, err := wt.Post(bookPath, "multipart/form-data; boundary="+m.Boundary(), &body) - if err != nil { - t.Fatal(err) - } - if got, want := resp.Request.URL.Path, bookPath; got != want { - t.Errorf("got %s, want %s", got, want) - } - - bodyContains(t, wt, bookPath, "simpsons") - bodyContains(t, wt, bookPath, "homer") - - if err := b.DB.DeleteBook(ctx, id); err != nil { - t.Fatalf("got err %v, want nil", err) - } - }) - } -} - -func TestAddAndDelete(t *testing.T) { - for name, db := range testDBs { - t.Run(name, func(t *testing.T) { - b.DB = db - bodyContains(t, wt, "/books/add", "Add book") - - bookPath := fmt.Sprintf("/books") - - var body bytes.Buffer - m := multipart.NewWriter(&body) - m.WriteField("title", "simpsons") - m.WriteField("author", "homer") - m.Close() - - resp, err := wt.Post(bookPath, "multipart/form-data; boundary="+m.Boundary(), &body) - if err != nil { - t.Fatal(err) - } - - gotPath := resp.Request.URL.Path - if wantPrefix := "/books"; !strings.HasPrefix(gotPath, wantPrefix) { - t.Fatalf("redirect: got %q, want prefix %q", gotPath, wantPrefix) - } - - bodyContains(t, wt, gotPath, "simpsons") - bodyContains(t, wt, gotPath, "homer") - - _, err = wt.Post(gotPath+":delete", "", nil) - if err != nil { - t.Fatal(err) - } - }) - } - -} - -func bodyContains(t *testing.T, wt *webtest.W, path, contains string) (ok bool) { - body, _, err := wt.GetBody(path) - if err != nil { - t.Error(err) - return false - } - if !strings.Contains(body, contains) { - t.Errorf("want %s to contain %s", body, contains) - return false - } - return true -} diff --git a/getting-started/bookshelf/app.yaml b/getting-started/bookshelf/pubsub_worker/app.yaml similarity index 70% rename from getting-started/bookshelf/app.yaml rename to getting-started/bookshelf/pubsub_worker/app.yaml index 37e5c1f952..fec89de4a2 100644 --- a/getting-started/bookshelf/app.yaml +++ b/getting-started/bookshelf/pubsub_worker/app.yaml @@ -12,4 +12,18 @@ # See the License for the specific language governing permissions and # limitations under the License. -runtime: go112 +runtime: go +env: flex +service: worker + +resources: + cpu: .5 + memory_gb: 1.3 + disk_size_gb: 10 + +automatic_scaling: + min_num_instances: 1 + max_num_instances: 2 + cool_down_period_sec: 60 + cpu_utilization: + target_utilization: 0.75 diff --git a/getting-started/bookshelf/pubsub_worker/worker.go b/getting-started/bookshelf/pubsub_worker/worker.go new file mode 100644 index 0000000000..2241a6fb30 --- /dev/null +++ b/getting-started/bookshelf/pubsub_worker/worker.go @@ -0,0 +1,164 @@ +// Copyright 2019 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Sample pubsub_worker demonstrates the use of the Cloud Pub/Sub API to communicate between two modules. +// See https://cloud.google.com/go/getting-started/using-pub-sub +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "strings" + "sync" + + books "google.golang.org/api/books/v1" + + "cloud.google.com/go/pubsub" + + "github.com/GoogleCloudPlatform/golang-samples/getting-started/bookshelf" +) + +const subName = "book-worker-sub" + +var ( + countMu sync.Mutex + count int + + booksClient *books.Service + subscription *pubsub.Subscription +) + +func main() { + ctx := context.Background() + + if bookshelf.PubsubClient == nil { + log.Fatal("You must configure the Pub/Sub client in config.go before running pubsub_worker.") + } + + var err error + booksClient, err = books.New(http.DefaultClient) + if err != nil { + log.Fatalf("could not access Google Books API: %v", err) + } + + // [START pubsub_create_topic] + // Create pubsub topic if it does not yet exist. + topic := bookshelf.PubsubClient.Topic(bookshelf.PubsubTopicID) + exists, err := topic.Exists(ctx) + if err != nil { + log.Fatalf("Error checking for topic: %v", err) + } + if !exists { + if _, err := bookshelf.PubsubClient.CreateTopic(ctx, bookshelf.PubsubTopicID); err != nil { + log.Fatalf("Failed to create topic: %v", err) + } + } + + // Create topic subscription if it does not yet exist. + subscription = bookshelf.PubsubClient.Subscription(subName) + exists, err = subscription.Exists(ctx) + if err != nil { + log.Fatalf("Error checking for subscription: %v", err) + } + if !exists { + if _, err = bookshelf.PubsubClient.CreateSubscription(ctx, subName, pubsub.SubscriptionConfig{Topic: topic}); err != nil { + log.Fatalf("Failed to create subscription: %v", err) + } + } + // [END pubsub_create_topic] + + // Start worker goroutine. + go subscribe() + + // [START http] + // Publish a count of processed requests to the server homepage. + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + countMu.Lock() + defer countMu.Unlock() + fmt.Fprintf(w, "This worker has processed %d books.", count) + }) + + port := "8080" + if p := os.Getenv("PORT"); p != "" { + port = p + } + log.Fatal(http.ListenAndServe(":"+port, nil)) + // [END http] +} + +func subscribe() { + ctx := context.Background() + err := subscription.Receive(ctx, func(ctx context.Context, msg *pubsub.Message) { + var id int64 + if err := json.Unmarshal(msg.Data, &id); err != nil { + log.Printf("could not decode message data: %#v", msg) + msg.Ack() + return + } + + log.Printf("[ID %d] Processing.", id) + if err := update(id); err != nil { + log.Printf("[ID %d] could not update: %v", id, err) + msg.Nack() + return + } + + countMu.Lock() + count++ + countMu.Unlock() + + msg.Ack() + log.Printf("[ID %d] ACK", id) + }) + if err != nil { + log.Fatal(err) + } +} + +// update retrieves the book with the given ID, finds metata from the Books +// server and updates the database with the book's details. +func update(bookID int64) error { + book, err := bookshelf.DB.GetBook(bookID) + if err != nil { + return err + } + + vols, err := booksClient.Volumes.List(book.Title).Do() + if err != nil { + return err + } + + if len(vols.Items) == 0 { + return nil + } + + info := vols.Items[0].VolumeInfo + book.Title = info.Title + book.Author = strings.Join(info.Authors, ", ") + book.PublishedDate = info.PublishedDate + if book.Description == "" { + book.Description = info.Description + } + if book.ImageURL == "" && info.ImageLinks != nil { + url := info.ImageLinks.Thumbnail + // Replace http with https to prevent Content Security errors on the page. + book.ImageURL = strings.Replace(url, "http://", "https://", 1) + } + + return bookshelf.DB.UpdateBook(book) +} diff --git a/getting-started/bookshelf/internal/webtest/webtest.go b/internal/webtest/webtest.go similarity index 100% rename from getting-started/bookshelf/internal/webtest/webtest.go rename to internal/webtest/webtest.go