Builder Book

  1. Introduction
  2. App structure. Next.js. HOC. Material-UI. Server-side rendering. Styles.
  3. Server. Database. Session. Header and MenuDrop components.
  4. Authentication HOC. Promise. Async/await. Static method for User model. Google OAuth.
  5. Testing with Jest. Debugging with Winston. Transactional emails. In-app notifications.
  6. Book and Chapter models. Internal API. Render chapter.
  7. Github integration. Admin dashboard. Testing Admin UX and Github integration.
  8. Table of Contents. Highlight for section. Hide Header. Mobile browser.
  9. BuyButton component. Buy book logic. ReadChapter page. Checkout flow. MyBooks page. Mailchimp API. Deploy app.

Chapter 5: Book and Chapter models. Internal API. Render chapter.

We keep the book up-to-date with the latest frameworks and packages.


In Chapter 5, you'll start with the codebase in the 5-start folder of our builderbook repo and end up with the codebase in the 5-end folder. We'll cover the following topics in this chapter:

  • Book model
    - Schema for Book
    - Static methods for Book

  • Chapter model
    - Schema for Chapter
    - Static methods for Chapter
    - Index in MongoDB

  • Internal APIs
    - Intro to Express routes
    - Basics of internal API
    - Express routes
    - API methods
    - Pages

  • RenderChapter page
    - Express route
    - API method getChapterDetail()
    - Page

  • Testing


In the previous chapter (Chapter 4), you learned testing with Jest and debugging with Winston, integrated AWS SES to send transactional emails, and created a Notifier component to show users success, error and informational in-app messages.

In this chapter (Chapter 5), we introduce the Book and Chapter models (you will be able to sell a book once you add a paywall and integrate Stripe in Chapter 8). We will write an integration to access Github's API, so we can use Github as our content management system (CMS). In particular, we'd like to host all of our chapter content on Github, then sync this content with our database and render the content inside of our app.

As discussed in the Introduction chapter, our app has two types of users - an Admin who writes a book and a Customer who buys and reads the book. We will gradually discuss and introduce dashboards for each user. For example, an Admin should be able to create a book and set the book's price; a Customer should be able to see a list of purchased books and all available books.

link Book model

In this section, we'll introduce a simplified Book model. Simplified means that this model will not have any code related to Github API or Stripe API. We'll cover Github integration in Chapter 6 and Stripe integration in Chapter 8.

At this point in the book, you've successfully created User and EmailTemplate models. From the User model, you learned how to create Mongoose's schema and model:

const { Schema } = mongoose;

const mongoSchema = new Schema({
    // parameters
});

const Book = mongoose.model('Book', mongoSchema);

You also learned (in Chapter 3) how to add static methods to a model by using Mongoose's class. These static methods typically create and edit documents in a collection:

class BookClass { 
    // methods
};

mongoSchema.loadClass(BookClass);

Based on what you learned, we can create the Book model in two steps:

  • discuss and add parameters to the Book's Schema (parameters such as name and price)
  • discuss and write static methods, then add them to the Book's Class, BookClass (methods such as add and edit)

Here's the carcass of the Book model (it's similar to any other model, like our User model):
server/models/Book.js :

import mongoose from 'mongoose';

const { Schema } = mongoose;

const mongoSchema = new Schema({
    // parameters
});

class BookClass {
    // methods
}

mongoSchema.loadClass(BookClass);

const Book = mongoose.model('Book', mongoSchema);

export default Book;

link Schema for Book

For our Book object, we want four common-sense parameters: name, slug (generated from slug), createdAt, price:

const { Schema } = mongoose;

const mongoSchema = new Schema({
    name: {
        type: String,
        required: true,
    },
    slug: {
        type: String,
        required: true,
        unique: true,
    },
    createdAt: {
        type: Date,
        required: true,
    },
    price: {
        type: Number,
        required: true,
    },
    githubRepo: {
        type: String,
        required: true,
    },
    githubLastCommitSha: String,
});

The parameters githubRepo and githubLastCommitSha are related to integration with Github. We host a book on Github as a repository. This book repo contains a list of .md files, each of which corresponds to one chapter.

The githubRepo parameter is the name of the repo on Github that contains our book's content (chapters). For example, for our first test book, githubRepo will have the value:

"githubRepo": "builderbook/builderbook",

This is the repo named builderbook inside the organization builderbook.

The githubLastCommitSha parameter is the ID of the latest commit and may look like:

"githubLastCommitSha": "908c5d7d28531ea85a451193eb3b6535c619700d"

We will discuss these Github-related parameters in more detail in Chapter 6, although we added the parameters to our Book model Schema right now.

link Static methods for Book

Alright, we are done with Schema. Now let's define static methods inside the BookClass.

You learned about Mongoose's class properties in Chapter 3 when we discussed UserClass and added static methods publicFields() (specifies public parameters for user object) and signInOrSignUp() (either finds an existing user or creates a new user).

We will have four static methods for BookClass:

  1. list() retrieves a list of all books. When constructing our app's internal APIs, we'll use this method to display a list of all available or purchased books.
  2. getBySlug() finds one unique book by its slug. We'll use it to display a single book - for example, when a Customer reads a book.
  3. add() adds a new book to our Book collection. We'll use it in our admin's internal API (later in this chapter).
  4. edit() finds and edits a book's name, price, or githubRepo. Like the add() method, only an admin can access this method, so we'll use it in our admin's internal API.

