Project Setup The project has the following prerequisites:
- NodeJs 8.X
- Typescript => 2.7
- TSLint => 5.11.0
- Webpack => 4
- Webpack CLI => 3.1.0
- Git
Create the project directory.
mkdir express-typescript-todo
cd express-typescript-todo
Initialize the git repository for the project.
git init
Create a .gitignore file with following content:
.idea/
.vscode/
node_modules/
build/
dist/
Create the npm package.
npm init -y
Install the dependencies.
npm install --save body-parser@1.18.3 express@4.16.3 multer@1.3.1 reflect-metadata@0.1.10 routing-controllers@0.7.7
The function of each of these packages is as follows:
- body-parser: parse the request body into an object on express.Request.body property
- express: the webserver being used
- multer: parse for handling multipart/form-data
- reflect-metadata: allows the adding of decorators to modify typescript classes
- routing-controllers: allows the use of @Controller and http verb decorators to simplify routing declarations
Install the dev dependencies.
npm install -D @types/body-parser@1.17.0 @types/express@4.16.0 @types/multer@1.3.7 ts-loader@4.5.0 tslint@5.11.0 typescript@3.0.1 webpack@4.17.1 webpack-cli@3.1.0 nodemon-webpack-plugin@4.0.3 webpack-node-externals@1.7.2
The function of each of these packages is as follows:
- @types/*: provides the type definitions for a npm package
- ts-loader: webpack typescript loader
- tslint: typescript linter
- typescript: a super set of javascript
- webpack: a module bundler
- webpack-cli: allows the running of webpack from terminal
- nodemon-webpack-plugin: a webpack plugin to auto-restart a expressjs server with nodemon
- webpack-node-externals: a function for webpack to filter out node_modules when bundling
Create the Typescript configuration.
tsc --init
Make the following changes to the tsconfig.json file:
{
"compileOnSave": false,
"compilerOptions": {
"target": "es5",
"module": "commonjs",
"lib": [
"es2017",
"dom"
],
"typeRoots": [
"node_modules/@types"
],
"esModuleInterop": true ,
"inlineSourceMap": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
},
"exclude": [
"node_modules"
]
}
Create the TSLint configuration.
tslint --init
Create a webpack.config.js with the following content:
const path = require('path');
const nodeExternals = require('webpack-node-externals');
const nodemonPlugin = require( 'nodemon-webpack-plugin' );
module.exports = {
entry: './src/application/app.ts',
devtool: 'inline-source-map',
mode: 'development',
watch: true,
target: 'node',
module: {
rules: [{
test: /\.tsx?$/,
use: 'ts-loader',
exclude: /node_modules/
}]
},
resolve: {
extensions: ['.tsx', '.ts', '.js']
},
output: {
filename: 'app.js',
path: path.resolve(__dirname, 'dist')
},
externals: [
nodeExternals()
],
plugins:[
new nodemonPlugin()
]
};
Create the application directories.
mkdir src
cd src
mkdir application
mkdir test
cd application
mkdir controllers
Create the file src/application/controllers/todo.controller.ts with the following content:
import {Body, Controller, Delete, Get, Param, Post, Put} from "routing-controllers";
@Controller()
export class TodoController {
@Get("/todo")
public getAll() {
return "This action returns all todos";
}
@Get("/todo/:id")
public getOne(@Param("id") id: number) {
return "This action returns todo #" + id;
}
@Post("/todo")
public post(@Body() todo: any) {
return "Saving todo...";
}
@Put("/todo/:id")
public put(@Param("id") id: number, @Body() todo: any) {
return "Updating a todo...";
}
@Delete("/todo/:id")
public remove(@Param("id") id: number) {
return "Removing todo...";
}
}
Create the barrel roll file src/application/controllers/index.ts with the content:
export * from "./todo.controller";
Create the application bootstrapping file src/application/app.ts with the content:
import "reflect-metadata";
import { createExpressServer } from "routing-controllers";
import { TodoController } from "./controllers";
const app = createExpressServer({
controllers: [TodoController],
cors: false,
});
app.listen(3000);
Add the following scripts to the package.json file:
"scripts": {
"dev": "webpack"
}
Run the application.
npm run dev
The application is accessible at http://localhost:3000/todo.
Install the dependencies
npm install --save typedi
Install the dev dependencies
npm install @types/node -D
Create the services and models directory
cd src/application
mkdir services
mkdir models
Update the app file src/application/app.ts with the content:
import "reflect-metadata";
import { createExpressServer, useContainer } from "routing-controllers";
import {Container} from "typedi";
import { TodoController } from "./controllers";
useContainer(Container);
const app = createExpressServer({
controllers: [TodoController],
cors: false,
});
app.listen(3000);
Update the file tslint.json with the content:
{
"defaultSeverity": "error",
"extends": [
"tslint:recommended"
],
"jsRules": {},
"rules": {
"variable-name": [
true,
"check-format",
"allow-leading-underscore"
]
},
"rulesDirectory": []
}
Create the todo model file src/application/models/todo.model.ts with the content:
import { Exclude, Expose } from "class-transformer";
@Exclude()
export class TodoModel {
private _id: number;
private _name: string;
private _completed: boolean;
@Expose()
public get id(): number {
return this._id;
}
public set id(v: number) {
this._id = v;
}
@Expose()
public get name(): string {
return this._name;
}
public set name(v: string) {
this._name = v;
}
@Expose()
public get completed(): boolean {
return this._completed;
}
public set completed(v: boolean) {
this._completed = v;
}
}
Create the barrel roll file src/application/models/index.ts with the content:
export * from "./todo.model";
Create the todo service file src/application/services/todo.service.ts with the content:
import { Service } from "typedi";
import { TodoModel } from "../models";
@Service()
export class TodoService {
private _idCounter: number;
private _todos: TodoModel[];
constructor() {
this._todos = [];
this._idCounter = 0;
}
public getAll(): TodoModel[] {
return this._todos;
}
public getOne(id: number): TodoModel {
return this._todos.find((todo) => todo.id === id);
}
public create(todo: TodoModel): void {
todo.id = this.generateId();
this._todos.push(todo);
}
public update(id: number, todo: TodoModel): void {
const previousVersion = this.getOne(id);
previousVersion.completed = todo.completed;
previousVersion.id = todo.id;
previousVersion.name = todo.name;
}
public delete(id: number): void {
const index = this._todos.findIndex((todo) => todo.id === id);
this._todos.splice(index, 1);
}
private generateId(): number {
this._idCounter++;
return this._idCounter;
}
}
Create the barrel roll file src/application/services/index.ts with the content:
export * from "./todo.service";
Update the todo controller file src/application/controllers/todo.controller.ts with the content:
import { Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put } from "routing-controllers";
import { TodoModel } from "../models";
import { TodoService } from "../services";
@JsonController()
export class TodoController {
constructor(private _todoService: TodoService) {
}
@Get("/todo")
public getAll(): TodoModel[] {
return this._todoService.getAll();
}
@Get("/todo/:id")
public getOne(@Param("id") id: number): TodoModel {
return this._todoService.getOne(id);
}
@OnUndefined(201)
@Post("/todo")
public post(@Body() todo: TodoModel) {
this._todoService.create(todo);
}
@OnUndefined(200)
@Put("/todo/:id")
public put(@Param("id") id: number, @Body() todo: TodoModel) {
this._todoService.update(id, todo);
}
@OnUndefined(204)
@Delete("/todo/:id")
public remove(@Param("id") id: number): void {
this._todoService.delete(id);
}
}
Update the todo model file src/application/models/todo.model.ts with the content:
import { Exclude, Expose } from "class-transformer";
import { IsBoolean, IsEmpty, IsNotEmpty, IsString, MaxLength, MinLength } from "class-validator";
@Exclude()
export class TodoModel {
private _id: number;
@IsNotEmpty({ message: "Name is required" })
@IsString({ message: "Name should be a string" })
@MinLength(1, {
message: "Name is too short"
})
@MaxLength(50, {
message: "Name is too long"
})
private _name: string;
@IsNotEmpty({ message: "Completed is required" })
@IsBoolean({ message: "Completed should be boolean" })
private _completed: boolean;
@Expose()
public get id(): number {
return this._id;
}
public set id(v: number) {
this._id = v;
}
@Expose()
public get name(): string {
return this._name;
}
public set name(v: string) {
this._name = v;
}
@Expose()
public get completed(): boolean {
return this._completed;
}
public set completed(v: boolean) {
this._completed = v;
}
}
Install the dependencies.
npm install --save microframework-w3tec
Install the dev dependencies.
npm install chai mocha ts-node supertest @types/chai @types/mocha @types/supertest nyc source-map-support -D
Create the unit test directories.
cd src/test
mkdir e2e
mkdir unit
cd e2e
mkdir controllers
mkdir utils
cd ../unit
mkdir services
Create the mocha options file src/test/mocha.opts with the content:
--require ts-node/register
--require source-map-support/register
--full-trace
--bail
src/test/**/*.spec.ts
Update the npm package scripts.
"scripts": {
"dev": "webpack",
"test": "mocha --opts src/test/mocha.opts"
},
"nyc": {
"include": [
"src/application/**/*.ts"
],
"exclude": [
"src/test/**/*.ts"
],
"extension": [
".ts"
],
"require": [
"ts-node/register"
],
"reporter": [
"text-summary",
"html"
],
"sourceMap": true,
"instrument": true
}
Update the gitignore file with the following content:
.idea/
.vscode/
node_modules/
temp/
dist/
.nyc_output/
coverage/
Update the tslint with the following content:
{
"defaultSeverity": "error",
"extends": [
"tslint:recommended"
],
"jsRules": {},
"rules": {
"variable-name": [
true,
"check-format",
"allow-leading-underscore"
],
"trailing-comma":false,
"no-unused-expression":false
},
"rulesDirectory": []
}
Create the loaders directory.
cd src/application
mkdir loaders
Create the express loader file src/application/loaders/express.loader.ts with the content:
import { Application } from "express";
import { Server } from "http";
import { MicroframeworkLoader, MicroframeworkSettings } from "microframework-w3tec";
import { createExpressServer, useContainer } from "routing-controllers";
import Container from "typedi";
import { TodoController } from "../controllers";
export const expressLoader: MicroframeworkLoader = (settings: MicroframeworkSettings | undefined) => {
if (settings) {
useContainer(Container);
const app: Application = createExpressServer({
controllers: [TodoController],
cors: false,
});
const server: Server = app.listen(3000);
settings.setData("express_app", app);
settings.setData("express_server", server);
}
};
Create the barrel roll file src/application/loaders/index.ts with the content:
export * from "./express.loader";
Update the app file src/application/app.ts with the content:
import { bootstrapMicroframework } from "microframework-w3tec";
import "reflect-metadata";
import { expressLoader } from "./loaders";
bootstrapMicroframework([
expressLoader
]);
Create the barrel roll file src/application/index.ts with the content:
export * from "./controllers";
export * from "./loaders";
export * from "./models";
export * from "./services";
export * from "./app";
Update the todo.controller.ts for src/application/controllers/todo.controller.ts with the content:
import { Body, Delete, Get, JsonController, OnUndefined, Param, Post, Put } from "routing-controllers";
import { TodoModel } from "../models";
import { TodoService } from "../services";
@JsonController()
export class TodoController {
constructor(private _todoService: TodoService) {
}
@Get("/todo")
public getAll(): TodoModel[] {
return this._todoService.getAll();
}
@OnUndefined(404)
@Get("/todo/:id")
public getOne(@Param("id") id: number): TodoModel {
return this._todoService.getOne(id);
}
@OnUndefined(400)
@Post("/todo")
public post(@Body() todo: TodoModel): TodoModel {
return this._todoService.create(todo);
}
@OnUndefined(200)
@Put("/todo/:id")
public put(@Param("id") id: number, @Body() todo: TodoModel) {
this._todoService.update(id, todo);
}
@OnUndefined(204)
@Delete("/todo/:id")
public remove(@Param("id") id: number): void {
this._todoService.delete(id);
}
}
Update the todo.service.ts for src/application/services/todo.service.ts with the content:
import { Service } from "typedi";
import { TodoModel } from "../models";
@Service("TodoService")
export class TodoService {
private _idCounter: number;
private _todos: TodoModel[];
constructor() {
this._todos = [];
this._idCounter = 0;
}
public getAll(): TodoModel[] {
return this._todos;
}
public getOne(id: number): TodoModel {
return this._todos.find((todo) => todo.id === id);
}
public create(todo: TodoModel): TodoModel {
todo.id = this.generateId();
this._todos.push(todo);
return todo;
}
public update(id: number, todo: TodoModel): void {
const previousVersion = this.getOne(id);
previousVersion.completed = todo.completed;
previousVersion.name = todo.name;
}
public delete(id: number): void {
const index = this._todos.findIndex((todo) => todo.id === id);
this._todos.splice(index, 1);
}
private generateId(): number {
this._idCounter++;
return this._idCounter;
}
}
Create the todo service unit test src/test/unit/services/todo.service.spec.ts with the content:
import { expect } from "chai";
import { beforeEach, describe, it } from "mocha";
import { TodoModel, TodoService } from "../../../application";
describe("TodoService", () => {
describe("getAll", () => {
describe("with a populated list", () => {
let sut: TodoService;
let expectedTodos: TodoModel[];
let actualTodos: TodoModel[];
beforeEach(() => {
sut = new TodoService();
expectedTodos = [
{
completed: false,
name: "Clean bathroom"
} as TodoModel,
{
completed: false,
name: "Clean kitchen"
} as TodoModel,
];
expectedTodos.forEach((todo) => sut.create(todo));
actualTodos = sut.getAll();
});
it("should match the length of the expected todos", () => {
expect(expectedTodos.length).to.equal(actualTodos.length);
});
it("should return todos in the same order as populated", () => {
for (let index = 0; index < expectedTodos.length; index++) {
const expectedTodo = expectedTodos[index];
const actualTodo = actualTodos[index];
expect(expectedTodo).to.equal(actualTodo);
}
});
});
describe("with a empty list", () => {
let sut: TodoService;
let actualTodos: TodoModel[];
beforeEach(() => {
sut = new TodoService();
actualTodos = sut.getAll();
});
it("should return an empty list", () => {
expect(actualTodos).to.be.empty;
});
});
});
describe("getOne", () => {
describe("with a populated list", () => {
let expectedTodo: TodoModel;
let actualTodo: TodoModel;
let sut: TodoService;
before(() => {
sut = new TodoService();
expectedTodo = {
completed: false,
name: "Clean bathroom"
} as TodoModel;
expectedTodo = sut.create(expectedTodo);
actualTodo = sut.getOne(expectedTodo.id);
});
it("should return a matching todo", () => {
expect(expectedTodo).to.equal(actualTodo);
});
});
describe("with a empty list", () => {
let sut: TodoService;
let actualTodo: TodoModel;
beforeEach(() => {
sut = new TodoService();
actualTodo = sut.getOne(1);
});
it("should return undefined", () => {
expect(actualTodo).to.be.undefined;
});
});
});
describe("create", () => {
let expectedTodo: TodoModel;
let actualTodo: TodoModel;
let sut: TodoService;
before(() => {
sut = new TodoService();
expectedTodo = {
completed: false,
name: "Clean bathroom"
} as TodoModel;
expectedTodo = sut.create(expectedTodo);
actualTodo = sut.getOne(expectedTodo.id);
});
it("should create a matching todo", () => {
expect(expectedTodo).to.equal(actualTodo);
});
});
describe("update", () => {
describe("with a populated list", () => {
let expectedTodo: TodoModel;
let actualTodo: TodoModel;
let sut: TodoService;
before(() => {
sut = new TodoService();
expectedTodo = {
completed: false,
name: "Clean bathroom"
} as TodoModel;
expectedTodo = sut.create(expectedTodo);
expectedTodo.completed = true;
expectedTodo.name = "Clean kitchen";
sut.update(expectedTodo.id, expectedTodo);
actualTodo = sut.getOne(expectedTodo.id);
});
it("should update todo in list", () => {
expect(expectedTodo).to.equal(actualTodo);
});
});
});
describe("delete", () => {
describe("with a populated list", () => {
let expectedTodo: TodoModel;
let actualTodo: TodoModel;
let sut: TodoService;
before(() => {
sut = new TodoService();
expectedTodo = {
completed: false,
name: "Clean bathroom"
} as TodoModel;
expectedTodo = sut.create(expectedTodo);
sut.delete(expectedTodo.id);
actualTodo = sut.getOne(expectedTodo.id);
});
it("should remove todo in list", () => {
expect(actualTodo).to.be.undefined;
});
});
describe("with a empty list", () => {
let sut: TodoService;
let actualTodos: TodoModel[];
beforeEach(() => {
sut = new TodoService();
sut.delete(1);
actualTodos = sut.getAll();
});
it("should not change the list", () => {
expect(actualTodos).to.be.empty;
});
});
describe("with a non-existing todo", () => {
let sut: TodoService;
let expectedTodos: TodoModel[];
let actualTodos: TodoModel[];
beforeEach(() => {
sut = new TodoService();
let expectedTodo = {
completed: false,
name: "Clean bathroom"
} as TodoModel;
expectedTodo = sut.create(expectedTodo);
expectedTodos = sut.getAll();
sut.delete(expectedTodo.id + 1);
actualTodos = sut.getAll();
});
it("should not change the list", () => {
for (let index = 0; index < expectedTodos.length; index++) {
const expectedTodo = expectedTodos[index];
const actualTodo = actualTodos[index];
expect(expectedTodo).to.equal(actualTodo);
}
});
});
});
});
Create the todo controller e2e test src/test/e2e/controllers/todo.controller.spec.ts with the content:
import {expect} from "chai";
import {before, describe} from "mocha";
import {agent} from "supertest";
import {TodoModel} from "../../../application";
import {bootstrapApp, IBootstrapSettings} from "../utils";
describe("TodoController", async () => {
let settings: IBootstrapSettings;
before(async () => {
settings = await bootstrapApp();
});
after((done) => {
settings.server.close(done);
});
describe("getAll", async () => {
describe("with a populated list", async () => {
const expectedTodos: TodoModel[] = [];
let actualTodos: TodoModel[];
before(async () => {
const todos = [
{
completed: false,
name: "Clean bathroom"
} as TodoModel,
{
completed: false,
name: "Clean kitchen"
} as TodoModel,
];
todos.forEach(async (todo) => {
const response = await agent(settings.application)
.post("/todo")
.send(todo)
.set("Accept", "application/json")
.expect("Content-Type", /json/)
.expect(200);
expectedTodos.push(response.body as TodoModel);
});
});
after(async () => {
expectedTodos.forEach(async (todo) => {
await agent(settings.application)
.delete("/todo/" + todo.id)
.set("Accept", "application/json")
.expect(204);
});
});
it("responds with the expected records", async () => {
const response = await agent(settings.application)
.get("/todo")
.set("Accept", "application/json")
.expect("Content-Type", /json/)
.expect(200);
actualTodos = response.body as TodoModel[];
for (let index = 0; index < expectedTodos.length; index++) {
const expectedTodo = expectedTodos[index];
const actualTodo = actualTodos[index];
expect(expectedTodo.id).to.equal(actualTodo.id);
expect(expectedTodo.name).to.equal(actualTodo.name);
expect(expectedTodo.completed).to.equal(actualTodo.completed);
}
});
});
describe("with a empty list", () => {
it("should return an empty list", async () => {
const response = await agent(settings.application)
.get("/todo")
.set("Accept", "application/json")
.expect("Content-Type", /json/)
.expect(200);
const actualTodos = response.body as TodoModel[];
expect(actualTodos).to.be.empty;
});
});
});
describe("getOne", () => {
describe("with a populated list", () => {
let expectedTodo: TodoModel;
let actualTodo: TodoModel;
before(async () => {
const todo = {
completed: false,
name: "Clean bathroom"
} as TodoModel;
const response = await agent(settings.application)
.post("/todo")
.send(todo)
.set("Accept", "application/json")
.expect("Content-Type", /json/)
.expect(200);
expectedTodo = response.body as TodoModel;
});
after(async () => {
await agent(settings.application)
.delete("/todo/" + expectedTodo.id)
.set("Accept", "application/json")
.expect(204);
});
it("should return a matching todo", async () => {
const response = await agent(settings.application)
.get("/todo/" + expectedTodo.id)
.set("Accept", "application/json")
.expect("Content-Type", /json/)
.expect(200);
actualTodo = response.body as TodoModel;
expect(expectedTodo.id).to.equal(actualTodo.id);
expect(expectedTodo.name).to.equal(actualTodo.name);
expect(expectedTodo.completed).to.equal(actualTodo.completed);
});
});
describe("with a empty list", () => {
it("should return 404", async () => {
await agent(settings.application)
.get("/todo/" + 1)
.set("Accept", "application/json")
.expect("Content-Type", /json/)
.expect(404);
});
});
});
describe("create", () => {
let expectedTodo: TodoModel;
let actualTodo: TodoModel;
after(async () => {
await agent(settings.application)
.delete("/todo/" + actualTodo.id)
.set("Accept", "application/json")
.expect(204);
});
it("should create a matching todo", async () => {
expectedTodo = {
completed: false,
name: "Clean bathroom"
} as TodoModel;
const response = await agent(settings.application)
.post("/todo")
.send(expectedTodo)
.set("Accept", "application/json")
.expect("Content-Type", /json/)
.expect(200);
actualTodo = response.body as TodoModel;
expect(expectedTodo.name).to.equal(actualTodo.name);
expect(expectedTodo.completed).to.equal(actualTodo.completed);
expect(actualTodo.id).to.not.be.null;
});
it("should return 400 when todo model is invalid", async () => {
expectedTodo = {
completed: false
} as TodoModel;
await agent(settings.application)
.post("/todo")
.send(expectedTodo)
.set("Accept", "application/json")
.expect("Content-Type", /json/)
.expect(400);
});
});
describe("update", () => {
describe("with a populated list", () => {
let expectedTodo: TodoModel;
let actualTodo: TodoModel;
before(async () => {
const todo = {
completed: false,
name: "Clean bathroom"
} as TodoModel;
const response = await agent(settings.application)
.post("/todo")
.send(todo)
.set("Accept", "application/json")
.expect("Content-Type", /json/)
.expect(200);
expectedTodo = response.body as TodoModel;
});
after(async () => {
await agent(settings.application)
.delete("/todo/" + expectedTodo.id)
.set("Accept", "application/json")
.expect(204);
});
it("should update todo in list", async () => {
expectedTodo.completed = true;
expectedTodo.name = "Clean kitchen";
await agent(settings.application)
.put("/todo/" + expectedTodo.id)
.send(expectedTodo)
.set("Accept", "application/json")
.expect("Content-Type", /json/)
.expect(200);
const response = await agent(settings.application)
.get("/todo/" + expectedTodo.id)
.set("Accept", "application/json")
.expect("Content-Type", /json/)
.expect(200);
actualTodo = response.body as TodoModel;
expect(expectedTodo.id).to.equal(actualTodo.id);
expect(expectedTodo.name).to.equal(actualTodo.name);
expect(expectedTodo.completed).to.equal(actualTodo.completed);
});
});
});
describe("delete", () => {
describe("with a populated list", () => {
let expectedTodo: TodoModel;
before(async () => {
const todo = {
completed: false,
name: "Clean bathroom"
} as TodoModel;
const response = await agent(settings.application)
.post("/todo")
.send(todo)
.set("Accept", "application/json")
.expect("Content-Type", /json/)
.expect(200);
expectedTodo = response.body as TodoModel;
});
it("should remove todo in list", async () => {
await agent(settings.application)
.delete("/todo/" + expectedTodo.id)
.set("Accept", "application/json")
.expect(204);
await agent(settings.application)
.get("/todo/" + expectedTodo.id)
.set("Accept", "application/json")
.expect("Content-Type", /json/)
.expect(404);
});
});
});
});
Create the utils barrel roll file src/test/e2e/utils/index.ts with the content:
export * from "./bootstrap.settings";
export * from "./bootstrap";