May 19, 2019

Loopback 4 Todo List API with MongoDB

Building a Todo List API in minutes with Loopback 4 and MongoDB

Loopback 4 Todo List API with MongoDB

In this post we will look at creating a ToDo List API using Loopback 4 and MongoDB.

Introduction

I am a big fan of building backend APIs with NodeJS and Express, however I often find myself creating/copying a lot of the same code over and over again. I recently started a new project, and was looking for away to simply the creating of my API. There are a lot of backend frameworks to choose from, but I finally decided on Loopback 4.

Loopback has been around for a while, however they recently released version 4, which is a huge change from previous versions. In this post, we will look at creating a basic Todo list API using Loopback 4 and MongoDB.

Installing Loopback 4 CLI

The first think we have to do is install Loopback's CLI. Open a terminal and execute the following.

npm i -g @loopback/cli

This will install the latest version for us, which at the time of this writing was @loopback/cli version: 1.13.0

Create Project

After the CLI is installed, lets use it to create our application.

$ lb4 app
? Project name: todo-list
? Project description: ToDo List API written in Loopback 4 and MongoDB
? Project root directory: todo-list
? Application class name: TodoListApplication
? Select features to enable in the project (Press <space> to select, <a> to toggle all, <i> to invert selection)
❯◉ Enable tslint: add a linter with pre-configured lint rules
 ◉ Enable prettier: install prettier to format code conforming to rules
 ◉ Enable mocha: install mocha to run tests
 ◉ Enable loopbackBuild: use @loopback/build helpers (e.g. lb-tslint)
 ◉ Enable vscode: add VSCode config files
 ◉ Enable docker: include Dockerfile and .dockerignore
 ◉ Enable repositories: include repository imports and RepositoryMixin
(Move up and down to reveal more choices)

I am settings the name of our application to todo-list, and giving it a description. We use the defaults for the rest of the options.

Loopback 4 Project Structure

Navigating through the project that was just created for us, there is a lot to take in. For now, let's focus on the key concepts of Loopback 4.

Concept Description
Model Similar to other frameworks, a model is used to define the format of a piece of data. Unlike other systems, you do not add your operations(create, read, update, delete) to the model itself. Personally, I like that the model definition and the operations are kept separately.
DataSource A DataSource is where our API is going to store/fetch data from. This could be in-memory storage, from a file, or in our case from a MongoDB database.
Repository This took me the longest to understand as it is the biggest change compared to other frameworks I have used. A repository is an abstraction layer between your model and your controller. The model defines the format of the data, the repository add the type of behavior you can do with the model, and the controller exposes the API endpoints and interacts with the repository.
Controller Compared to writing an Express API, the controller is where you put your API endpoint logic and handle requests/responses to your API.

For more information on the folder structure, be sure the checking the official documentation here.

Models

We start our journey by creating our models. For our API, we are going to provide the user the ability to have different todo lists, and have multiple todo's inside each list. For this, we are going to create a Todo model and a TodoList model.

ToDo Model

Using Loopback's CLI, we can create the model by providing the model's name, type, and the properties inside our model.

Our Todo model will have the following properties

Property Description
id This will be the MongoDB ID. Note that we are setting it to type string
title this will be the title of our todo item
description This is an optional field, where the user can provide more details for the todo item
isComplete a boolean value when identifies if the item is complete or not

Using the lb4 command in the terminal, we create our model like so ...

$ lb4 model
? Model class name: Todo
? Please select the model base class Entity (A persisted model with an ID)
? Allow additional (free-form) properties? No
Let's add a property to Todo
Enter an empty property name when done

? Enter the property name: id
? Property type: string
? Is id the ID property? Yes
? Is it required?: No
? Default value [leave blank for none]:

Let's add another property to Todo
Enter an empty property name when done

? Enter the property name: title
? Property type: string
? Is it required?: Yes
? Default value [leave blank for none]:

Let's add another property to Todo
Enter an empty property name when done

? Enter the property name: description
? Property type: string
? Is it required?: No
? Default value [leave blank for none]:

Let's add another property to Todo
Enter an empty property name when done

? Enter the property name: isComplete
? Property type: boolean
? Is it required?: No
? Default value [leave blank for none]:

Let's add another property to Todo
Enter an empty property name when done

? Enter the property name:
   create src/models/todo.model.ts
   update src/models/index.ts

Model Todo was created in src/models/

TodoList Model

We are going to do the same for our Todo List model.

$ lb4 model
? Model class name: TodoList
? Please select the model base class Entity (A persisted model with an ID)
? Allow additional (free-form) properties? No
Let's add a property to TodoList
Enter an empty property name when done

? Enter the property name: id
? Property type: string
? Is id the ID property? Yes
? Is it required?: No
? Default value [leave blank for none]:

Let's add another property to TodoList
Enter an empty property name when done

? Enter the property name: title
? Property type: string
? Is it required?: Yes
? Default value [leave blank for none]:

Let's add another property to TodoList
Enter an empty property name when done

? Enter the property name:
   create src/models/todo-list.model.ts
   update src/models/index.ts

Model TodoList was created in src/models/

