Build a blog application on Google App Engine: Comment module (part 7)

Sébastien Loix
Google Cloud - Community
11 min readDec 11, 2018

This is the seventh part of a multipart tutorial on how to build a small blog application in Node.js using the Google Datastore and deploy it to Google App Engine. If you missed the beginning, jump to the first part where I explain how to set up the project and where you’ll find the links to the other parts.

We have gone a long way and our application is almost done. In this section, we will build the Comment module that will, like the name implies, allow users to leave comments on a post. As a lot of the code is going to be similar to what we’ve been doing in other modules, I will not go into too much details. The main layers that we will need to create are:

  • A database layer.
  • A domain layer that will interact with the database layer.
  • A route handler layer to make the bridge between an HTTP Request and our domain layer.

Comment database

Just like for the BlogPost layer, the Comment layer will be a sub-module of our Blog module. Let’s start by adding our Comment Typescript types and then we will be ready to create the database access layer to interact with the Datastore.

Create a “comment” folder inside the “modules/blog” folder and in it, add a “models.ts” file with the following:

// modules/blog/comment/models.tsexport type CommentType = {
blogPost: number;
createdOn: Date;
createdOnFormatted?: string;
name: string;
comment: string;
website: string;
};

Good, now that we have our CommentType let’s build the database layer. Create a “comment.db.ts” file in the same folder and add the following:

// modules/blog/comment/comment.db.tsimport Joi from "joi";
import distanceInWords from "date-fns/distance_in_words";
import { Entity, Query, QueryListOptions } from "gstore-node";
import { Context } from "../models";
import { CommentType } from "./models";
export interface CommentDB {
getComments(
postId: number | string,
options?: QueryListOptions & { withVirtuals?: boolean }
): Promise<any>;
createComment(data: CommentType): Promise<CommentType>;
deleteComment(
id: number | string | (number | string)[]
): Promise<any>;
}
export default ({ gstore }: Context): CommentDB => {
const { Schema } = gstore;
/**
* We use "Joi" to validate this Schema
*/
const schema = new Schema<CommentType>(
{
blogPost: { joi: Joi.number() },
createdOn: {
joi: Joi.date().default(
() => new Date(),
"Current datetime of request"
),
write: false
},
// user name must have minimum 3 characters
name: { joi: Joi.string().min(3) },
// comment must have minimum 10 characters
comment: {
joi: Joi.string().min(10),
excludeFromIndexes: true
},
website: {
joi: Joi.string()
.uri() // validate url
.allow(null)
}
},
{ joi: true } // tell gstore that we will validate with Joi
);
/**
* We add a virtual property "createdOnFormatted" (not persisted
* in the Datastore) to display the date in our View
*/
schema
.virtual("createdOnFormatted")
.get(function getCreatedOnFormatted() {
return `${distanceInWords(
new Date(),
new Date(this.createdOn)
)} ago`;
});
/**
* Create a "Comment" Entity Kind Model passing our schema
*/
const Comment = gstore.model("Comment", schema);
/**
* DB API
*/
return {
async getComments(postId, options = { limit: 3 }) {
const query = Comment.query()
.filter("blogPost", postId)
.order("createdOn", { descending: true })
.limit(options.limit);
if (options.start) {
query.start(options.start);
}
const { entities, nextPageCursor } = await query.run({
format: "ENTITY"
});
return {
entities: (<Entity<CommentType>[]>entities).map(entity =>
// Return Json with virtual properties
entity.plain({ virtuals: !!options.withVirtuals })
),
nextPageCursor
};
},
async createComment(data) {
const entityData = Comment.sanitize(data);
const comment = new Comment(entityData);
const entity = await comment.save();
return entity.plain({ virtuals: true });
},
deleteComment(id) {
return Comment.delete(id);
}
};
};

You can see that for our Comment schema I’ve decided to validate the properties using Joi. Joi is a powerful object schema validation that lets you specify more granular rules (like the length of a string for example) than gstore validation (although any validation is possible through custom validation function but is not as straightforward as Joi). In many cases, the gstore validation is enough, but if you need finer rules you might want to go with Joi. Bear in mind that you will need to install the Joi npm package separately as it does not come by default when installing gstore-node.
One important note: you can’t mix both types of validation on the same schema and if you chose to go with Joi, you must specify it in the schema options object with { joi: true }. Read the docs for all the information.

