1. Overview

The Bookshelf app is a sample web app written in Groovy with the Grails framework. It shows how to use a variety of Google Cloud Platform products, including:

This tutorial explores the Bookshelf app in detail, and discusses how each feature of the app is implemented using familiar technologies and services provided by Cloud Platform.

bookshelf app 3

The Bookshelf sample app stores a collection of book titles. Anyone who has access to the app can add books to the list. The sample app offers these features:

  • Users can view the list of books

  • Users can remove books from the list.

  • Users can add books. They create books uploading a cover image, text int that image is detected and used to populate the book title.

  • Users can edit book metadata such as title, description, author or publication date.

  • A title and description for Spanish and Italian is generated on the fly.

  • Users can log in with their Google accounts and view the books that they have added to the list.

1.1. Objectives

  • Clone or download the sample app.

  • Build the app and run it on your local machine.

  • Deploy the app to App Engine.

  • Walk through the sample code.

  • Learn how the app stores structured data.

  • Learn how the app stores binary data in Google Cloud Storage.

  • Learn how the app uses Cloud Translate API to translate English text into Spanish and Italian

  • Learn how the app uses Cloud Vision to extract text from a cover image

  • Learn how the app authenticates users.

  • Learn how the app creates event logs that are visible in the Google Cloud Platform Console.

1.2. Costs

This tutorial uses billable components of Cloud Platform including Google Compute Engine.

This tutorial has several steps, and each step is documented on its own page. The final page of the tutorial includes instructions for cleaning up resources, so you won’t continue to be billed for Cloud Platform services. If you decide not to complete all the steps of the tutorial, see the cleanup instructions on the final page.

1.3. Before you begin

  1. Use the Cloud Platform Console to set up your Google Cloud Platform project:

    • Create a new Cloud Platform project, and then create an App Engine application and enable billing in that project: GO TO APP ENGINE

    • Enable the Cloud Datastore, Cloud Pub/Sub, Cloud Storage JSON, Stackdriver Logging, and Google+ APIs. ENABLE THE APIS

  2. To deploy a Java app to App Engine, you must first setup your environment, see Using Gradle and the App Engine Plugin for details.

  3. Download, install, and initialize the Google Cloud SDK: DOWNLOAD THE SDK

  4. Acquire local credentials for authenticating with Google Cloud Platform services: gcloud beta auth application-default login

  5. Verify that your default project is correct:
    gcloud config list
    If the project ID listed in the output is not the project that you intended to use for this tutorial, set the project by entering this command:
    gcloud config set project [YOUR_PROJECT_ID]
    where [YOUR_PROJECT_ID] is the ID of the project you created or chose to use for this tutorial.

  6. Clone the sample repository:
    git clone https://github.com/grails-samples/google-bookshelf.git

You can create Google Cloud SDK configurations to set configuration properties like a Cloud Platform project ID, and then quickly switch between those configurations each time you use the gcloud tool, see Managing Cloud SDK Configurations for more information.

Alternatively, you can download the sample as a zip file and extract it.

This tutorial assumes that you are familiar with Groovy and Grails and that you have Java 8 and Gradle installed.

1.4. Tutorial structure

The Bookshelf tutorial has several parts that demonstrate how the sample app uses various Cloud Platform services and leverages Grails Features.

The Controllers and Views part demonstrates how the app uses Grails: Controllers, Command Objects and GSPs to generate the UI and handle interaction.

The Services part shows how Grails can interact with different Cloud Services.

  • This sample stores book information in a SQL database. The app’s web page displays a form where the user can enter the title, author, description, and publication date of a book. For each book entered, the app stores this information in a database, so it can be retrieved later for viewing or editing.

  • This sample app stores binary data in Cloud Storage. On the app’s web page, the user can specify a cover image for each book. The app stores the cover images in a Cloud Storage bucket.

  • This sample app uses Cloud Vision API to extract the text present on a book cover image.

  • This sample app uses Cloud Translation API to translate text from English into Spanish and Italian.

The authorization part of the tutorial demonstrates how the app provides a sign-in flow for the user. When a user is signed in, any books entered are associated with the individual user. Signed-in users see only their own books.

The logging part of the tutorial demonstrates how the app writes logs that become visible in the Google Cloud Platform Console. Logs of this type can provide diagnostic information during app development.

1.5. Running the app on your local machine

To run the app locally while you are developing:

  1. Create a MySQL database and configure its url, username and password in app/grails-app/conf/application.yml

  2. In the root of the repository, enter this command to start a local web server:
    ./gradlew app:bootRun

  3. In your web browser, navigate to http://localhost:8080

2. Deploying

2.1. Deploying the app to the App Engine flexible environment

  1. Create a configuration file for production: app/grails-app/conf/application-production.yml See code listing below.

  2. Enter this command to deploy the app:
    ./gradlew app:appengineDeploy

  3. In your web browser, enter this address. Replace [YOUR_PROJECT_ID] with your project ID:
    https://[YOUR_PROJECT_ID].appspot-preview.com

app/grails-app/conf/application-production.yml
bookshelf:
    clientID: {CLIENTID}
    clientSecret: {CLIENTSECRET}
    callback: https://{PROJECT_ID}.appspot.com/oauth2callback
---
dataSource:
    dialect: org.hibernate.dialect.MySQL5InnoDBDialect
    driverClassName: com.mysql.jdbc.Driver
    dbCreate: update
    url: jdbc:mysql://google/{DATABASENAME}?cloudSqlInstance={CLOUD_SQL_INSTANCENAME}&socketFactory=com.google.cloud.sql.mysql.SocketFactory&user={USERNAME}&password={PASSWORD}&useSSL=false
    properties:
        jmxEnabled: true
        initialSize: 5
        maxActive: 50
        minIdle: 5
        maxIdle: 25
        maxWait: 10000
        maxAge: 600000
        timeBetweenEvictionRunsMillis: 5000
        minEvictableIdleTimeMillis: 60000
        validationQuery: SELECT 1
        validationQueryTimeout: 3
        validationInterval: 15000
        testOnBorrow: true
        testWhileIdle: true
        testOnReturn: false
        jdbcInterceptors: ConnectionState
        defaultTransactionIsolation: 2 # TRANSACTION_READ_COMMITTED
replace the above placeholders with the values of your configuration.

If you update your app, you can deploy the updated version by entering the same command you used to deploy the app the first time. The new deployment creates a new version of your app and promotes it to the default version. The older versions of your app remain, as do their associated VM instances. Be aware that all of these app versions and VM instances are billable resources.

You can reduce costs by deleting the non-default versions of your app.

To delete an app version:

  1. In the Cloud Platform Console, go to the App Engine Versions page. GO TO THE VERSIONS PAGE

  2. Click the checkbox next to the non-default app version you want to delete.

  3. Click the Delete button at the top of the page to delete the app version.

Note: The only way to delete the default version of your App Engine app is to delete your project. You can, however, stop the default version in the Cloud Platform Console. This will shut down all instances associated with the version. You can restart these instances later if needed. You can overwrite the default version of your app by redeploying the app.

For complete information about cleaning up billable resources, see the Cleaning up section in the final step of this tutorial.

2.2. Deployment changes from a regular Grails App

We define the dependencies' versions used in the project in gradle.properties

