When one wants to implement a Node.js-based backend API, the first framework that usually comes to mind is Express. This is, perhaps, the most popular web framework for Node.js. This framework is also usually the top choice for demonstration purposes such as proof of concepts, or tutorials, which further contributes to its popularity. A framework such as LoopBack is usually not the first option, even though this is a relatively mature and stable framework, and backed by an IBM company called StrongLoop.
As part of this blog post, we'll demonstrate the basic functionalities of LoopBack by building a small REST API.
Features at a Glance
LoopBack uses Express under the hood, but there are several noticeable features that make LoopBack stand out from other API frameworks:
- First-Class Support for TypeScript If you like TypeScript, then you'll definitely want to consider LoopBack. It can be a great companion to other TypeScript-based frameworks such as Angular, as frontend and backend teams will use the same programming language for the entire stack.
- OpenAPI-Driven APIs The OpenAPI standard is becoming widely adopted, and LoopBack makes it easy to create OpenAPI-based REST APIs.
- Great CLI Tool LoopBack comes with its CLI tool, which is great for scaffolding initial projects, or generating additional code. Many can get a project running within minutes.
- Loads of Connectors There are numerous connectors written for LoopBack, from REST/SOAP connectors, to database connectors for relational and NoSQL databases. This way, you can easily integrate your application with other APIs without much custom code.
- Dependency Injection LoopBack has a DI-based context at its core, allowing for writing loosely coupled code. Your components declare dependencies on other components, and it's up to LoopBack to wire them up.
Getting Started
Now that we know what LoopBack is about, let's try to implement an application that will demonstrate the usage of two common functionalities that most API backends have, such as saving to a database, and invoking an external API. For this purpose, we'll build a small application that acts like a proxy towards the GitHub issue API, but with local caching. It will expose an endpoint where you can fetch a specific issue, and then this issue will be fetched from the local cache, or from GitHub if it's not cached.
Scaffolding the Project
To start off, install the LoopBack CLI which is used to scaffold projects quickly:
$ npm install -g @loopback/cli
Now that you have the CLI installed, start by scaffolding your application:
$ lb4 loopback-demo
The CLI tool will ask you a couple of questions about the project, and allow you to select which features you will be using. Let's select all features, for now, just to see everything that you can get out of the box:
? Project description: Demonstration of basic features of LoopBack.
? Project root directory: LoopBackDemoApplication
? Application class name: LoopbackDemoApplication
? Select features to enable in the project (Press <space> to select, <a> to toggle all, <i> to invert selection)
>(*) Enable eslint: 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-eslint)
(*) Enable vscode: add VSCode config files
(*) Enable docker: include Dockerfile and .dockerignore
(*) Enable repositories: include repository imports and RepositoryMixin
At this point, we already have the application running with a sample REST endpoint, but let's do a couple of additional steps first that will make our development easier. At the moment, the LoopBack starter project does not come with a hot restart functionality, so let's add that first:
$ npm install -D tsc-watch
Then add this line under your "scripts"
tag in package.json
:
"start:watch": "tsc-watch --target es2017 --outDir ./dist --onSuccess \"node .\"",
LoopBack does not output many logs by default, unfortunately. Log output is controlled via the DEBUG environment variable, which determines the scope of the logs. To set this variable in a cross-platform manner and output some useful logs, we will use cross-env:
npm install -D cross-env
Now, add the following command, which adds some basic logging to standard out:
"start:watch:debug": "cross-env DEBUG=loopback:connector:*,loopback:rest:* npm run start:watch",
Running the Project
Running the project is now easy- simply execute npm run start:watch:debug
, and the application process will automatically restart on every code change. Open http://localhost:3000/ping
in your browser, and you should see something like this:
{"greeting":"Hello from LoopBack4","date":"2021-07-23T13:33:28.014Z","url":"/ping","headers":{"host":"localhost:3000","connection":"keep-alive","cache-control":"max-age=0","sec-ch-ua":"\"Chromium\";v=\"92\", \" Not A;Brand\";v=\"99\", \"Google Chrome\";v=\"92\"","sec-ch-ua-mobile":"?0","upgrade-insecure-requests":"1","user-agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36","accept":"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9","sec-fetch-site":"none","sec-fetch-mode":"navigate","sec-fetch-user":"?1","sec-fetch-dest":"document","accept-encoding":"gzip, deflate, br","accept-language":"en-US,en;q=0.9"}}
That's it! You now have a LoopBack application running at http://localhost:3000/.
Project Structure
The project is organized into several layers, where each layer has its own subdirectory within the root project tree. The first layer that you'll probably notice, and the first layer that you reached by executing the GET request in the previous chapter, is the controllers layer. A controller is where you would implement your API endpoint, and place your business logic. The controller will authenticate the incoming request, parse and validate the request, as well as orchestrate calls to services and repositories. There are no services in the starter project, but services provide you with interfaces to invoke local and remote operations. Repositories, on the other hand, handle all CRUD operations on your models, which represent domain objects.
LoopBack comes with dependency-injection (DI) out of the box, and components such as controllers, services, and repositories are registered automatically to the DI context. There's no need to wire them up manually. In fact, they are detected automatically when the application is booted.
Developing Our Cache Application
The cache will have the following flow:
- A GET request is sent to
/issues
to retrieve a GitHub issue. - This will look in the internal cache if issue already exists.
- If exists, it will return the cached issue.
- If not, it will retrieve the relevant issue from GitHub using GitHub's REST API, and cache it locally.
For the actual cache, we'll use an in-memory database, which is persisted to disk.
Implementing Basic Controller
To start, we can implement just a basic controller that will accept a request and reflect it back in the response. First, use the lb4 model
command to create a CachedIssue
model, an instance of which will be a response from the controller. When creating a new model, the CLI tool will typically ask you whether you want to create an instance of a Model, or an instance of an Entity. The difference between the two is that an Entity is saved to a database (therefore it must have a unique ID), while a Model is typically not saved to a database, and can be used for data transfer objects (DTOs) on the API level.
Our CachedIssue
will eventually be saved to our database (cache), so we'll choose the Entity option, with the following properties:
- issueNumber: number
- repositoryOwner: string
- repositoryName: string
- title: string
- reporter: string
- state: string
Once the model is created, now we can generate a controller that will use it. Execute the lb4 controller
command to generate an empty controller. Implement the following endpoint in the controller:
import {get, getModelSchemaRef, param} from '@loopback/rest';
import {CachedIssue} from '../models';
export class IssueController {
constructor() {}
@get('/issues/{repositoryOwner}/{repositoryName}/{issueNumber}', {
responses: {
'200': {
content: {
'application/json': {
schema: getModelSchemaRef(CachedIssue),
},
},
},
},
})
async getIssue(
@param.path.string('repositoryOwner') repositoryOwner: string,
@param.path.string('repositoryName') repositoryName: string,
@param.path.number('issueNumber') issueNumber: number,
): Promise<CachedIssue> {
return {
issueNumber: issueNumber,
repositoryOwner: repositoryOwner,
repositoryName: repositoryName,
} as CachedIssue;
}
}
It's a simple controller that accepts three properties, specified using the @param
annotation, and reflects them back in the response, just to verify that it's working. Even on this simple example, you can see one of the benefits that LoopBack gives you out of the box, and that is a validation of requests. For example, if you send a request that doesn't conform the request specification, LoopBack will throw an error, and inform why the validation failed.
Now execute a GET request towards http://localhost:3000/issues/strongloop/loopback/1553 - this should return an instance of CachedIssue
, containing the following properties:
{"issueNumber":1553,"repositoryOwner":"strongloop","repositoryName":"loopback"}
At this point, we have a functioning controller.
Integrating with GitHub
To integrate with an external API, we need to generate two things: a datasource and a service.
Generate a datasource using the command lb4 datasource
, name it GitHubApi
, and use the REST services
connector. This will place the datasource configuration in the git-hub-api.datasource.ts
, and LoopBack will use this file to auto-configure the datasource on startup. Be sure to place the following configuration in the datasource after it has been generated.
const config = {
name: 'GitHubApi',
connector: 'rest',
options: {
headers: {
accept: 'application/json',
'content-type': 'application/json',
'User-Agent': 'LoopBack', // GitHub API requires User-Agent header
},
},
operations: [
{
template: {
method: 'GET',
url: 'https://api.github.com/repos/{repositoryOwner}/{repositoryName}/issues/{issueNumber}',
},
functions: {
// The service that uses this datasource will need to have these functions in its interface
getIssue: ['repositoryOwner', 'repositoryName', 'issueNumber'],
},
},
],
};
This configuration specifies general configuration of the connector (such as the user agent used), however it also specifies the operations that we will expose internally towards the controllers, the GitHub API URL for each operation, and parameters for each operation. There is plenty of other configuration properties for the REST connector, but we'll keep it simple for the purposes of this tutorial.
Controllers normally access datasources through services, and the next step is to generate a service. Use the lb4 service
command to generate a service named "GitHubApi". Choose the Remote service proxy backed by a data source
option and choose the GitHubApiDatasource
datasource. Once the service is generated, create an interface that will match the operations specified in the configuration of the REST connector:
import {inject, Provider} from '@loopback/core';
import {getService} from '@loopback/service-proxy';
import {GitHubApiDataSource} from '../datasources';
/**
* This class is used for representing the response from GitHub API. Only a subset of returned properties is used.
*/
export class GitHubIssueResponse {
'number': number;
title: string;
user: {login: string};
state: string;
}
/**
* When other components want to use this service, they declare dependency on the following interface.
*/
export interface GitHubApi {
getIssue(
repositoryOwner: string,
repositoryName: string,
issueNumber: number,
): Promise<GitHubIssueResponse>;
}
export class GitHubApiProvider implements Provider<GitHubApi> {
constructor(
// GitHubApi must match the name property in the datasource json file
@inject('datasources.GitHubApi')
protected dataSource: GitHubApiDataSource = new GitHubApiDataSource(),
) {}
value(): Promise<GitHubApi> {
return getService(this.dataSource);
}
}
Here, we used GitHubIssueResponse
to define the properties that are read from the GitHub API. GitHub API returns numerous properties in the response, but we'll only use a few properties for simplicity. LoopBack will automatically parse GitHub API's response, and instantiate an object of GitHubIssueResponse
with the properties that we need.
Notice also how we declared a dependency on GitHubApiDataSource
in the constructor. This is LoopBack's dependency injection mechanism at work - we only declare the dependency through @inject
annotation, and LoopBack's DI container is responsible for injecting an instance of GitHubApiDataSource
during bootstrap.
Saving Issues to Local Database
Our cache would not work without having some local storage. LoopBack has a variety of database connectors, but we'll go with the most simple one: an in-memory database which is persisted to a JSON file on the local disk. Run the lb4 datasource
command, name the datasource "FileDb" and use the In-memory db
connector with data/db.json
as the full path for file persistence. Config of the generated datasource should look like this:
const config = {
name: 'FileDb',
connector: 'memory',
localStorage: '',
file: './data/db.json' // Make sure the file exists!
};
As you can see, the configuration for an in-memory database is quite simple when compared to the configuration of the REST connector that we configured earlier. One thing to note here is that LoopBack expects the file ./data/db.json
to exists on the filesystem. Be sure to create it before starting the application (it can be empty).
The controller will access this datasource through a service, but not just any service. For CRUD operations, we use a specialized service called a repository. Repositories provide strong-typed data access operations of a domain model against the underlying database or service. Before we create a repository, we need a model to persist first. Execute lb4 model
, and choose Entity model this time, since it will be persisted to the database.
import {Entity, model, property} from '@loopback/repository';
/**
* This model represents the issue that is persisted to local database.
*/
@model()
export class CachedIssue extends Entity {
@property({
type: 'number',
id: true,
generated: false,
required: true,
})
issueNumber: number;
@property({
type: 'string',
required: true,
})
repositoryOwner: string;
@property({
type: 'string',
required: true,
})
repositoryName: string;
@property({
type: 'string',
required: true,
})
title: string;
@property({
type: 'string',
required: true,
})
reporter: string;
@property({
type: 'string',
required: true,
})
state: string;
constructor(data?: Partial<CachedIssue>) {
super(data);
}
}
export interface CachedIssueRelations {
// describe navigational properties here
}
export type IssueWithRelations = CachedIssue & CachedIssueRelations;
tions {
// describe navigational properties here
}
export type IssueWithRelations = Issue & IssueRelations;
Use the lb4 repository
command to generate a repository using the FileDbDatasource
datasource and the CachedIssue
model:
import {inject} from '@loopback/core';
import {DefaultCrudRepository} from '@loopback/repository';
import {FileDbDataSource} from '../datasources';
import {CachedIssue, IssueRelations} from '../models';
export class IssueRepository extends DefaultCrudRepository<
CachedIssue,
typeof CachedIssue.prototype.issueNumber,
IssueRelations
> {
constructor(@inject('datasources.FileDb') dataSource: FileDbDataSource) {
super(CachedIssue, dataSource);
}
}
import {inject} from '@loopback/core';
import {DefaultCrudRepository} from '@loopback/repository';
import {FileDbDataSource} from '../datasources';
import {Issue, IssueRelations} from '../models';
export class IssueRepository extends DefaultCrudRepository<
Issue,
typeof Issue.prototype.issueNumber,
IssueRelations
> {
constructor(
@inject('datasources.FileDb') dataSource: FileDbDataSource,
) {
super(Issue, dataSource);
}
}
Putting It All Together
With all the integrations in place, we can now finalize the controller and add some business logic to it. The controller should read parameters from the request, propagate them to GitHub API, and read GitHub API's response, which is represented by an instance of GitHubIssueResponse
object. Once it has this object, it uses the issue number as a primary key to see if the issue has already been persisted to the database. If it has, the controller will return the cached issue, otherwise it will fetch it from GitHub before returning it.
import {get, getModelSchemaRef, HttpErrors, param} from '@loopback/rest';
import {CachedIssue} from '../models';
import {inject} from '@loopback/core';
import {GitHubApi} from '../services';
import {repository} from '@loopback/repository';
import {IssueRepository} from '../repositories';
export class IssueController {
constructor(
@inject('services.GitHubApi')
protected gitHubApiService: GitHubApi,
@repository(IssueRepository)
public issueRepository: IssueRepository,
) {}
@get('/issues/{repositoryOwner}/{repositoryName}/{issueNumber}', {
responses: {
'200': {
content: {
'application/json': {
schema: getModelSchemaRef(CachedIssue),
},
},
},
},
})
async getIssue(
@param.path.string('repositoryOwner') repositoryOwner: string,
@param.path.string('repositoryName') repositoryName: string,
@param.path.number('issueNumber') issueNumber: number,
): Promise<CachedIssue> {
try {
// This method will return saved entity if exists, otherwise it will raise ENTITY_NOT_FOUND error
return await this.issueRepository.findById(issueNumber);
} catch (e) {
if (e.code === 'ENTITY_NOT_FOUND') {
const issueResponse = await this.gitHubApiService.getIssue(
repositoryOwner,
repositoryName,
issueNumber,
);
const newIssueEntity = new CachedIssue();
newIssueEntity.issueNumber = issueResponse.number;
newIssueEntity.title = issueResponse.title;
newIssueEntity.reporter = issueResponse.user?.login;
newIssueEntity.state = issueResponse.state;
newIssueEntity.repositoryName = repositoryName;
newIssueEntity.repositoryOwner = repositoryOwner;
return this.issueRepository.create(newIssueEntity);
} else {
throw new HttpErrors.InternalServerError(
`Error while looking up issue #${issueNumber} in the database`,
);
}
}
}
}
We now have a fully functioning application. You can try re-sending the GET request from earlier, and verify that it is persisted to ./data/db.json
. Of course, we're still missing some important cache features such as eviction of unused data, but we'll leave it to you to implement in case you'd like to play with the framework further.
We hope you enjoyed this brief intro to LoopBack, and perhaps you'll even consider it for your next project. LoopBack has many other functionalities and built-in connectors, but as part of this tutorial, we focused on the common ones: creating a simple API, invoking an external API, and using a database. The complete project is available on GitHub in case you'd like to check it out.