After declaring the Comment schema, we use one of its helper method: virtual(). This method allows us to create virtual properties on our entities. Virtual properties are created dynamically and are not stored in the Datastore. These virtual properties are added to the entity data whenever we call the plain() method on an entity. For example:

const entityData = someEntity.plain({ virtuals: true });

For our Comment schema, we are declaring a createdOnFormatted virtual property. It will format the createdOn date property to a literal string (e.g. “1 hour ago”).

Below, in our API method getComments() you can see that we limit the number of result to 3 comments. And if a start option is provided (which corresponds to the nextPageCursor value from our previous query) we forward it to the Google Datastore Query object. This allows us to do pagination and have a “Load more” button in our view to fetch more comments.

Great! This is all for the database layer. Let’s now create the domain layer that will make use of it.

Comment domain

In the same folder, create a “comment.domain.ts” file and add the following:

// modules/blog/comment/comment.domain.tsimport { QueryListOptions } from "gstore-node";
import { CommentType } from "./models";
import { Context, Modules } from "../models";
export interface CommentDomain {
getComments(
postId: number | string,
options?: QueryListOptions & { withVirtuals?: boolean }
): Promise<any>;
createComment(
postId: number | string,
data: CommentType
): Promise<CommentType>;
deleteComment(
id: number | string | (number | string)[]
): Promise<any>;
}
export default (
_: Context,
{ commentDB }: Modules
): CommentDomain => {
const getComments = (
postId: number | string,
options: QueryListOptions & { withVirtuals?: boolean }
) => {
postId = +postId;
if (options.start) {
options.start = decodeURIComponent(options.start);
}
return commentDB.getComments(postId, options);
};
const createComment = (
postId: number | string,
data: CommentType
) => {
postId = +postId;
const entityData = { ...data, blogPost: postId };
return commentDB.createComment(entityData);
};
const deleteComment = (
id: number | string | (number | string)[]
) => commentDB.deleteComment(id);
return {
getComments,
createComment,
deleteComment
};
};

Not much to say, a simple CRUD interface that calls our DB layer. We need to provide the commentDB to the Modules Typescript type so open the “models.ts” file from our Blog module and make the following modifications:

// modules/blog/models.ts...import { BlogPostDomain } from "./blog-post/blog-post.domain";
import { CommentDB } from "./comment/comment.db"; // ***
import { CommentDomain } from "./comment/comment.domain"; // ***
...export type Modules = {
blogPost?: BlogPostModule;
blogPostDB?: BlogPostDB;
blogPostDomain?: BlogPostDomain;
commentDB?: CommentDB; // ***
commentDomain?: CommentDomain; // ***
images?: ImagesModule;
utils?: UtilsModule;
};
// *** = Add this line

And finally, let’s quickly add our Routes handlers to call our domain layer.

Comment Routes handlers

Create a “comment.routes-handlers.ts” file in the Comment module and add the following:

// modules/blog/comment/comment.routes-handlers.tsimport { Request, Response } from "express";
import { Context, Modules } from "../models";
export interface CommentRoutes {
getComments(req: Request, res: Response): any;
createComment(req: Request, res: Response): any;
deleteComment(req: Request, res: Response): any;
}
export default (
_: Context,
{ commentDomain }: Modules
): CommentRoutes => {
const getComments = async (req: Request, res: Response) => {
const postId = req.params.id;
let result;
try {
result = await commentDomain.getComments(postId, {
start: req.query.start,
limit: 3,
withVirtuals: true
});
} catch (err) {
return res.status(400).json(err);
}
res.json(result);
};
const createComment = async (req: Request, res: Response) => {
const postId = req.params.id;
let comment;
try {
comment = await commentDomain.createComment(postId, req.body);
} catch (err) {
return res.status(400).json(err);
}
res.json(comment);
};
const deleteComment = async (req: Request, res: Response) => {
const commentId = req.params.id;
let result;
try {
result = await commentDomain.deleteComment(commentId);
} catch (err) {
return res.status(400).json(err);
}
res.send(result);
};
return {
getComments,
createComment,
deleteComment
};
};