gradle.properties
grailsVersion=3.2.9
gormVersion=6.0.10.RELEASE
gradleWrapperVersion=3.4.1
coberturaPluginVersion=2.4.0
googleCloudDatastoreVersion=0.12.0-beta
googleCloudStorageVersion=0.13.0-beta
googleCloudTranslateVersion=0.13.0-beta
googleCloudVisionVersion=0.13.0-beta
googleApiClientVersion=1.22.0
googleHttpClientJacksonVersion=1.22.0
jacksonDatabindVersion=2.8.8
htmlunitVersion=2.18
seleniumHtmlunitDriver=2.47.1
assetPipelineGrailsVersion=2.14.1
appengineGradlePluginVersion=1.1.1
mysqlSocketFactoryConnectorJ6Version=1.0.2
mysqlConnectorJavaVersion=6.0.5
jfrogGradlePluginVersion=4.4.15
guavaVersion=19.0
groovyVersion=2.4.10

In order to deploy to App Engine Flexible, we need to add the buildscript dependency and apply the Google Cloud Gradle Plugin.

app/build.gradle
buildscript {
    repositories {
        mavenLocal()
        maven { url "https://repo.grails.org/grails/core" }
    }
    dependencies {
        classpath "org.grails:grails-gradle-plugin:$grailsVersion"
        classpath "com.bertramlabs.plugins:asset-pipeline-gradle:$assetPipelineGrailsVersion"
        classpath "org.grails.plugins:hibernate5:${gormVersion-".RELEASE"}"
        classpath "com.google.cloud.tools:appengine-gradle-plugin:$appengineGradlePluginVersion" (1)
        classpath "net.saliman:gradle-cobertura-plugin:$coberturaPluginVersion"
    }
}

version "0.1"
group "app"

apply plugin:"eclipse"
apply plugin:"idea"
apply plugin:"war"
apply plugin:"org.grails.grails-web"
apply plugin:"org.grails.grails-gsp"
apply plugin:"asset-pipeline"
apply plugin: 'com.google.cloud.tools.appengine' (2)
apply from: "${rootProject.projectDir}/gradle/codenarc.gradle"
1 Google Cloud Gradle Plugin buildscript dependency
2 Apply Google Cloud Gradle Plugin

We need an AppEngine configuration file too:

app/src/main/appengine/app.yaml
runtime: java
env: flex

runtime_config:
    jdk: openjdk8
    server: jetty9

health_check:
    enable_health_check: False

resources:
    cpu: 1
    memory_gb: 2.3

manual_scaling:
  instances: 1

Since we are using Jetty we need to replace the compile dependency:

app/build.gradle
provided 'org.springframework.boot:spring-boot-starter-tomcat'

with:

app/build.gradle
provided 'org.springframework.boot:spring-boot-starter-jetty'

Moreover, we need to fix the Google Guava version

app/build.gradle
configurations.all {
    resolutionStrategy {
        force "com.google.guava:guava:$guavaVersion"
    }
    exclude group: 'com.google.guava', module: 'guava-jdk5'
}

We need to exclude tomcat-juli dependency too:

app/build.gradle
configurations {
    compile.exclude module: "tomcat-juli"
}

We highlight next the changes regarding dependencies

app/build.gradle
dependencies {
    compile "org.springframework.boot:spring-boot-starter-logging"
    compile "org.springframework.boot:spring-boot-autoconfigure"
    compile "org.grails:grails-core"
    compile "org.springframework.boot:spring-boot-starter-actuator"
    provided "org.springframework.boot:spring-boot-starter-jetty" (1)
    compile "org.grails:grails-dependencies"
    compile "org.grails:grails-web-boot"
    compile "org.grails.plugins:cache"
    compile "org.grails.plugins:scaffolding"
    compile "org.grails.plugins:hibernate5"
    compile "org.hibernate:hibernate-core:5.1.3.Final"
    compile "org.hibernate:hibernate-ehcache:5.1.3.Final"
    console "org.grails:grails-console"
    profile "org.grails.profiles:web"
    runtime "com.bertramlabs.plugins:asset-pipeline-grails:$assetPipelineGrailsVersion"
    testRuntime "com.h2database:h2"
    testCompile "org.grails:grails-plugin-testing"
    testCompile "org.grails.plugins:geb"
    testRuntime "org.seleniumhq.selenium:selenium-htmlunit-driver:$seleniumHtmlunitDriver"
    testRuntime "net.sourceforge.htmlunit:htmlunit:$htmlunitVersion"
    testCompile "org.grails:grails-datastore-rest-client"
    runtime "mysql:mysql-connector-java:$mysqlConnectorJavaVersion" (2)
    runtime "com.google.cloud.sql:mysql-socket-factory-connector-j-6:$mysqlSocketFactoryConnectorJ6Version" (2)
    compile "com.google.guava:guava:$guavaVersion" (3)
    compile project(':bookshelf-dao')
}
1 Jetty dependency
2 Runtime depependencies to connect to a MySQL database in Cloud SQL
3 Fix Guava dependency

3. Controllers and Views

3.1. Internationalization

Books' titles and descriptions, coming from the persistence layer, are translated by Google Cloud Translation API.

To translate UI elements we use properties files:

app/grails-app/i18n/messages.properties
books=Books
books.mine=My books
login=Login
bookshelf=Bookshelf
book=Book
book.title=Title
book.author=Author
book.description=Description
book.cover.image=Cover Image
book.cover.image.url=Cover Image URL
book.date.published=Date Published
book.list.more=More
book.list.notFound=No books found
book.add=Add book
book.create=Create book
book.save=Save
book.edit=Edit book
book.delete=Delete book
book.author.by=By {0}
book.author.unknown=By Unknown
book.added.by=Added by {0}
book.added.by.anonymous=Added by Anonymous

Spanish

app/grails-app/i18n/messages_es.properties
books=Libros
books.mine=Mis Libros
login=Entrar
bookshelf=Biblioteca
book=Libro
book.title=Título
book.author=Autor
book.description=Descripción
book.cover.image=Portada
book.cover.image.url=URL Portada
book.date.published=Fecha Publicación
book.list.more=Más
book.list.notFound=Libros no encontrados
book.add=Añadir libro
book.create=Crear libro
book.save=Guardar
book.edit=Editar libro
book.delete=Borrar libro
book.author.by=por {0}
book.author.unknown=por desconocido
book.added.by=Añadido por {0}
book.added.by.anonymous=Añadido porAnónimo

Italian

app/grails-app/i18n/messages_it.properties
books=Libri
books.mine=I miei libri
login=Accesso
bookshelf=Libretto
book=Libro
book.title=Titolo
book.author=Autore
book.description=Descrizione
book.cover.image=Immagine di copertina
book.cover.image.url=Immagine di copertina URL
book.date.published=Data Pubblicata
book.list.more=Di Più
book.list.notFound=Nessun libro trovato
book.add=Aggiungi Libro
book.create=Aggiungi Libro
book.save=Salvare
book.edit=Modifica il libro
book.delete=Elimina il libro
book.author.by=By {0}
book.author.unknown=Per sconosciuto
book.added.by=Aggiunto da {0}
book.added.by.anonymous=Aggiunto da Anonimo

3.2. Listing books

We map the home page to be handled by the BookController index action.