Note that we are only specifying the id and title of the todo list. We will manually define the relationship between the todo list and todo later on. For now, lets continue with building out the boilerplate for our API.

Create MongoDB DataSource

Using Loopback's datasource command, we can tell our API where to store/fetch data from. For our example, we select MongoDB, and provide it with the connection information.

$ lb4 datasource
? Datasource name: db
? Select the connector for db: MongoDB (supported by StrongLoop)
? Connection String url to override other settings (eg: mongodb://username:password@hostname:port/database):
? host: localhost
? port: 27017
? user:
? password: [hidden]
? database: todo-list
? Feature supported by MongoDB v3.1.0 and above: Yes
   create src/datasources/db.datasource.json
   create src/datasources/db.datasource.ts
npm WARN todo-list@1.0.0 No license field.

+ loopback-connector-mongodb@4.2.0
added 10 packages from 14 contributors and audited 3961 packages in 14.925s
found 3 vulnerabilities (1 low, 2 moderate)
  run `npm audit fix` to fix them, or `npm audit` for details
   update src/datasources/index.ts

Datasource db was created in src/datasources/

Notice that the CLI automatically installs the loopback-connector-mongodb package for us. This is a library which loopbacks uses to communicate with MongoDB. This simplifies our code, because we don't have to handle connecting/disconnecting with the DB, nor do we have to worry about creating our own queries. This can a pro and a con at the same time.

Repositories

Up next is our repositories. Here, we are linking our datasource and our model together. Although outside of the scope of this tutorial, you have the ability to have a different datasource for each repository. This allows you to interact with data stored in different sources relatively easily.

TodoList Repository

$ lb4 repository
? Please select the datasource DbDatasource
? Select the model(s) you want to generate a repository TodoList? Please select the repository base class DefaultCrudRepository (Legacy juggler bridge)
   create src/repositories/todo-list.repository.ts
   update src/repositories/index.ts

Repository TodoListRepository was created in src/repositories/

Create ToDo Repository

$ lb4 repository
? Please select the datasource DbDatasource
? Select the model(s) you want to generate a repository Todo? Please select the repository base class DefaultCrudRepository (Legacy juggler bridge)
   create src/repositories/todo.repository.ts
   update src/repositories/index.ts

Repository TodoRepository was created in src/repositories/

Controllers

The last part is to generate our controllers. As part of our business logic, each todo must be linked to a todo list. To achieve this, we are going to create two controllers. One to handle our TodoList, and one to handle our todo's within each list. You will notice that we are not exposing a todo API directory. This is because we do not want to allow the user to create a todo item which is not assigned to a todo list.

TodoList Controller

$ lb4 controller
? Controller class name: TodoList
? What kind of controller would you like to generate? REST Controller with CRUD functions
? What is the name of the model to use with this CRUD repository? TodoList
? What is the name of your CRUD repository? TodoListRepository
? What is the type of your ID? string
? What is the base HTTP path name of the CRUD operations? /todo-lists
   create src/controllers/todo-list.controller.ts
   update src/controllers/index.ts

Controller TodoList was created in src/controllers/

Loopback's CLI walks us through the process by asking for the controller's name, the type of controller the repository it is linked to, the ID type, and the API endpoint we want to use.

TodoListTodo Controller

For this controller, we are going to choose an empty controller and code it ourselves. Because it will be nested within the TodoList controller, there are a few changes you will notice compared to the standard CRUD operations.

$ lb4 controller
? Controller class name: TodoListTodo
? What kind of controller would you like to generate? Empty Controller
   create src/controllers/todo-list-todo.controller.ts
   update src/controllers/index.ts

Controller TodoListTodo was created in src/controllers/

todo-list-todo.controller.ts

import {TodoListRepository} from './../repositories/todo-list.repository';
import {
  repository,
  Filter,
  CountSchema,
  Where,
  Count,
} from '@loopback/repository';
import {
  post,
  requestBody,
  param,
  get,
  patch,
  getWhereSchemaFor,
  del,
} from '@loopback/rest';
import {Todo} from '../models';

export class TodoListTodoController {
  constructor(
    @repository(TodoListRepository)
    protected todoListRepo: TodoListRepository,
  ) {}

  @post('/todo-lists/{id}/todos', {
    responses: {
      '200': {
        description: 'TodoList.Todo model instance',
        content: {
          'application/json': {schema: {'x-ts-type': Todo}},
        },
      },
    },
  })
  async create(
    @param.path.string('id') id: string,
    @requestBody() todo: Todo,
  ): Promise<Todo> {
    return await this.todoListRepo.todos(id).create(todo);
  }

  @get('/todo-lists/{id}/todos', {
    responses: {
      '200': {
        description: "Array of Todo's belonging to TodoList",
        content: {
          'application/json': {
            schema: {type: 'array', items: {'x-ts-type': Todo}},
          },
        },
      },
    },
  })
  async find(
    @param.path.string('id') id: string,
    @param.query.object('filter') filter?: Filter,
  ): Promise<Todo[]> {
    return await this.todoListRepo.findTodos(id);
    // return await this.todoListRepo.todos(id).find(filter);
  }

  @patch('/todo-lists/{id}/todos', {
    responses: {
      '200': {
        description: 'TodoList.Todo PATCH success count',
        content: {'application/json': {schema: CountSchema}},
      },
    },
  })
  async patch(
    @param.path.string('id') id: string,
    @requestBody() todo: Partial<Todo>,
    @param.query.object('where', getWhereSchemaFor(Todo))
    where?: Where,
  ): Promise<Count> {
    return await this.todoListRepo.todos(id).patch(todo, where);
  }

  @del('/todo-lists/{id}/todos', {
    responses: {
      '200': {
        description: 'TodoList.Todo DELETE success count',
        content: {'application/json': {schema: CountSchema}},
      },
    },
  })
  async delete(
    @param.path.string('id') id: string,
    @param.query.object('where', getWhereSchemaFor(Todo))
    where?: Where,
  ): Promise<Count> {
    return await this.todoListRepo.todos(id).delete(where);
  }
}

And that's it! By using the Loopback 4's CLI, we were about to generate all of our API logic. I don't know about you, but that was a lot quicker than doing it by hand. There are a few manual things we have to do ourselves, which we will look at now.

Relationships between models

In our API, we need to define a one-to-many relationship between a todo list and a todo item. A todo list can have multiple todo items, but a todo item can only be assigned to a single todo list.

todo-list.model.ts

First, we are going to add modify our todo-list model by adding a hasMany property. Open the todo-list.model.ts, and modify it as follows

import {hasMany, Entity, model, property} from '@loopback/repository';
import {Todo} from './todo.model';

@model({})
export class TodoList extends Entity {
  @property({
    type: 'string',
    id: true,
  })
  id?: string;

  @property({
    type: 'string',
    required: true,
  })
  title: string;

  @hasMany(() => Todo)
  todos?: Todo[];

  constructor(data?: Partial<TodoList>) {
    super(data);
  }
}

You'll notice that we are importing the hasMany property and our Todo model. We are also adding a new property called todos, which is an array of todo.

todo-list.repository.ts

We've told our model to use the type Todo. Now, we are going to import the Todo repository into our todo-list repo, so we can access the their underlying operations.

import {TodoRepository} from './todo.repository';
import {
  DefaultCrudRepository,
  HasManyRepositoryFactory,
  repository,
} from '@loopback/repository';
import {TodoList, Todo} from '../models';
import {DbDataSource} from '../datasources';
import {inject, Getter} from '@loopback/core';

export class TodoListRepository extends DefaultCrudRepository<
  TodoList,
  typeof TodoList.prototype.id
> {
  public readonly todos: HasManyRepositoryFactory<
    Todo,
    typeof TodoList.prototype.id
  >;
  constructor(
    @inject('datasources.db') dataSource: DbDataSource,
    @repository.getter(TodoRepository)
    protected todoRepositoryGetter: Getter<TodoRepository>,
  ) {
    super(TodoList, dataSource);
    // We associate the todos property to be hasmany of type Todo.
    this.todos = this.createHasManyRepositoryFactoryFor(
      'todos',
      todoRepositoryGetter,
    );
  }
}

todo.model.ts

The last step is tell the Todo which list it belongs to. We do this by adding a todoListId property to our todo model

import {Entity, model, property} from '@loopback/repository';

@model({settings: {}})
export class Todo extends Entity {
  @property({
    type: 'string',
    id: true,
  })
  id?: string;

  @property({
    type: 'string',
    required: true,
  })
  title?: string;

  @property({
    type: 'string',
  })
  description?: string;

  @property({
    type: 'boolean',
  })
  isComplete?: boolean;

  @property()
  todoListId: string;

  constructor(data?: Partial<Todo>) {
    super(data);
  }
}

The MongoDB Quirk

There is currently a bug in the MongoDB connector, which returns a blank array when looking for nested information due to the id property not being converted in an object id. This effects our code because the normal way to look up the todos inside a todolist. To fix this, we are going to create our own find logic. Open todo-list.repository.ts one more time, and add the following function.

 async findTodos(id: string): Promise<Todo[]> {
    return await this.todoRepo.find().then(todos => {
      return todos.filter(todo => {
        return todo.todoListId === id;
      });
    });
  }

Inside the todolist.controller.ts, we are going to modify our get method to use our new function. Modify the find function to the following.

  async find(
    @param.path.string('id') id: string,
    @param.query.object('filter') filter?: Filter,
  ): Promise<Todo[]> {
    return await this.todoListRepo.findTodos(id);
    // return await this.todoListRepo.todos(id).find(filter);
  }

To read more about the current bug, you can track is here

Testing

To start our API server, open your terminal and execute

npm start

You use other API testing tools list Postman, and you still can, but one of the features of Loopback is their API explorer, which provides you the ability to view and test your API. Check it out by navigating to localhost:3000 in your browser.

Conclusion

In just a few minutes, we were able to build a fully working API without having to write too much code. I am going to continue playing around with Loopback 4 and look forward to sharing my experiences.

As always, the code is on my GitHub, which can be found here