Here again, the code is self-explanatory. Three Express handlers for our routes. We will create the API routes in a minute but let’s first quickly initialize those 3 layers.
Create an “index.ts” file at the root of our Comment module and add the following:

// modules/blog/comment/index.tsimport initDB, { CommentDB } from "./comment.db";
import initRoutes, {
CommentRoutes
} from "./comment.routes-handlers";
import initDomain, { CommentDomain } from "./comment.domain";
import { Context } from "../models";
export * from "./models";export interface CommentModule {
commentDB: CommentDB;
commentDomain: CommentDomain;
routesHandlers: CommentRoutes;
}
export default (context: Context): CommentModule => {
const commentDB = initDB(context);
const commentDomain = initDomain(context, { commentDB });
const routesHandlers = initRoutes(context, { commentDomain });
return {
commentDB,
commentDomain,
routesHandlers
};
};

This is very similar to what we’ve done with our BlogPost module. We initialize our 3 layers and export them. We now need to add our Comment module to the Blog Modules type.

// modules/blog/models.ts...
import { BlogPostDomain } from "./blog-post/blog-post.domain";
import { CommentModule } from "./comment"; // Add this line
import { CommentDB } from "./comment/comment.db";
...
export type Modules = {
blogPost?: BlogPostModule;
comment?: CommentModule; // Add this line
...
};

And to the module initialization…

// modules/blog/index.ts...
import initBlogPost, { BlogPostModule } from "./blog-post";
import initComment, { CommentModule } from "./comment"; // Add this
import { Context, Modules } from "./models";export interface BlogModule {
webRouter: Router;
apiRouter: Router;
blogPost: BlogPostModule;
comment: CommentModule; // Add this line
}
export default (context: Context, modules: Modules): BlogModule => {
const comment = initComment(context); // Add this line
// Edit the following line:
const blogPost = initBlogPost(context, { ...modules, comment });
const { webRouter, apiRouter } = initRoutes(context, {
blogPost,
comment, // Add this line
});
return {
webRouter,
apiRouter,
blogPost,
comment // Add this line
};
};

Let’s now create our REST API routes, which will allow us to add/delete and list comments for a blog post.
Open the “blog.routes.ts” file and add the following:

// modules/blog/blog.routes.tsimport express from "express";
import bodyParser from "body-parser"; // Add this line
import { Context, Modules } from "./models";
export default (
context: Context,
{ blogPost, comment }: Modules
) => {
// WEB
...
// API
const apiRouter = express.Router();
apiRouter.delete("/blog/:id", blogPost.routesHandlers.deletePost);
apiRouter.get(
"/blog/:id/comments",
comment.routesHandlers.getComments
);
apiRouter.post(
"/blog/:id/comments",
// We need the bodyParser middleware to parse the form data
bodyParser.json(),
comment.routesHandlers.createComment
);
apiRouter.delete(
"/comments/:id",
comment.routesHandlers.deleteComment
);
};

Great! We have a REST API to manage comments, go ahead and create a few comments on one of your posts. You can also test the gstore validation by passing a wrong URL for the website field or a comment with less than 10 characters for example. You should receive a validation error… nice! I hope that you start seeing the benefit of schema validation :)

Cleaning up comments

Just like with our featured image, we want to delete all the comments associated with a post when we delete it. You probably guessed it, we need another middleware that we will hook after a BlogPost entity is deleted from the Datastore.

Let’s first add a handler to delete the comments of a BlogPost entity:

// modules/blog/comment/comment.db.ts...export interface CommentDB {
...
deleteComment(
id: number | string | (number | string)[]
): Promise<any>;
deletePostComment(postId: number): Promise<any>; // Add this line
}
export default ({ gstore }: Context): CommentDB => { ... /**
* DB API
*/
return {
...
deleteComment(id) {
return Comment.delete(id);
},
async deletePostComment(postId) {
/**
* A keys-only query returns just the keys of the entities
* instead of the entities data, at lower latency and cost.
*/
const { entities } = await Comment.query()
.filter("blogPost", postId)
.select("__key__")
.run();
const keys = (entities as Array<any>).map(
entity => entity[gstore.ds.KEY]
);
/**
* Use @google-cloud/datastore datastore.delete() APi to
* delete the keys.
* Reminder: gstore.ds is an alias to the underlying
* google datastore instance.
*/
return gstore.ds.delete(keys);
}
};
};

Let’s now create the middleware that will use this handler. Open the “blog-post.db.hooks.ts” file:

// modules/blog/blog-post/blog-post.db.hooks.tsimport { Entity } from 'gstore-node';
import { DatastoreKey } from '@google-cloud/datastore/entity';
...
export default (
{ gstore }: Context,
{ images, utils, commentDB }: Modules // Edit this line
) => {
... /**
* Delete all the comments of a BlogPost after it has been deleted
*
* @param {*} key The key of the entity deleted
*/
function deleteComments({ key }: { key: DatastoreKey }) {
const { id } = key;
return commentDB.deletePostComment(+id);
}
return {
initEntityData,
deletePreviousImage,
deleteFeatureImage,
deleteComments // Add this line
};
};

And finally, we need to attach this middleware to our BlogPost schema.

// modules/blog/blog-post/blog-post.domain.ts...export default (
context: Context,
{ blogPostDB, images, utils, comment }: Modules
): BlogPostDomain => {
/**
* Add "pre" and "post" hooks to our Schema
*/
const {
initEntityData,
deletePreviousImage,
deleteFeatureImage,
deleteComments // Add this
} = initDBhooks(context, {
images,
utils,
comment
});
blogPostDB.addPreSaveHook([deletePreviousImage, initEntityData]);
blogPostDB.addPreDeleteHook(deleteFeatureImage);
blogPostDB.addPostDeleteHook(deleteComments); // Add this
...
};

Great! We can now delete a blog post and all the comments associated with it.

And with that, our Comment module is completed. I hope that by now you start seeing the benefits of the architecture that we’ve put in place for our application. By organizing our application in modules and layers and connecting them through inputs and outputs, our code is very scalable, testable and if a bug arises, we should be able to locate it very quickly.
We could also easily change/swap the database layer of any of our modules and provide a new one to our domain layer (which does not care where the data comes from), and our application would work exactly as before.

One last touch…

Let’s add the last detail to our blog application. If you remember, when we created the BlogPost module, we declared an excerpt property on our BlogPost schema. An excerpt is a small extract from our post that we want to show on our home page as a preview of the post content. We could create a virtual property for it but let’s add it in our initData() middleware that we already have.

// modules/blog/blog-post/blog-post.hooks.tsimport R from 'ramda';
...
export default (
{ gstore }: Context,
{ images, utils }: Modules
) => {
/**
* Initialize the entityData before saving it in the Datastore
*/
function initEntityData(): Promise<any> {
/**
* Reminder: "compose" execute the functions from right --> left
*/
this.entityData = R.compose(
createExcerpt,
addCloudStorageData
)(this.entityData);
return Promise.resolve();
}
function addCloudStorageData(entityData: any) { ... } /**
* Generate the excerpt from the "content" value
*/
function createExcerpt(entityData: any) {
return {
...entityData,
excerpt: utils.string.createExcerpt(entityData.content)
};
}
...
};

We are using the Ramda compose() method to chain our entity data initialization. I do admit that adding a new dependency just for that is overkill… but I wanted to add a bit of functional programming to close this tutorial! :) Go ahead and create a new post, you should now have a short extract of your post appearing on the home page.

And with that, our application is complete! Thanks for reading, I hope I didn’t lose you along the way, please reach out in the comments if you have any question.
This is my first long tutorial and I have to admit it is not easy to find the right balance between what needs to be explained and what not :) I hope I managed to get you excited in trying to build your own Node.js app on Google App Engine and use the Datastore to store your content.

The last thing we need to do is to deploy our application on Google Cloud and make it available to our users.

We will do that in the next and final part of this tutorial, let’s go for it!

--

--