app/grails-app/controllers/com/example/getstarted/basicactions/UrlMappings.groovy
'/'(controller: 'book', action: 'index', method: 'GET')

The controller’s action returns a list of books, along with a URL safe cursor. It uses a service to fetch books and handles the model to the view:

app/grails-app/controllers/com/example/getstarted/basicactions/BookController.groovy
def index(String cursor) {
    Result<Book> result = daoService.listBooks(cursor)
    log.info 'Retrieved list of all books'
    String title = messageSource.getMessage('books', null, 'Books', request.locale)
    [books: result.result, cursor: result.cursor, title: title]
}

As you can see in the next code snippet, we use the message tag to reference the codes defined in the internationalization section.

app/grails-app/views/book/index.gsp
<%@ page import="org.springframework.web.servlet.support.RequestContextUtils" %>
<!DOCTYPE html>
<html>
<head>
    <meta name="layout" content="main" />
</head>
<body>
<div class="container">
    <h3>${title}</h3>
    <a href="/create">
        <i class="glyphicon glyphicon-plus"></i>
        <g:message code="book.add" default="Add book"/>
    </a>
    <g:if test="${books}">
        <g:each in="${books}" var="book">
            <div class="media">
                <g:link controller="book" action="show" id="${book.id}">
                    <div class="media-left">
                        <img alt="ahhh" src="${book?.imageUrl ?: 'http://placekitten.com/g/128/192'}">
                    </div>
                    <div class="media-body">
                        <h4><bookshelf:bookTitle id="${book?.id}" languageCode="${RequestContextUtils.getLocale(request).language}"/></h4>
                        <p>${book.author}</p>
                    </div>
                </g:link>
            </div>
        </g:each>
        <g:if test="${cursor}">
            <nav>
                <ul class="pager">
                    <li><a href="?cursor=${cursor}"><g:message code="book.list.more" default="More"/></a></li>
                </ul>
            </nav>
        </g:if>
    </g:if>
    <g:else>
        <p><g:message code="book.list.notFound" default="No books found"/></p>
    </g:else>
</div>
</body>
</html>

3.3. Create a book

To create a book, we create a form which allows us to upload a cover image:

create book

We map the display of the form to be handled by the BookController create action.

app/grails-app/controllers/com/example/getstarted/basicactions/UrlMappings.groovy
'/create'(controller: 'book', action: 'create', method: 'GET')
app/grails-app/controllers/com/example/getstarted/basicactions/BookController.groovy
@SuppressWarnings('EmptyMethod')
def create() {
}

We render the form with the help of the Grails Form Tag.

app/grails-app/views/book/create.gsp
<!DOCTYPE html>
<html>
<head>
    <meta name="layout" content="main" />
</head>
<body>
<div class="container">
    <h3><g:message code="book.create" default="Create book"/></h3>
    <g:form method="POST" url="/create${book?.id ? ('/' + book.id) : ''}" enctype="multipart/form-data">
        <div class="form-group ${application.isCloudStorageConfigured ? '' : 'hidden'}">
            <label for="file"><g:message code="book.cover.image" default="Cover Image"/></label>
            <input type="file" name="file" id="file" class="form-control" />
        </div>
        <button type="submit" class="btn btn-success"><g:message code="book.save" default="Save"/></button>

    </g:form>
</div>
</body>
</html>

We map the creation of the book to be handled by the BookController save action.

app/grails-app/controllers/com/example/getstarted/basicactions/UrlMappings.groovy
'/create'(controller: 'book', action: 'save', method: 'POST')
app/grails-app/controllers/com/example/getstarted/basicactions/BookController.groovy
def save(CreateBookCommand cmd) {
    if ( cmd.hasErrors() ) {
        respond cmd.errors
        return
    }

    BookCurator curator = new BookCurator()
    if ( session[Oauth2CallbackController.SESSION_ATTRIBUTE_TOKEN] ) {
        curator.createdBy = session[Oauth2CallbackController.SESSION_USER_EMAIL]
        curator.createdById = session[Oauth2CallbackController.SESSION_USER_ID]
    }

    Long id = createBookWithCoverImageService.saveBookWithCover(cmd.file, curator)

    log.info "Created book ${id}"

    redirect(action: 'show', id: id)
}

We encapsulate the binding and validation of the cover image with a command object:

app/grails-app/controllers/com/example/getstarted/basicactions/CreateBookCommand.groovy
package com.example.getstarted.basicactions

import grails.compiler.GrailsCompileStatic
import grails.validation.Validateable

import org.springframework.web.multipart.MultipartFile

@GrailsCompileStatic
class CreateBookCommand implements Validateable {
    MultipartFile file

    static constraints = {
        file nullable: false
    }
}

3.4. Display book

We map the home page to be handled by the BookController show action.

app/grails-app/controllers/com/example/getstarted/basicactions/UrlMappings.groovy
"/read/$id"(controller: 'book', action: 'show', method: 'GET')

The controller’s action returns a list of books, along with a URL safe cursor. It uses a service to fetch books and handles the model to the view:

app/grails-app/controllers/com/example/getstarted/basicactions/BookController.groovy
def show(Long id) {
    log.info "Read book with id ${id}"
    [book: daoService.readBook(id)]
}

We use a GSP to render the book:

app/grails-app/views/book/show.gsp
<%@ page import="com.example.getstarted.domain.BookGormEntity" %>
<%@ page import="com.example.getstarted.domain.BookLocalizationGormEntity" %>
<%@ page import="org.springframework.web.servlet.support.RequestContextUtils" %>
<!DOCTYPE html>
<html>
<head>
    <meta name="layout" content="main" />
</head>
<body>
<div class="container">
    <h3><g:message code="book" default="Book"/></h3>
    <div class="btn-group">
        <g:link controller="book" action="edit" id="${book?.id}"  class="btn btn-primary btn-sm">
            <i class="glyphicon glyphicon-edit"></i>
            <g:message code="book.edit" default="Edit book"/>
        </g:link>
        <g:link controller="book" action="delete" id="${book?.id}"  class="btn btn-danger btn-sm">
            <i class="glyphicon glyphicon-trash"></i>
            <g:message code="book.delete" default="Delete book"/>
        </g:link>
    </div>

    <div class="media">
        <div class="media-left">
            <img class="book-image" src="${book?.imageUrl ?: 'http://placekitten.com/g/128/192'}">
        </div>
        <div class="media-body">
            <h4 class="book-title"><bookshelf:bookTitle id="${book?.id}" languageCode="${RequestContextUtils.getLocale(request).language}"/> <small>${book?.publishedDate}</small></h4>
            <h5 class="book-author">
                <g:if test="${book?.author}">
                    <g:message code="book.author.by" args="${[book.author]}"/>
                </g:if>
                <g:else>
                    <g:message code="book.author.unknown" default="By Unknown"/>
                </g:else>
            </h5>
            <p class="book-description"><bookshelf:bookDescription  id="${book?.id}" languageCode="${RequestContextUtils.getLocale(request).language}"/></p>

            <small>Locale: ${RequestContextUtils.getLocale(request).language}</small><br/>

            <small class="book-added-by">
                <g:if test="${book?.createdBy}">
                    <g:message code="book.added.by" args="${[book.createdBy]}"/>
                </g:if>
                <g:else>
                    <g:message code="book.added.by.anonymous" default="Added by Anonymous"/>
                </g:else>
            </small>
        </div>
    </div>
