getting-started/bookshelf/main.go (239 lines of code) (raw):

// 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 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. package main import ( "context" "errors" "fmt" "io" "log" "net/http" "os" "path" "runtime/debug" "cloud.google.com/go/errorreporting" "cloud.google.com/go/firestore" "cloud.google.com/go/storage" "github.com/gofrs/uuid" "github.com/gorilla/handlers" "github.com/gorilla/mux" ) var ( // See template.go. listTmpl = parseTemplate("list.html") editTmpl = parseTemplate("edit.html") detailTmpl = parseTemplate("detail.html") ) func main() { port := os.Getenv("PORT") 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) } b, err := NewBookshelf(projectID, db) if err != nil { log.Fatalf("NewBookshelf: %v", err) } b.registerHandlers() log.Printf("Listening on localhost:%s", port) if err := http.ListenAndServe(":"+port, nil); err != nil { log.Fatal(err) } } func (b *Bookshelf) registerHandlers() { // Use gorilla/mux for rich routing. // See https://www.gorillatoolkit.org/pkg/mux. r := mux.NewRouter() r.Handle("/", http.RedirectHandler("/books", http.StatusFound)) r.Methods("GET").Path("/books"). Handler(appHandler(b.listHandler)) 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)) 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") r.Methods("GET").Path("/logs").Handler(appHandler(b.sendLog)) r.Methods("GET").Path("/errors").Handler(appHandler(b.sendError)) // 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)) } // 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) if err != nil { return b.appErrorf(r, err, "could not list books: %v", err) } return listTmpl.Execute(b, w, r, books) } // 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") } book, err := b.DB.GetBook(ctx, id) if err != nil { return nil, fmt.Errorf("could not find book: %w", err) } return book, nil } // detailHandler displays the details of a given book. func (b *Bookshelf) detailHandler(w http.ResponseWriter, r *http.Request) *appError { book, err := b.bookFromRequest(r) if err != nil { return b.appErrorf(r, err, "%v", err) } return detailTmpl.Execute(b, w, r, book) } // 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 { return editTmpl.Execute(b, 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) if err != nil { return b.appErrorf(r, err, "%v", err) } return editTmpl.Execute(b, w, r, book) } // 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) if err != nil { return nil, fmt.Errorf("could not upload file: %w", err) } if imageURL == "" { imageURL = r.FormValue("imageURL") } book := &Book{ Title: r.FormValue("title"), Author: r.FormValue("author"), PublishedDate: r.FormValue("publishedDate"), ImageURL: imageURL, Description: r.FormValue("description"), } return book, nil } // [START getting_started_bookshelf_storage] // 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) { f, fh, err := r.FormFile("image") if err == http.ErrMissingFile { return "", nil } if err != nil { return "", err } if b.StorageBucket == nil { return "", errors.New("storage bucket is missing: check bookshelf.go") } if _, err := b.StorageBucket.Attrs(ctx); err != nil { if err == storage.ErrBucketNotExist { return "", fmt.Errorf("bucket %q does not exist: check bookshelf.go", b.StorageBucketName) } return "", fmt.Errorf("could not get bucket: %w", err) } // random filename, retaining existing extension. name := uuid.Must(uuid.NewV4()).String() + path.Ext(fh.Filename) w := b.StorageBucket.Object(name).NewWriter(ctx) // Warning: storage.AllUsers gives public read access to anyone. w.ACL = []storage.ACLRule{{Entity: storage.AllUsers, Role: storage.RoleReader}} w.ContentType = fh.Header.Get("Content-Type") // Entries are immutable, be aggressive about caching (1 day). w.CacheControl = "public, max-age=86400" if _, err := io.Copy(w, f); err != nil { return "", err } if err := w.Close(); err != nil { return "", err } const publicURL = "https://storage.googleapis.com/%s/%s" return fmt.Sprintf(publicURL, b.StorageBucketName, name), nil } // [END getting_started_bookshelf_storage] // 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) if err != nil { return b.appErrorf(r, err, "could not parse book from form: %v", err) } id, err := b.DB.AddBook(ctx, book) if err != nil { return b.appErrorf(r, err, "could not save book: %v", err) } http.Redirect(w, r, fmt.Sprintf("/books/%s", 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 b.appErrorf(r, errors.New("no book with empty ID"), "no book with empty ID") } book, err := b.bookFromForm(r) if err != nil { return b.appErrorf(r, err, "could not parse book from form: %v", err) } book.ID = id if err := b.DB.UpdateBook(ctx, book); err != nil { return b.appErrorf(r, err, "UpdateBook: %v", err) } http.Redirect(w, r, fmt.Sprintf("/books/%s", 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 b.appErrorf(r, err, "DeleteBook: %v", err) } http.Redirect(w, r, "/books", http.StatusFound) return nil } // sendLog logs a message. // // See https://cloud.google.com/logging/docs/setup/go for how to use the // Stackdriver logging client. Output to stdout and stderr is automaticaly // sent to Stackdriver when running on App Engine. func (b *Bookshelf) sendLog(w http.ResponseWriter, r *http.Request) *appError { fmt.Fprintln(b.logWriter, "Hey, you triggered a custom log entry. Good job!") fmt.Fprintln(w, `<html>Log sent! Check the <a href="http://console.cloud.google.com/logs">logging section of the Cloud Console</a>.</html>`) return nil } // sendError triggers an error that is sent to Error Reporting. func (b *Bookshelf) sendError(w http.ResponseWriter, r *http.Request) *appError { msg := `<html>Logging an error. Check <a href="http://console.cloud.google.com/errors">Error Reporting</a> (it may take a minute or two for the error to appear).</html>` err := errors.New("uh oh! an error occurred") return b.appErrorf(r, err, msg) } // https://blog.golang.org/error-handling-and-go type appHandler func(http.ResponseWriter, *http.Request) *appError type appError struct { err error message string code int req *http.Request b *Bookshelf stack []byte } func (fn appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if e := fn(w, r); e != nil { // e is *appError, not os.Error. fmt.Fprintf(e.b.logWriter, "Handler error (reported to Error Reporting): status code: %d, message: %s, underlying err: %+v\n", e.code, e.message, e.err) w.WriteHeader(e.code) fmt.Fprint(w, e.message) e.b.errorClient.Report(errorreporting.Entry{ Error: e.err, Req: r, Stack: e.stack, }) e.b.errorClient.Flush() } } func (b *Bookshelf) appErrorf(r *http.Request, err error, format string, v ...interface{}) *appError { return &appError{ err: err, message: fmt.Sprintf(format, v...), code: 500, req: r, b: b, stack: debug.Stack(), } }