In order to develop our Single Page Application, we need to create a back-end service. For consistency, we're going to use a Node.js server with sierra
, an MVC framework.
Let's create a new project for our service.
First, we need to ensure Yeoman is installed
npm install -g yo
Then we need to install our Yeoman generator for TypeScript
npm install -g generator-typescript-project
Now we are ready to create our new project. In our projects folder, we will create a directory example-service
.
mkdir example-service
Enter the new directory
cd example-service
Now let's run our generator, and answer any prompts.
yo typescript-project
Preparing our Project
Let's install our dependencies. First, we need to install sierra
.
npm install sierra
Open src/tests/test.ts
and remove any content.
Open tsconfig.json
and change compilerOptions.target
to es2017
.
Creating a Sierra Server
Then open src/scripts/main.ts
. Remove any content, add the following lines.
import Sierra from 'sierra';
let sierra = new Sierra();
sierra.init();
sierra.listen(8001);
Note that we have imported sierra
. Then we created a Sierra instance, initialized it, and started listening on port 8001.
Let's go ahead and run the following in the command prompt.
tsc
npm run node
Open your browser, and navigate to http://localhost:8001
. You should see the message "no route found"
.
This means that Sierra is running correctly, but we haven't yet configured it to do anything.
Middleware
Sierra works by running a series of "Middleware", which are simply a set of functions which are run in order, every time a Request is received by the server. Middleware handle common tasks like retrieving Session information, parsing Post Body data, checking Authentication, etc. Each Middleware returns a Promise
, which allows them to pause execution in case of Database calls and the like. If any unhandled exceptions occur, they are handled by the Error Middleware. Once all of the Middleware have run, execution is sent to the "Router" which parses the URL, and runs a specified function.
Let's take a look at how this all works.
In your src/scripts/main.ts
, update the import line to:
import Sierra, { BodyMiddleware } from 'sierra';
This brings in the BodyMiddleware
. Now let's add it to our Middleware stack.
After let sierra = new Sierra();
add the line
sierra.use(BodyMiddleware.handle);
This will cause our BodyMiddleware
to check for any Post Body data, and assemble it for use.
Routing
If you build and run our server again, you'll see it still doesn't do anything. We need to set up routes.
Create a file src/scripts/controllers/HomeController.ts
.
Inside the file, add the lines:
import { Controller, method } from 'sierra';
export default class HomeController extends Controller {
constructor() {
super('/');
}
@method('get')
async index() {
return 'Home/index';
}
}
Note that we extend Controller
, which provides a basic container for routes. In the constructor, we set the base of our route to '/'
, which means it will be our default Controller. We'll take a look at auto-generated route names later.
Then we created a "Controller method" called index
by adding the @method
decorator and passing 'get'
. This means our route will only respond to the GET
HTTP method.
We also named the method index
which is a reserved method name. It means this is the default method, and since we are already using the default controller, it means this method will run if we run the default URL. Hence, we have just created a method for http://localhost:8001
.
Now, let's add our controller to Sierra.
Open src/scripts/main.ts
and import our HomeController
:
import HomeController from './controllers/HomeController';
Now, before the init call, add HomeController
to sierra:
sierra.addController(new HomeController());
Build and run our server, and again navigate to http://localhost:8001
. You should see the message "Home/index"
.
Gateway
Before we can build a full RESTful API, we need some data to serve. For simplicity, we're going to use a local, JavaScript only database. For a production server, this database would likely be insufficient, but it will work great for our purposes.
Go to the command prompt, and install nedb
.
npm install nedb
Create the file src/scripts/Gateway.ts
and paste the following lines:
import * as Nedb from 'nedb';
export interface IModifier {
$set: any;
$unset: any;
$inc: any;
$min: any;
$max: any;
$push: any;
$pop: any;
$addToSet: any;
$pull: any;
$each: any;
$slice: any;
}
export interface IQuery<T> {
$lt: T;
$lte: T;
$gt: T;
$gte: T;
$in: T;
$nin: T;
$ne: T;
$exists: T;
$regex: RegExp;
}
export interface IDoc {
_id: string;
}
export default class Gateway<T> {
db: Nedb;
constructor(file?: string) {
this.db = new Nedb({
filename: file,
autoload: true
});
}
create(doc: T): Promise<T & IDoc> {
return new Promise((resolve, reject) => {
this.db.insert(doc, function (err, newDoc) {
if (err) {
reject(err);
} else {
resolve(newDoc as any);
}
});
});
}
get(_id: string): Promise<T & IDoc> {
return new Promise((resolve, reject) => {
this.db.findOne({ _id: _id }, function (err, doc) {
if (err) {
reject(err);
} else {
resolve(doc as any);
}
});
});
}
update(_id: string, doc: T | IModifier): Promise<T & IDoc> {
return new Promise((resolve, reject) => {
this.db.update({ _id: _id }, doc, {}, function (err, docs) {
if (err) {
reject(err);
} else {
resolve(docs[0]);
}
});
});
}
find(query: Partial<T> | keyof T): Promise<(T & IDoc)[]> {
return new Promise((resolve, reject) => {
this.db.find(query, function (err, docs) {
if (err) {
reject(err);
} else {
resolve(docs);
}
});
})
};
delete(_id: string): Promise<number> {
return new Promise((resolve, reject) => {
this.db.remove({ _id: _id }, {}, function (err, numRemoved) {
if (err) {
reject(err);
} else {
resolve(numRemoved);
}
});
});
}
}
This will create a Gateway for accessing our database.
Now create a folder data
in the root of your project. This will be used for storing our database. If you need to reset any of the data, simply delete the files in this directory. Also, if you decide to store your project in source control, do not commit this directory.
Service
Now that we have a database, let's create our service to access the data.
We are going to create a list of Products that a user can manage. They will have a name
and a description
.
Create a file src/scripts/data/IProduct.ts
, and paste the following lines:
export interface IProduct {
name: string;
description: string;
}
This interface will define the fields of our Product.
Now create a file src/scripts/controllers/ProductController.ts
, and paste the following lines:
import { Controller, method } from 'sierra';
import Gateway from './Gateway';
import { IProduct } from '../data/IProduct';
export default class ProductController extends Controller {
productGateway: Gateway<IProduct> = new Gateway<Test>('data/products.db');
}
Note that we have created a Controller called ProductController
. We also imported both IProduct
and Gateway
to create a gateway property. Our Gateway instance points at a file data/products.db
which will store our data.
Also note that we didn't specify a base route. Sierra will then generate a route automatically based on the Controller's name. In this case, the base will be set to product
.
Now let's create our Controller methods. We are creating a RESTful API, which in general conform to certain standards. We want to support five operations: List, which will give us all records, and our "CRUD" operations: Create, Read, Update, Delete.
- List - Uses the
GET
HTTP method, and is the default route/product
. - Create - Uses the
POST
HTTP method, and is the default route/product
. - Read - Uses the
GET
HTTP method, and is the route with anid
parameter,/product/:id
. - Update - Uses the
PUT
HTTP method, and is the route with anid
parameter,/product/:id
. - Delete - Uses the
DELETE
HTTP method, and is the route with anid
parameter,/product/:id
.
Let's create the List method. Inside the class, add the following lines:
@method('get')
async index() {
return await this.gateway.find({});
}
Here we have created another index
method, but in this case we are returning the results of this.gateway.find({})
, which will return all rows of our products.
Now let's create the Create method. Inside the class, add the following lines:
@method('post')
async post($body: IProduct) {
return this.gateway.create($body);
}
Here we again used another reserved method name, post
. If we use a reserved method name that matches the HTTP method, we will use the default route. Then we used a "special parameter" $body
, which is provided by Sierra to represent the Post Body data. We then store the $body
with this.gateway.create($body)
.
For our next three methods, we are going to use "query parameters". These are named portions of the URL pathname which are parsed and passed as parameters.
@method('get', '/:id')
async get(id: string) {
return await this.gateway.get(id);
}
Here we defined our route as /:id
, which is appended onto the product
base route. It is then automatically passed as the id
parameter. These parameters are all type string
. We then pass the id
to this.gateway.get(id)
.
For our remaining methods, we use similar ideas.
@method('put', '/:id')
async put(id: string, $body: IProduct) {
return this.gateway.update(id, $body);
}
@method('delete', '/:id')
async delete(id: string) {
return this.gateway.delete(id);
}
Testing
In order to test our service, we will need a better tool than a browser. Download Postman from https://www.getpostman.com/.