</div>
</body>
</html>

The GSP uses a custom taglib to renders title or description depending on the current locale.

app/grails-app/taglib/com/example/getstarted/BookLocalizationTagLib.groovy
package com.example.getstarted

import com.example.getstarted.daos.DaoService
import com.example.getstarted.objects.BookLocalization
import grails.config.Config
import grails.core.support.GrailsConfigurationAware
import groovy.util.logging.Slf4j

@Slf4j
class BookLocalizationTagLib implements GrailsConfigurationAware {

    static namespace = 'bookshelf'

    static defaultEncodeAs = [taglib: 'html']

    String defaultLanguageCode

    DaoService daoService

    def bookTitle = { args ->
        String languageCode = args.languageCode ?: defaultLanguageCode
        Long bookId = args.id
        BookLocalization bookLocalization = daoService.getLocalization(bookId, languageCode)
        out << bookLocalization?.title
    }

    def bookDescription = { args ->
        String languageCode = args.languageCode ?: defaultLanguageCode
        Long bookId = args.id
        BookLocalization bookLocalization = daoService.getLocalization(bookId, languageCode)
        out << bookLocalization?.description
    }

    @Override
    void setConfiguration(Config co) {
        defaultLanguageCode = co.getProperty('bookshelf.defaultLanguageCode', String, 'en')
    }
}

3.5. Delete book

We map the home page to be handled by the BookController show action.

app/grails-app/controllers/com/example/getstarted/basicactions/UrlMappings.groovy
"/delete/$id"(controller: 'book', action: 'delete', method: 'GET')

The controller’s action deletes a book identify by its id.

app/grails-app/controllers/com/example/getstarted/basicactions/BookController.groovy
def delete(Long id) {
    daoService.deleteBook(id)
    redirect action: 'index'
}

3.6. Edit a book

To edit a book, we have a form which allows us to modify the metadata and/or upload a new cover image:

edit book

We map the display of the form to be handled by the BookController edit action.

app/grails-app/controllers/com/example/getstarted/basicactions/UrlMappings.groovy
"/update/$id"(controller: 'book', action: 'edit', method: 'GET')
app/grails-app/controllers/com/example/getstarted/basicactions/BookController.groovy
def edit(Long id) {
    [book: daoService.readBook(id)]
}

3.7. Update a book

We manage the edit form submission with the Controller’s update action.

app/grails-app/controllers/com/example/getstarted/basicactions/UrlMappings.groovy
"/update/$id"(controller: 'book', action: 'update', method: 'POST')
app/grails-app/controllers/com/example/getstarted/basicactions/BookController.groovy
def update(UpdateBookCommand cmd) {
    if ( cmd.hasErrors() ) {
        return
    }
    def book = cmd as Book

    if ( (
            book.createdById == null ||
                    book.createdBy == null
         ) && session[Oauth2CallbackController.SESSION_ATTRIBUTE_TOKEN] ) {
        book.createdBy = session[Oauth2CallbackController.SESSION_USER_EMAIL]
        book.createdById = session[Oauth2CallbackController.SESSION_USER_ID]
    }

    if ( cmd.file && !cmd.file.isEmpty() ) {
        String fileName = uploadBookCoverService.nameForFile(cmd.file)
        String imageUrl = googleCloudStorageService.storeMultipartFile(fileName, cmd.file)
        book.imageUrl = imageUrl
    }

    daoService.updateBook(book)
    redirect(action: 'show', id: book.id)
}

The update methods gets a Command Object which encapsulates the parameters binding and validation

app/grails-app/controllers/com/example/getstarted/basicactions/UpdateBookCommand.groovy
package com.example.getstarted.basicactions

import com.example.getstarted.objects.Book
import com.example.getstarted.objects.BookImpl
import grails.compiler.GrailsCompileStatic
import grails.validation.Validateable
import org.springframework.web.multipart.MultipartFile

@GrailsCompileStatic
class UpdateBookCommand implements Validateable {
    Long id
    String author
    String description
    String publishedDate
    String title
    MultipartFile file
    String imageUrl
    String createdById
    String createdBy

    static constraints = {
        id nullable: false
        title nullable: false
        author nullable: true
        description nullable: true
        publishedDate nullable: true
        file nullable: true
        imageUrl nullable: true
        createdById nullable: true
        createdBy nullable: true
    }

    Object asType(Class clazz) {
        if (clazz == Book) {
            def book = new BookImpl()
            copyProperties(this, book)
            return book
        }
        super.asType(clazz)
    }

    def copyProperties(source, target) {
        source.properties.each { key, value ->
            if (target.hasProperty(key as String) && !(key in ['class', 'metaClass'])) {
                target[key as String] = value
            }
        }
    }
}

4. Services

As you could see in the previous code snippets the business logic of the application is encapsulated in Grails Services.

4.1. Configuration

We setup several configuration values in application.yml We use these configuration parameters across multiple services.

app/grails-app/conf/application.yml
org:
    grails:
        plugins:
            googlecloud:
                storage:
                    bucket: grails-bookshelf.appspot.com
---
bookshelf:
    storageType: cloudSQL
    limit: 10
    orderBy: title
    callback: http://localhost:8080/oauth2callback
    defaultLanguageCode: en
    localizations:
        - it
        - es
---

4.2. Service integration Overview

Grails integrates with multiple Google Cloud Service to power the application.

overview

4.3. Handling Cover Image upload

app/grails-app/services/com/example/getstarted/CreateBookWithCoverImageService.groovy
package com.example.getstarted

import com.example.getstarted.daos.DaoService
import com.example.getstarted.objects.BookCurator
import com.example.getstarted.objects.BookImpl
import com.example.getstarted.objects.BookLocalization
import com.example.getstarted.objects.BookLocalizationImpl
import org.grails.plugins.googlecloud.storage.GoogleCloudStorageService
import org.grails.plugins.googlecloud.vision.GoogleCloudVisionService
import org.springframework.web.multipart.MultipartFile
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j

@SuppressWarnings('GrailsStatelessService')
@Slf4j
@CompileStatic
class CreateBookWithCoverImageService {

    UploadBookCoverService uploadBookCoverService

    GoogleCloudStorageService googleCloudStorageService

    GoogleCloudVisionService googleCloudVisionService

    DaoService daoService

    BookLocalization bookLocalizationWithFile(MultipartFile file) {
        def text = googleCloudVisionService.detectDocumentText(file.inputStream)
        bookLocalizationWithText(text)
    }

    BookLocalization bookLocalizationWithText(String text) {
        if (text == null ) {
            return null
        }
        final int titleMaxSize = 255

        String title = null
        String description = null

        if ( text.size() >= (titleMaxSize + 1) ) {
            title = text[0 .. (titleMaxSize - 1)]
            description = text[titleMaxSize .. (text.size() - 1)]

        } else {
            title = text
        }

        new BookLocalizationImpl(title: title, description: description)
    }

    String imageUrlWithFile(MultipartFile file) {
        String fileName = uploadBookCoverService.nameForFile(file)
        googleCloudStorageService.storeMultipartFile(fileName, file)
    }