To summarize what we just discussed:

class BookClass {
    static async list({ offset = 0, limit = 10 } = {}) {
        // some code
    }

    static async getBySlug({ slug }) {
        // some code
    }

    static async add({ name, price, githubRepo }) {
        // some code
    }

    static async edit({
        id, name, price, githubRepo,
    }) {
        // some code
    }
}

mongoSchema.loadClass(BookClass);

1) The static and async list() method (static async list()) takes two arguments: offset and limit. The method waits (await) until all books are found (this.find()) and returns an array of book objects ({}). Inside the list() method, we apply three MongoDB methods to reorganize the array of book objects: .sort(), .skip(), .limit:

static async list({ offset = 0, limit = 10 } = {}) {
    const books = await this.find({})
        .sort({ createdAt: -1 })
        .skip(offset)
        .limit(limit);
    return { books };
}

.sort({ createdAt: -1 }) sorts book objects by creation date, from the most to least recently created.

.skip(offset) with offset = 0 ensures that we do not skip any books.

.limit(limit) and limit=10 returns no more than 10 books. If we return too many books, MongoDB's query time may be high and user-unfriendly.

The default value for the .skip() method is zero, so we don't need to specify it explicitly. However, let's keep the offset argument. We may need later if we decide to add pagination to our list of books.

2) The static and async getBySlug() method (static async getBySlug()) takes one argument: slug. The main method waits (await) until Mongoose's this.findOne() method finds one book (slug is unique, take a look above at the Book's model Schema). If a book can't be found - we throw error:

throw new Error('Book not found');

Otherwise, we take the book document we found and convert it into a plain JavaScript object by using Mongoose toObject() method:

const book = bookDoc.toObject();
static async getBySlug({ slug }) {
    const bookDoc = await this.findOne({ slug });
    if (!bookDoc) {
        throw new Error('Book not found');
    }

    const book = bookDoc.toObject();

    return book;
}

We are not done with the getBySlug() method just yet. Before we return a JS object from our book with return book;, we want to retrieve the book's chapters. Retrieving the book along with its chapters is useful for building a Table of Contents (link to Chapter 6). To find all chapters of a particular book, we use Mongoose's Chapter.find(). We search for all chapters with the proper bookId value (bookId: book._id). For each chapter, we retrieve title and slug.

We sort our array of chapters with the order parameter and go through each chapter document in our array with the .map JS method.

We convert each chapter document into a plain JS object with Mongoose's toObject() method:

static async getBySlug({ slug }) {
    const bookDoc = await this.findOne({ slug });
    if (!bookDoc) {
        throw new Error('Book not found');
    }

    const book = bookDoc.toObject();

    book.chapters = (await Chapter.find({ bookId: book._id }, 'title slug')
        .sort({ order: 1 }))
        .map(chapter => chapter.toObject());
    return book;
}

3) The static add() method (static async add()) takes three arguments: book name, price, and githubRepo. This method generates a unique slug for a book by using the generateSlug() function. The method then returns a Promise (recall from Chapter 3) to create a new book with all parameters except githubLastCommitSha (this parameter will be added later - after book creation when we sync the book's content with its Github repo):

static async add({ name, price, githubRepo }) {
        const slug = await generateSlug(this, name);

    if (!slug) {
        throw new Error(`Error with slug generation for name: ${name}`);
    }

    return this.create({
        name,
        slug,
        price,
        githubRepo,
        createdAt: new Date(),
    });
}

4) The static and async edit() method (static async edit()) takes four parameters: id, name, price and githubRepo. This method finds one book by its id with Mongoose's findById() method (this method uses Mongo's findOne() method).

Unlike the getBySlug() method, we do not convert the book's document into a plain JS object with edit(), so we can use the book variable instead of bookDoc. Waiting (await) to find one book by its id, then retrieving the book's slug and name will look like:

const book = await this.findById(id, 'slug name');

Similar to the getBySlug() method, if a book is not found, we throw an error throw new Error('Book is not found by id'); and catch error later when we write our internal APIs.

If a book is found, we define a modifier variable that points to an array of two parameters:

const modifier = { price, githubRepo };

Then we check if the book's name in our database (book.name) matches a new name (name !== book.name). If it does not, we add a new name to our modifier by extending it (modifier.name = name;). We also generate and add slug to our modifier:

modifier.slug = await generateSlug(this, name);

Finally, for book found by its id, we modify the book's parameters (name, price and githubRepo) with Mongoose/Mongo's this.updateOne() method. We replace the values of all four parameters (name, slug, price, githubRepo) with new values by using the well-known $set operator that does just that.

After translating English to JavaScript, we get:

static async edit({
    id, name, price, githubRepo,
}) {
    const book = await this.findById(id, 'slug name');

    if (!book) {
        throw new Error('Book is not found by id');
    }

    const modifier = { price, githubRepo };

    if (name !== book.name) {
        modifier.name = name;
        modifier.slug = await generateSlug(this, name);
    }

    await this.updateOne({ _id: id }, { $set: modifier });
    const editedBook = await this.findById(id, 'slug');
    return editedBook;
}

Done. We are ready to put it all together for our Book model.

To continue reading, buy this book.

We keep the book up-to-date with the latest frameworks and packages.


format_list_bulleted
help_outline