    Long saveBookWithCover(MultipartFile file, BookCurator curator) {
        def book = new BookImpl()
        book.createdBy = curator.createdBy
        book.createdById = curator.createdById
        book.imageUrl = imageUrlWithFile(file)
        def bookLocalization = bookLocalizationWithFile(file)
        if ( bookLocalization ) {
            book.title = bookLocalization.title
            book.description = bookLocalization.description
        }
        daoService.createBook(book)
    }
}

When we upload a book cover image several things will happen:

  • The cover image is uploaded to Google Cloud Storage. This will provided use with an image url which we will store in the database book table row.

  • Text in the cover image is extracted by Google Cloud Vision API.

  • A book entry in a MySQL database provided by Google Cloud SQL.

  • Prior to saving or updating a book, the English title and description are translated to Spanish and Italian with Google Cloud Translation API. Those translations are saved to the database.

4.4. Uploading an image to Cloud Storage

We encapsulate the code which interacts with Google Cloud Storage in its own service:

grails-googlecloud-storage/grails-app/services/org/grails/plugins/googlecloud/storage/GoogleCloudStorageService.groovy
package org.grails.plugins.googlecloud.storage

import groovy.transform.CompileStatic

import com.google.cloud.storage.Acl
import com.google.cloud.storage.BlobId
import com.google.cloud.storage.BlobInfo
import com.google.cloud.storage.Storage
import com.google.cloud.storage.StorageOptions
import grails.config.Config
import grails.core.support.GrailsConfigurationAware
import groovy.util.logging.Slf4j
import org.springframework.web.multipart.MultipartFile

@Slf4j
@SuppressWarnings('GrailsStatelessService')
@CompileStatic
class GoogleCloudStorageService implements GrailsConfigurationAware {
    // Cloud Storage Bucket
    String bucket

    Storage storage = StorageOptions.defaultInstance.service

    String storeMultipartFile(String fileName, MultipartFile multipartFile) {
        log.info "Uploaded file ${multipartFile.originalFilename}"
        storeInputStream(fileName, multipartFile.inputStream)
    }

    String storeInputStream(String fileName, InputStream inputStream) {
        BlobInfo blobInfo = storage.create(readableBlobInfo(bucket, fileName), inputStream)
        log.info "Uploaded file as ${fileName} with mediaLink ${blobInfo.mediaLink}"

        blobInfo.mediaLink
    }

    String storeBytes(String fileName, byte[] bytes) {
        BlobInfo blobInfo = storage.create(readableBlobInfo(bucket, fileName), bytes)
        blobInfo.mediaLink
    }

    private static BlobInfo readableBlobInfo(String bucket, String fileName) {
        // Modify access list to allow all users with link to read file
        List<Acl> acl = [Acl.of(Acl.User.ofAllUsers(), Acl.Role.READER)]
        BlobInfo.newBuilder(bucket, fileName)
                .setAcl(acl)
                .build()
    }

    boolean deleteFile(String fileName) {
        BlobId blobId = BlobId.of(bucket, fileName)
        storage.delete(blobId)
    }

    @Override
    void setConfiguration(Config co) {
        bucket = co.getProperty('org.grails.plugins.googlecloud.storage.bucket', String)
    }
}

In order to use Google Cloud Storage we need to add the next dependency to our compile dependencies:

grails-googlecloud-storage/build.gradle
    compile "com.google.cloud:google-cloud-storage:$googleCloudStorageVersion"

4.5. Extracting the text of an image with Cloud Vision

We encapsulate the code which extracts the text in a Cover Image in a Grails Service

grails-googlecloud-vision/grails-app/services/org/grails/plugins/googlecloud/vision/GoogleCloudVisionService.groovy
package org.grails.plugins.googlecloud.vision

import com.google.cloud.vision.spi.v1.ImageAnnotatorClient
import com.google.cloud.vision.v1.AnnotateImageRequest
import com.google.cloud.vision.v1.AnnotateImageResponse
import com.google.cloud.vision.v1.BatchAnnotateImagesResponse
import com.google.cloud.vision.v1.Feature
import com.google.cloud.vision.v1.Feature.Type
import com.google.cloud.vision.v1.Image
import com.google.cloud.vision.v1.TextAnnotation
import com.google.protobuf.ByteString
import groovy.util.logging.Slf4j
import groovy.transform.CompileStatic

@Slf4j
@CompileStatic
class GoogleCloudVisionService {

    @SuppressWarnings(['ReturnNullFromCatchBlock', 'CatchException'])
    String detectDocumentText(InputStream inputStream) {
        try {
            List<AnnotateImageRequest> requests = []

            ByteString imgBytes = ByteString.readFrom(inputStream)

            Image img = Image.newBuilder().setContent(imgBytes).build()
            Feature feat = Feature.newBuilder().setType(Type.DOCUMENT_TEXT_DETECTION).build()
            AnnotateImageRequest request =
                    AnnotateImageRequest.newBuilder().addFeatures(feat).setImage(img).build()
            requests.add(request)

            BatchAnnotateImagesResponse response =
                    ImageAnnotatorClient.create().batchAnnotateImages(requests)
            List<AnnotateImageResponse> responses = response.responsesList

            List<String> responsesText = []
            for (AnnotateImageResponse res : responses) {
                if (res.hasError()) {
                    log.error "Error ${res.error.message}"
                    continue
                }

                TextAnnotation annotation = res.fullTextAnnotation
                responsesText << annotation.text
            }

            return responsesText*.replaceAll('\n', ' ').join(' ').trim().replaceAll(' +', ' ')

        } catch ( Exception e ) {
            log.error(e.message, e)
            return null
        }
    }
}

In order to use Google Cloud Vision we need to add the next dependency to our compile dependencies:

grails-googlecloud-vision/build.gradle
    compile "com.google.cloud:google-cloud-vision:$googleCloudVisionVersion"

Checkout Google Cloud Vision for complete overview of the image analysis options.

4.6. Translate Text with Cloud Translation API

The next service method would be invoked with code such as:

assert 'Hola Mundo' == googleCloudTranslateService.translateTextFromSourceToTarget('Hello World', 'en', 'es')
grails-googlecloud-translate/grails-app/services/org/grails/plugins/googlecloud/translate/GoogleCloudTranslateService.groovy
package org.grails.plugins.googlecloud.translate

import com.google.cloud.translate.Translate.TranslateOption
import com.google.cloud.translate.TranslateException
import com.google.cloud.translate.TranslateOptions
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j

@Slf4j
@CompileStatic
class GoogleCloudTranslateService {

    @SuppressWarnings('ReturnNullFromCatchBlock')
    String translateTextFromSourceToTarget(String text, String source, String target) {
        if ( !text ) {
            return text
        }
        try {
            return TranslateOptions.defaultInstance.service.translate(text,
                    TranslateOption.sourceLanguage(source),
                    TranslateOption.targetLanguage(target)).translatedText
        } catch (TranslateException e) {
            log.error(e.message, e)
            return null
        }
    }
}

In order to use Google Cloud Translation API wee need to add the next dependency:

grails-googlecloud-translate/build.gradle
    compile "com.google.cloud:google-cloud-translate:$googleCloudTranslateVersion"

4.7. Interact with Cloud SQL: MySQL database with GORM Hibernate implementation

We use GORM. GORM is the data access toolkit used by Grails and provides a rich set of APIs for accessing relational and non-relational data including implementations for Hibernate (SQL), MongoDB, Neo4j, Cassandra and an in-memory ConcurrentHashMap for testing.

We map books and localizations in a one-to-many relationship with the use of Grails Domain Classes:

app/grails-app/domain/com/example/getstarted/domain/BookGormEntity.groovy
package com.example.getstarted.domain

import grails.compiler.GrailsCompileStatic

@GrailsCompileStatic
class BookGormEntity {

    String author
    String createdBy
    String createdById
    String publishedDate
    String imageUrl
    static hasMany = [localizations: BookLocalizationGormEntity]

    static constraints = {
        author nullable: true, maxSize: 255
        createdBy nullable: true, maxSize: 255
        createdById nullable: true, maxSize: 255
        publishedDate nullable: true
        imageUrl    nullable: true
    }

    static mapping = {
        table 'book'
    }
}
app/grails-app/domain/com/example/getstarted/domain/BookLocalizationGormEntity.groovy
package com.example.getstarted.domain

import com.example.getstarted.objects.BookLocalization
import grails.compiler.GrailsCompileStatic

@GrailsCompileStatic
class BookLocalizationGormEntity implements BookLocalization {
    String title
    String description
    String languageCode
    static belongsTo = [book: BookGormEntity]

    static constraints = {
        title nullable: false, maxSize: 255
        description nullable: true
    }

    static mapping = {
        table 'book_localization'
        description type: 'text'
    }
}

We encapsulate the GORM database access in a service:

app/grails-app/services/com/example/getstarted/daos/CloudSqlService.groovy
package com.example.getstarted.daos

import com.example.getstarted.objects.BookImpl
import org.grails.plugins.googlecloud.translate.GoogleCloudTranslateService
import com.example.getstarted.domain.BookGormEntity
import com.example.getstarted.domain.BookLocalizationGormEntity
import com.example.getstarted.objects.Book
import com.example.getstarted.objects.BookLocalization
import com.example.getstarted.objects.Result
import grails.config.Config
import grails.core.support.GrailsConfigurationAware
import grails.gorm.DetachedCriteria
import grails.transaction.Transactional
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j

@Slf4j
@SuppressWarnings('GrailsStatelessService')
@CompileStatic
@Transactional
class CloudSqlService implements BookDao, GrailsConfigurationAware {

    int limit
    String defaultLanguageCode
    List<String> localizations

    GoogleCloudTranslateService googleCloudTranslateService

    @Override
    void setConfiguration(Config co) {
        limit = co.getProperty('bookshelf.limit', Integer, 10)
        defaultLanguageCode = co.getProperty('bookshelf.defaultLanguageCode', String, 'en')
        localizations = co.getProperty('bookshelf.localizations', List)
    }

    @SuppressWarnings('LineLength')
    @Override
    Long createBook(Book book) {
        BookGormEntity entity = new BookGormEntity()
        populateEntityWithBook(entity, book)
        if ( book.title != null ) {
            entity.addToLocalizations(new BookLocalizationGormEntity(languageCode: defaultLanguageCode,
                    title: book.title,
                    description: book.description))

            for (String languageCode : localizations ) {
                String title = googleCloudTranslateService.translateTextFromSourceToTarget(book.title, defaultLanguageCode, languageCode)
                String description = googleCloudTranslateService.translateTextFromSourceToTarget(book.description, defaultLanguageCode, languageCode)
                entity.addToLocalizations(new BookLocalizationGormEntity(languageCode: languageCode,
                        title: title,
                        description: description))
            }
        }

        entity.save()
        entity.id
    }

    @Transactional(readOnly = true)
    @Override
    Book readBook(Long bookId) {
        def entity = BookGormEntity.get(bookId)
        def book = new BookImpl()
        book.with {
            id = entity.id
            author = entity.author
            createdBy = entity.createdById
            createdById = entity.createdById
            publishedDate = entity.publishedDate
            imageUrl = entity.imageUrl
        }
        BookLocalization bookLocalization = getLocalization(bookId, defaultLanguageCode)
        book.title = bookLocalization?.title
        book.description = bookLocalization?.description
        book
    }

    @SuppressWarnings('LineLength')
    @Override
    void updateBook(Book book) {
        def entity = BookGormEntity.get(book.id)
        populateEntityWithBook(entity, book)
        addOrUpdateBookLocalizationWithTitleAndDescriptionByLanguageCode(entity, defaultLanguageCode, book.title, book.description)

        for (String languageCode : localizations ) {
            String title = googleCloudTranslateService.translateTextFromSourceToTarget(book.title, defaultLanguageCode, languageCode)
            String description = googleCloudTranslateService.translateTextFromSourceToTarget(book.description, defaultLanguageCode, languageCode)
            addOrUpdateBookLocalizationWithTitleAndDescriptionByLanguageCode(entity, languageCode, title, description)
        }

        entity.save()
    }

    @SuppressWarnings('LineLength')
    static void addOrUpdateBookLocalizationWithTitleAndDescriptionByLanguageCode(BookGormEntity entity, String languageCode, String title, String description) {
        BookLocalizationGormEntity defaultBookLocalizationGormEntity = entity.localizations.find { it.languageCode == languageCode }
        if ( defaultBookLocalizationGormEntity ) {
            defaultBookLocalizationGormEntity.title = title
            defaultBookLocalizationGormEntity.description = description
        } else {
            entity.addToLocalizations(new BookLocalizationGormEntity(languageCode: languageCode,
                    title: title,
                    description: description))
        }
    }

    @Override
    void deleteBook(Long bookId) {
        def entity = BookGormEntity.get(bookId)
        entity?.delete()
    }

    @Transactional(readOnly = true)
    @Override
    Result<Book> listBooks(String cursor) {
        def query = listBooksQuery()
        listBooksByQuery(cursor, query)
    }

    @Transactional(readOnly = true)
    @Override
    Result<Book> listBooksByUser(String userId, String cursor) {
        def query = listBooksByUserQuery(userId)
        listBooksByQuery(cursor, query)
    }

    @Transactional(readOnly = true)
    @Override
    BookLocalization getLocalization(Long bookId, String code) {
        String language = code ?: defaultLanguageCode
        def q = BookLocalizationGormEntity.where { book.id == bookId && languageCode == language }
        log.info 'code: ' + code + 'size: ' + q.list().size()
        q.get()
    }

    private DetachedCriteria<BookGormEntity> listBooksQuery() {
        BookGormEntity.where { }
    }

    private DetachedCriteria<BookGormEntity> listBooksByUserQuery(String userId) {
        BookGormEntity.where { createdById == userId }
    }

    private Result<Book> listBooksByQuery(String cursor, DetachedCriteria<BookGormEntity> query) {
        def offset = cursor ? Integer.parseInt(cursor) : 0
        def max = limit
        int total = query.count() as int
        def booksEntities = query.max(max).offset(offset).list()
        def books = collectBooks(booksEntities)
        if (total > (offset + limit)) {
            return new Result<>(books, Integer.toString(offset + limit))
        }
        new Result<>(books)
    }

    private List<Book> collectBooks(List<BookGormEntity> entities) {
        entities.collect { BookGormEntity entity ->
            BookImpl book = new BookImpl()

            BookLocalization bookLocalization = getLocalization(entity.id, defaultLanguageCode)
            book.with {
                id = entity.id
                author = entity.author
                createdBy = entity.createdBy
                createdById = entity.createdById
                publishedDate = entity.publishedDate
                imageUrl = entity.imageUrl
                title = bookLocalization?.title
                description = bookLocalization?.description
            }
            book
        } as List<Book>
    }

    private static void populateEntityWithBook(BookGormEntity entity, Book book) {
        entity.with {
            author = book.author
            createdBy = book.createdBy
            createdById = book.createdById
            publishedDate = book.publishedDate
            imageUrl = book.imageUrl
        }
    }
}

5. Authenticating Users with Grails

This part of the Bookshelf tutorial for Java shows how to create a sign-in flow for users and how to use profile information to provide users with personalized functionality.

By using Google Identity Platform, you can easily access information about your users while ensuring their sign-in credentials are safely managed by Google. OAuth 2.0 makes it easy to provide a sign-in flow for all users of your app and provides your application with access to basic profile information about authenticated users.

5.1. Creating a web application client ID

A web application client ID allows your application to authorize users and access Google APIs on behalf of your users.

  1. Go to the credentials section in the Google Cloud Platform Console.

  2. Click OAuth consent screen. For the the product name, enter Java Bookshelf App. Fill in any relevant optional fields. Click Save.

  3. Click Create credentials > OAuth client ID.

  4. Under Application type, select Web Application.

  5. Under Name, enter Java Bookshelf Client.

  6. Under Authorized redirect URIs enter the following URLs, one at a time. Replace [YOUR_PROJECT_ID] with your project ID: http://localhost:8080/oauth2callback http://[YOUR_PROJECT_ID].appspot.com/oauth2callback https://[YOUR_PROJECT_ID].appspot.com/oauth2callback http://[YOUR_PROJECT_ID].appspot-preview.com/oauth2callback https://[YOUR_PROJECT_ID].appspot-preview.com/oauth2callback`

  7. Click Create.

  8. Copy the client ID and client secret and save them for later use.

5.2. Configuring settings

In the app/grails-app/conf/ directory, open application-production.yml for editing.

  1. Set bookshelf.callback to [YOUR_PROJECT_ID].appspot.com//auth2callback.

  2. Set bookshelf.clientID to the client ID you created previously.

  3. Set bookshelf.clientSecret to the client secret you created previously.

  4. Save and close application-production.yml.

5.3. Application structure

The following diagram shows the application’s components and how they connect to one another.

java auth

5.4. Understanding the code

This section walks you through the application code and explains how it works.

The LoginController is invoked when the user clicks Login.

app/grails-app/controllers/com/example/getstarted/basicactions/LoginController.groovy
package com.example.getstarted.basicactions

import grails.config.Config
import grails.core.support.GrailsConfigurationAware
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j
import org.grails.plugins.googlecloud.authorization.GoogleAuthorizationService

@Slf4j
@CompileStatic
class LoginController implements GrailsConfigurationAware {

    static allowedMethods = [index: 'GET']
    public static final String SESSION_ATTRIBUTE_STATE = 'state'
    public static final String SESSION_ATTRIBUTE_LOGIN_DESTINATION = 'loginDestination'

    String defaultLoginDestination

    GoogleAuthorizationService googleAuthorizationService

    def index(String loginDestination) {
        String state = googleAuthorizationService.randomState() (1)
        def url = googleAuthorizationService.authorizationRedirectUrl(state)
        session.setAttribute(SESSION_ATTRIBUTE_STATE, state)
        String destination = loginDestination ?: defaultLoginDestination
        log.info "logging destination $destination"
        session.setAttribute(SESSION_ATTRIBUTE_LOGIN_DESTINATION, destination) (2)
        redirect(url: url)
    }

    @Override
    void setConfiguration(Config co) {
        defaultLoginDestination = co.getProperty('bookshelf.loginDestination', String, '/books')
    }
}

It does the following:

1 Save some random state to help prevent request forgeries.
2 Save the destination of where to go after sign in.

A service encapsulates the interaction with Google API Client Library, specifically GoogleAuthorizationCodeFlow. to generate a callback request to Google to handle signing in to a Google account. This app specifies the "email" and "profile" scopes, so it can display the user’s email and image on each page:

grails-googlecloud-authorization/grails-app/services/org/grails/plugins/googlecloud/authorization/GoogleAuthorizationService.groovy
package org.grails.plugins.googlecloud.authorization

import com.fasterxml.jackson.databind.ObjectMapper

import com.google.api.client.auth.oauth2.Credential
import com.google.api.client.auth.oauth2.TokenResponse
import com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeFlow
import com.google.api.client.http.GenericUrl
import com.google.api.client.http.HttpRequest
import com.google.api.client.http.HttpRequestFactory
import com.google.api.client.http.HttpTransport
import com.google.api.client.http.javanet.NetHttpTransport
import com.google.api.client.json.JsonFactory
import com.google.api.client.json.jackson.JacksonFactory
import grails.config.Config
import grails.core.support.GrailsConfigurationAware
import java.security.SecureRandom
import groovy.transform.CompileStatic

@SuppressWarnings('GrailsStatelessService')
@CompileStatic
class GoogleAuthorizationService implements GrailsConfigurationAware {
    private static final String USERINFO_ENDPOINT = 'https://www.googleapis.com/plus/v1/people/me/openIdConnect'

    private static final HttpTransport HTTP_TRANSPORT = new NetHttpTransport()
    private static final Collection<String> SCOPES = ['email', 'profile']
    private static final JsonFactory JSON_FACTORY = new JacksonFactory()

    String clientID
    String clientSecret
    String callback

    static String randomState() {
        new BigInteger(130, new SecureRandom()).toString(32)
    }

    /**
     *
     * @param state - Used to prevent request forgery
     * @return
     */
    String authorizationRedirectUrl(String state) {
        def flow = new GoogleAuthorizationCodeFlow.Builder(
                HTTP_TRANSPORT,
                JSON_FACTORY,
                clientID,
                clientSecret,
                SCOPES)
                .build()
        flow.newAuthorizationUrl()
                .setRedirectUri(callback)
                .setState(state)            // Prevent request forgery
                .build()
    }

    GoogleAuthorizationCodeFlow flow() {
        new GoogleAuthorizationCodeFlow.Builder(
                HTTP_TRANSPORT,
                JSON_FACTORY,
                clientID,
                clientSecret,
                SCOPES).build()
    }

    TokenResponse tokenResponse(GoogleAuthorizationCodeFlow flow, String authorizationCode) {
        flow.newTokenRequest(authorizationCode)
                .setRedirectUri(callback)
                .execute()
    }

    Map<String, String> useIdResultForTokenResponse(GoogleAuthorizationCodeFlow flow, TokenResponse tokenResponse) {
        final Credential credential = flow.createAndStoreCredential(tokenResponse, null)
        final HttpRequestFactory requestFactory = HTTP_TRANSPORT.createRequestFactory(credential)
        final GenericUrl url = new GenericUrl(USERINFO_ENDPOINT)      // Make an authenticated request.
        final HttpRequest request = requestFactory.buildGetRequest(url)
        request.headers.contentType = 'application/json'
        final String jsonIdentity = request.execute().parseAsString()
        new ObjectMapper().readValue(jsonIdentity, HashMap)
    }

    @Override
    void setConfiguration(Config co) {
        clientID = co.getRequiredProperty('bookshelf.clientID', String)
        clientSecret = co.getRequiredProperty('bookshelf.clientSecret', String)
        callback = co.getRequiredProperty('bookshelf.callback', String)
    }
}

Google redirects the user to the /oauth2callback URL. After the user successfully signs in, the Oauth2CallbackController index method does the following:

  1. Check that its not a forgery by comparing our state with the saved session state.

  2. Delete the saved session state.

  3. Get the response tokenResponse.

  4. Use the tokenResponse to get a Credential.

  5. Use the credential to create a requestFactory.

  6. Use the request to get the jsonIdentity.

  7. Extract email, a picture, and an ID.

  8. Redirect to the saved loginDestination from the prior step.

app/grails-app/controllers/com/example/getstarted/basicactions/Oauth2CallbackController.groovy
package com.example.getstarted.basicactions

import com.google.api.client.auth.oauth2.TokenResponse
import grails.config.Config
import grails.core.support.GrailsConfigurationAware
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j
import org.grails.plugins.googlecloud.authorization.GoogleAuthorizationService

import javax.servlet.http.HttpServletResponse

@Slf4j
@CompileStatic
class Oauth2CallbackController implements GrailsConfigurationAware {
    static allowedMethods = [index: 'GET']

    public static final String SESSION_ATTRIBUTE_TOKEN = 'token'
    public static final String SESSION_USER_ID = 'userId'
    public static final String SESSION_USER_EMAIL = 'userEmail'
    public static final String SESSION_USER_IMAGE_URL = 'userImageUrl'

    String defaultLoginDestination

    GoogleAuthorizationService googleAuthorizationService

    @SuppressWarnings('LineLength')
    def index(String state, String code) {

        // Ensure that this is no request forgery going on, and that the user
        // sending us this connect request is the user that was supposed to.
        if ( session[LoginController.SESSION_ATTRIBUTE_STATE] == null || !(state == session[LoginController.SESSION_ATTRIBUTE_STATE])) {
            response.status = HttpServletResponse.SC_UNAUTHORIZED
            redirect(uri: defaultLoginDestination)
            return
        }

        session.removeAttribute(LoginController.SESSION_ATTRIBUTE_STATE)     // Remove one-time use state.

        def flow = googleAuthorizationService.flow()
        final TokenResponse tokenResponse = googleAuthorizationService.tokenResponse(flow, code)

        session[SESSION_ATTRIBUTE_TOKEN] = tokenResponse.toString() // Keep track of the token.

        Map<String, String> userIdResult = googleAuthorizationService.useIdResultForTokenResponse(flow, tokenResponse)

        // From this map, extract the relevant profile info and store it in the session.
        session[SESSION_USER_EMAIL] = userIdResult['email']
        session[SESSION_USER_ID] = userIdResult['sub']
        session[SESSION_USER_IMAGE_URL] = userIdResult['picture']
        def destination = session[LoginController.SESSION_ATTRIBUTE_LOGIN_DESTINATION]
        log.info "Login successful, redirecting to $destination"
        redirect(uri: destination)
    }

    @Override
    void setConfiguration(Config co) {
        defaultLoginDestination = co.getProperty('bookshelf.loginDestination', String, '/books')
    }
}

The LogoutController deletes the session and creates a new one:

app/grails-app/controllers/com/example/getstarted/basicactions/LogoutController.groovy
package com.example.getstarted.basicactions

import groovy.transform.CompileStatic

@CompileStatic
class LogoutController {

    static allowedMethods = [index: 'GET']

    def index() {
        session.invalidate()
        redirect(controller: 'book', action: 'index')
    }
}

6. Logging Application Events with Grails

This part of the Bookshelf tutorial for Grails shows how an app can incorporate detailed logging to help with detecting, debugging, and monitoring potential issues.

6.1. Viewing logs

As the bookshelf app runs, it writes logging data that is collected and made available in the Cloud Platform Console. You can use the log monitoring tools in the Cloud Platform Console to analyze the logs directly. If you want more detailed analysis, you can use the Cloud Platform Console to stream or import the app’s logs into BigQuery or export them to a Cloud Storage Bucket.

dev console log monitor

6.2. Application structure

The following diagram shows how the app handles logging when deployed to the App Engine flexible environment.

java logs gae

6.3. Understanding the code

There are many Java logging packages that are compatible with the App Engine flexible environment. Grails 3 uses Logback logging framework

Grails artefacts (Controllers, Services …​) get injected a log property automatically. For any other class, you can inject a org.slf4j.Logger log property in any Groovy Class with the @Slf4j Local Transformation.

You can use the methods debug, info, warn, or error to log with different levels: DEBUG, INFO, WARNING, and ERROR.

Example:

app/grails-app/controllers/com/example/getstarted/basicactions/BookController.groovy
    def show(Long id) {
        log.info "Read book with id ${id}"
        [book: daoService.readBook(id)]
    }

Logging configuration can be configured in the grails-app/conf/logback.groovy file.

6.4. Understanding the logging configuration

When the sample app runs in the App Engine flexible environment, anything logged to stderr and stdout is automatically collected by Cloud Logging and available for viewing, searching, and exporting in the logs viewer in the Cloud Platform Console.

In this sample, all logs are written to stderr/stdout.

7. Cleaning up

To avoid incurring charges to your Google Cloud Platform account for the resources used in this tutorial:

7.1. Delete the project

The easiest way to eliminate billing is to delete the project you created for the tutorial.

To delete the project:

  1. In the Cloud Platform Console, go to the Projects page. GO TO THE PROJECTS PAGE

  2. In the project list, select the project you want to delete and click Delete project.

  3. In the dialog, type the project ID, and then click Shut down to delete the project.

delete project screenshot
Deleting a project has the following consequences:
If you used an existing project, you’ll also delete any other work you’ve done in the project.
You can’t reuse the project ID of a deleted project. If you created a custom project ID that you plan to use in the future, you should delete the resources inside the project instead. This ensures that URLs that use the project ID, such as an appspot.com URL, remain available.
If you are exploring multiple tutorials and quickstarts, reusing projects instead of deleting them prevents you from exceeding project quota limits.

7.2. Delete non-default versions of your app

If you don’t want to delete your project, you can reduce costs by deleting the non-default versions of your app.

To delete an app version:

  1. In the Cloud Platform Console, go to the App Engine Versions page. GO TO THE VERSIONS PAGE

  2. Click the checkbox next to the non-default app version you want to delete. You can overwrite the default version of your app by redeploying the app.**

  3. Click the Delete button at the top of the page to delete the app version.

The only way to delete the default version of your App Engine app is to delete your project. You can, however, stop the default version in the Cloud Platform Console. This will shut down all instances associated with the version. You can restart these instances later if needed.

7.3. Delete your Cloud Storage bucket

To delete a Cloud Storage bucket:

  1. In the Cloud Platform Console, go to the Cloud Storage browser. GO TO THE CLOUD STORAGE BROWSER

  2. Click the checkbox next to the bucket you want to delete.

  3. Click the Delete button at the top of the page to delete the bucket.