Example application for creating an MVC Express + Node + TypeScript app and deploying it to Azure
This tutorial will focus on creating a basic MVC web app with Node, Express, and TypeScript. We'll approach this from the angle of already being familiar with creating an ASP.NET Core MVC app in C#, so this tutorial will model the structure of the MVC web app tutorial.
In short, if you are a C# developer and want to learn how to make Express MVC apps in Node and TypeScript, this tutorial is for you!
TODO: explain what Node is https://nodejs.org/en/download/
NPM is Node's package manager, similar to Nuget. It's included in Node. When adding dependencies to your projects, the first thing you'll need to do in a new project is run npm init
. This will take you through the steps of creating a new package.json
file, which will contain the manifest of all the installed dependencies on your project, as well as custom scripts and other configuration settings.
There's two types of dependencies:
dependencies
refers to any dependencies your running app actually depends on- To install and save a dependency,
npm install some-dependency --save
- (or
npm i some-dependency -S
for short)
- To install and save a dependency,
devDependencies
refers to any dependencies needed to build, test, lint, etc. your app, and are not necessary for running the app.- To install and save a devDependency,
npm install some-dependency --save-dev
- (or
npm i some-dependency -D
for short)
- To install and save a devDependency,
We will be installing most dependencies as normal dependencies, so that we can use Azure to build the app as part of the deployment process.
More info:
VS Code is the preferred editor for creating TypeScript projects, as it has built-in support for TypeScript. You can get it here: https://code.visualstudio.com/download
TypeScript is a "typed superset of JavaScript that compiles to plain JavaScript." JavaScript is not a typed language, which may make it feel unfamiliar when coming from a strongly-typed language like C#. Thankfully, TypeScript (TS for short) provides strong typing support to make JavaScript feel similar to C#.
Throughout the course, we'll compare the TypeScript we write to C#, and explore the differences.
More info:
- http://www.typescriptlang.org/docs/handbook/typescript-in-5-minutes.html
- https://www.lynda.com/course-tutorials/Typescript-C-Programmers/543000-2.html
Create a new directory which will contain our Node app.
mkdir my-app
cd my-app
Initialize the package.json
file. You can just press Enter through all the steps, unless you want to set any of the package configuration settings (which you can do at any time in the package.json
file).
npm init
To get up and running quickly, we'll just install express
as a dependency:
npm install express --save
Express is the most popular minimalist web framework for Node apps, and you will see many Node apps authored either in Express or variants of Express, such as Koa or Nest.
Now install TypeScript and the Express types:
npm install typescript @types/express --save
The @types/express
package adds the proper typings for express
, so that it can be strongly typed and work with Intellisense even though it's written in JavaScript.
After installing TypeScript, add a tsconfig.json
file to the project root with the following configuration:
{
"compilerOptions": {
"module": "commonjs",
"esModuleInterop": true,
"target": "es6",
"noImplicitAny": true,
"moduleResolution": "node",
"sourceMap": true,
"outDir": "dist",
"baseUrl": ".",
"paths": {
"*": [
"node_modules/*",
"src/types/*"
]
}
},
"include": [
"src/**/*"
]
}
This file contains the config settings for compiling the TypeScript code. In short, it tells TypeScript to look for .ts
files in the src/
directories and subdirectories, compile it according to the config settings, and output the compiled .js
files in the dist/
directory. To dive deeper into what each of these configuration settings mean, check out the TypeScript Node Starter readme or the TypeScript config file docs.
Here's what our overall folder structure will look like:
src/
├─ controllers/
| └─ MoviesController.ts
├─ models/
| └─ Movie.ts
├─ routers/
| └─ MoviesRouter.ts
├─ views/
│ ├─ layouts
| | └─ base.tsx
│ └─ movies
| └─ index.tsx
└─ index.ts
Create these directories:
touch index.ts
mkdir src && cd src
mkdir models views controllers
cd ../
When TypeScript compiles this app, it will recursively compile all .ts
files within these folders and output them in dist/
with the same structure.
Now that our project is set up, let's set up a simple running Node + Express + TypeScript app. Import the express
package in src/index.ts
:
// src/index.ts
import express from 'express';
ℹ️ Compare TS imports to C#
In JavaScript/TypeScript, external references to variables/functions/etc. must be explicitly imported and referenced. This is different than C#, where you would use the using
directive to reference external namespaces.
For example, in C#, the ReadAllText
function is implicitly available from using System.IO.File
:
using System.IO.File;
class ReadFromFile
{
static void Main()
{
// ReadAllText is implicitly available from System.IO.File
string text = ReadAllText(@"path\to\file.txt");
}
}
Whereas in TypeScript, it is referenced from the built-in fs
module:
import { readFileSync } from 'fs';
export class ReadFromFile {
constructor() {
// readFileSync is explicitly imported from the fs module
this.text = readFileSync('path/to/file.txt');
}
}
The above can also be written without import destructuring:
import * as fs from 'fs';
export class ReadFromFile {
constructor() {
// readFileSync is referenced from the fs module
this.text = fs.readFileSync('path/to/file.txt');
}
}
Following the hello world guide in the Express docs, continue creating the app:
// src/index.ts
import express from 'express';
// Creates a new Express app instance
const app = express();
// Configures the http://localhost:5000/ route to send a text response
app.get('/', (req, res) => {
res.send('Hello, world!');
});
// Starts the app on port 5000, then calls the callback when
// the app successfully starts.
app.listen(5000, () => {
console.log('Listening on port 5000: http://localhost:5000');
});
You'll notice that the above TS code is almost the same as the normal JS code, thanks to type inference. We'll make the port configurable later.
There's two steps to run this app:
- Compile the TypeScript files
- Run the compiled JavaScript
dist/index.js
file.
To compile the TypeScript files, you can use the tsc
terminal command, which is made available from installing the typescript
dependency in this project:
tsc
This will:
- read the configuration settings and compiler options from
tsconfig.json
- compile the TypeScript files from the
"include"
setting (in this case,"src/**/*
) - output the compiled TypeScript files as JavaScript to the specified
"outDir"
setting (in this case,dist/
)
From there, you can run the compiled dist/index.js
file via node
:
node dist/index.js
# Listening on port 5000: http://localhost:5000
Of course, we don't want to manually run tsc && node dist/index.js
whenever we want the app to run. Thankfully, we can add custom NPM scripts that will both help us in development, as well as provide an easy way for Azure deployment scripts to build and run the app in the cloud.
In package.json
, add the following scripts:
...
"scripts": {
"start": "npm run build-ts && npm run serve",
"serve": "node dist/index.js",
"build-ts": "./node_modules/.bin/tsc",
"watch-ts": "./node_modules/.bin/tsc --watch",
"test": "echo \"Error: no test specified\" && exit 1"
},
...
Note that we specify ./node_modules/.bin/tsc --watch
instead of just tsc --watch
to prevent any binary path resolution issues in Azure.
To break these down:
npm run start
(or justnpm start
) will runnpm run build-ts
and thennpm run serve
once that completes...npm run serve
will serve the compiled JS app vianode
npm run build-ts
will compile the TS files viatsc
npm run watch-ts
can be used in development to automatically compile the TS files whenever the files changenpm run test
(or justnpm test
) is not specified yet, but will be used to run the unit/E2E tests
Now, you can run the below command to run your Node app:
npm start
Although Express is unopinionated, a common architectural pattern to implementing web apps and APIs is MVC (Mmodel View Controller), which will be familiar to you if you have experience with ASP.NET MVC. We'll be creating our application using the same architecture, with these responsibilities:
- Models will be responsible for fetching, modifying, and storing data that the app will use. They'll connect to the database(s), external APIs, and other data sources that our app might need.
- e.g.,
src/models/UserModel.ts
- e.g.,
- Views will be used for displaying the app's UI. With single-page apps (SPAs), views will remain mostly static, as the app will be used in a RESTful way.
- e.g.,
src/views/users.pug
- e.g.,
- Controllers will handle requests, make calls to the necessary models to fetch/update data, and respond with a representation of that data -- which can be an HTML response (with MVC apps), a JSON response (RESTful API + SPA), a status code response, etc.
- e.g.,
src/controllers/MoviesController.ts
- e.g.,
- Routers will map URLs to controller request handlers.
- e.g.,
src/routers/MoviesRouter.ts
- e.g.,
Create src/controllers/MoviesController.ts
:
// Reference the Request and Response types from express
import { Request, Response } from 'express';
export function index(req: Request, res: Response) {
res.send('This is my default response..');
}
export function getWelcome(req: Request, res: Response) {
res.send('This is my welcome response...');
}
ℹ️ Where's the class? Compare TS to C#
In ASP.NET MVC, you would be using Microsoft.AspNetCore.MVC
to inherit from its Controller
base class to create the controller. This has the advantages of automatically mapping to the default /[Controller]/[ActionName]/[Parameters]
route logic in an ASP.NET MVC app.
However, since Express is unopinionated, we will just be export
-ing the request handler functions, for use in the routers. These functions don't return
anything, but instead they call res.send(...)
to send the response with the given body
(in this case, plain text).
Since they are just functions, they don't need a class to group them. "Namespacing" is taken care of by importing entire modules using the import * as someVariable from '/some/path'
:
// Places the exported { index, getWelcome } functions into moviesController
import * as moviesController from '../controllers/MoviesController';
moviesController.index // function
moviesController.getWelcome // function
This is the idiomatic way to group related variables and functions into a single module in TS/JS, without the use of an explicit namespace
or class
.
Now create src/routers/MoviesRouter.ts
to group these route handlers to actual routes:
// src/routers/MovieRouter.ts
import { Router } from 'express';
import * as moviesController from '../controllers/MoviesController';
// Create a new router to handle /movies routes
const moviesRouter = Router();
// GET /movies/
moviesRouter.get('/', moviesController.index);
// GET /movies/welcome
moviesRouter.get('/welcome', moviesController.getWelcome);
export default moviesRouter;
To connect this router to the app, use app.use()
to configure the app and tell Express that whenever an incoming request's URL matches /movies
, use the moviesRouter
to handle the request.
// src/index.ts
// ...
const app = express();
// Handles /movies routes
app.use('/movies', moviesRouter); // << Add this line
// ...
The app.use(...)
method is a way to mount middleware for the specified path (in this case, '/movies'
). You can think of Express routers as middleware, since they intercept the requests matching that URL, make changes to the request/response objects, and (in this case) end the request/response cycle by calling res.send(...)
or res.end(...)
.
Now compile and run the app and visit http://localhost:5000/movies/ and http://localhost:5000/movies/welcome. You'll see the correct responses for each of the routes.
npm run build-ts && npm start
Here's what's happening when each request (e.g., http://localhost:5000/movies/welcome
) is made:
- The
app
listens for the request - Request URL matches
'/movies'
, so the request is sent to themoviesRouter
middleware (defined insrc/routers/MoviesRouter.ts
) - The URL sub-path matches
'/welcome'
and the request method matchesGET
, somoviesController.getWelcome
handles the request (defined insrc/controllers/MoviesController.ts
) moviesController.getWelcome(req, res)
receives the request and response, and...- Finally, that
getWelcome(req, res)
function callsres.send(...)
, which sends the response and ends the request/response cycle.
Modify the code to pass some query parameter data from the URL to the controller. For example, http://localhost:5000/movies/welcome?name=David&numTimes=4
. Change the getWelcome
method to read from the query data:
// src/controllers/MoviesController.ts
// ...
export function getWelcome(req: Request, res: Response) {
const { name, numTimes } = req.query;
res.send(`Hello ${name}, numTimes is: ${numTimes}`);
}
ℹ️ What's the weird variable bracket syntax? Destructuring in TS
In ES6, variables can be "destructured" from an object or an array (see the MDN docs for more info and use cases). So:
// req.query is an object, e.g., { name: 'David', numTimes: 4 }
const { name, numTimes } = req.query;
// is the same as
const name = req.query.name;
const numTimes = req.query.numTimes;
In C#, this idea is called deconstructing, and (at the time of writing) only works for tuples and user-defined types.
You can also destructure arrays in ES6 as well:
const numbers = [1, 2, 3, 4];
const [_, two, __, four] = numbers;
console.log(two, four); // 2 4
ℹ️ String interpolation: C# vs TS
In ES6, backticks are used to interpolate expressions in strings. This is similar to the $
special character in C#:
// C#
var str = $"Hello {name}, numTimes is: {numTimes}";
Expressions are interpolated inside the backticked string via the ${ expr }
syntax:
// ES6
const str = `Hello ${name}, numTimes is: ${numTimes}`;
These are called template literals. More info: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals
Compile and run the app (npm run build-ts && npm start
) and navigate to http://localhost:5000/movies/welcome?name=David&numTimes=4 . You can try different values for name
and numTimes
.
For parameters defined directly on the route, you can use route parameters in Express. To define a route parameter, use the colon (:
) prefix on the route:
// src/routers/MoviesRouter.ts
// ...
// GET /movies/welcome
// Add the /:id route parameter
// This will add the 'id' property to req.params
moviesRouter.get('/welcome/:id', moviesController.getWelcome);
The route parameters will be placed on the req.params
object, which you can handle in the controller:
// src/controllers/MoviesController.ts
// ...
export function getWelcome(req: Request, res: Response) {
const { id } = req.params; // the 'id' param that was just added
const { name } = req.query;
res.send(`Hello ${name}, ID is: ${id}`);
}
Now if you navigate to http://localhost:5000/movies/welcome/42?name=David , you should see:
Hello David, ID is: 42
There are a few different types of JavaScript template engines which let you interpolate dynamic data into static views. This is useful if you don't want to create a full SPA.
However, in this tutorial, we're going to use Express React views, which is a "view engine which renders React components on the server." This is helpful if you want to use React in the future, learn it, or avoid deviating from the normal HTML syntax while still maintaining the full expressivity of JS.
Install these three modules (and their types):
express-react-views
- the template engine for Express to understand.jsx
files- Note: you don't need to install types for this since it's implicitly loaded by Express
react
- the framework for creating React componentsreact-dom
- parses React components and outputs them as HTML (hence DOM, or Document Object Model)
# install the modules
npm install express-react-views react react-dom --save
# install the types
npm install @types/react @types/react-dom --save
// src/index.ts
// ...
import * as path from 'path';
// Creates a new Express app instance
const app = express();
// Sets up the view engine
app.set('views', path.join(__dirname, '/src/views'));
app.set('view engine', 'jsx');
app.engine('jsx', require('express-react-views').createEngine());
// ...
ℹ️ What is __dirname?
__dirname
is a global variable in Node, and it refers to the directory name of the current module (not of the current file).
path.join(__dirname, '/src/views');
// will join '/the/path/to/your/project' and '/src/views'
With the above configuration, views rendered from res.render('path/to/view')
will be:
- resolved from the
'views'
path (in this case,/src/views
) - compiled through the view engine (in this case,
'express-react-views'
) - sent as an HTML response.
Before we start, we need to make one quick config change to tsconfig.json
. Add "jsx": "preserve"
to "compilerOptions"
:
// tsconfig.json
{
"compilerOptions": {
// ...
"jsx": "preserve",
// ...
},
// ...
}
This will tell the TypeScript compiler to preserve the JSX output when compiling it. That is, instead of compiling as React.createElement('Something', ...)
, it will compile as the original <Something ...>
. More info: https://www.typescriptlang.org/docs/handbook/jsx.html
Let's make an index view for our /movies/
route. We will render from res.render('movies/index')
, so create src/views/movies/index.tsx
:
// src/views/movies/index.tsx
import * as React from 'react';
interface IMoviesViewProps {
title: string;
}
class MoviesView extends React.Component<IMoviesViewProps> {
render() {
return (
<div>
<h2>Index</h2>
<p>Hello from our MoviesView component!</p>
</div>
);
}
}
export default MoviesView;
ℹ️ Do I see an interface? Compare C# to TS
Yes! in TypeScript, interfaces are similar to C# interfaces. They're defined with the same interface
keyword, and they do support generics.
// C#
interface IMoviesViewProps
{
string title
{
get;
}
}
interface IMoviesViewProps {
readonly title: string;
}
Note the difference on where the type is defined. In ES6/TS, all variables are defined with var
, let
, or const
, so types are defined after the variable in TS (e.g., const title: string = 'some title';
).
Now let's modify the index
handler on the MoviesController.ts
:
// src/controllers/MoviesController.ts
import { Request, Response } from 'express';
export function index(req: Request, res: Response) {
// The second argument ({ title: ... }) will be passed to the view engine,
// which will pass the data to the React component as props.
res.render('movies/index', { title: 'Movies' });
}
// ...
Build and run the app, navigate to http://localhost:5000/movies/, and you should see the rendered view, saying "Hello from our MoviesView component!".
With React, you create components which can be composed together. This is helpful when creating layouts. Let's create a base HTML page layout in src/layouts/base.tsx
:
// src/views/layouts/base.tsx
import * as React from 'react';
interface IBaseLayoutProps {
title: string;
}
class BaseLayout extends React.Component<IBaseLayoutProps> {
render() {
return (
<html>
<head>
<title>{this.props.title}</title>
</head>
<body>
<header>
<h1>{this.props.title} - Movie App</h1>
</header>
<main>{this.props.children}</main>
</body>
</html>
);
}
}
export default BaseLayout;
There is a special component property called this.props
, which contains the passed-in attributes and children of the custom React component.
In this example, this.props.title
refers to the variable passed into <BaseLayout title="some title">
The interpolated {this.props.children}
variable contains the child elements that are passed into the <BaseLayout>
component.
Import BaseLayout
and use it in the MoviesView
:
// src/views/movies/index.tsx
import * as React from 'react';
import BaseLayout from '../layouts/base';
interface IMoviesViewProps {
title: string;
}
class MoviesView extends React.Component<IMoviesViewProps> {
render() {
return (
<BaseLayout title={this.props.title}>
<h2>Index</h2>
<p>Hello from our MoviesView component!</p>
</BaseLayout>
);
}
}
export default MoviesView;
You should see the "Movies - Movie App" text (from our interpolated "Movies"
title) as well as the content of the <MoviesView>
component. Here's what's happening:
- The Movies controller (
src/controllers/MoviesController.ts
) passes the{ title: 'Movies' }
props into the'movies/index'
view viares.render('movies/index', { title: 'Movies' })
- The view engine maps that view path to
views/movies/index.tsx
and passes the props into the<MoviesView>
component- i.e.,
<MoviesView title="Movies" />
- i.e.,
- The
<MoviesView>
component renders the<BaseLayout>
component and passes its receivedtitle
prop into it viathis.props.title
- i.e.,
<BaseLayout title={this.props.title}>
which becomes<BaseLayout title={"Movies"}>
- i.e.,
- The
<BaseLayout>
component renders its view, which recursively renders all its child views.
In a real app, the data that the controller retrieves, which is then passed to the view, usually comes from a data store somewhere, whether that's via an external API or a database. We're going to use MongoDB and Mongoose to create our models:
- MongoDB is "an open-source document database that provides high performance, high availability, and automatic scaling." Sounds similar to CosmosDB? You're right, but more on that later.
- Mongoose helps you create, update, and validate model schemas for MongoDB.
First install Mongoose and its types:
npm install mongoose @types/mongoose --save
Now create a Movie
model in src/models/Movie.ts
. This will be responsible for creating, updating, deleting, querying, etc. our movies.
// src/models/Movie.ts
import { Schema, model } from 'mongoose';
// Create Movie schema
const MovieSchema = new Schema({
title: {
type: String,
required: true
},
releaseDate: Date,
genre: String,
price: Number
});
// Create Movie model
const Movie = model('Movie', MovieSchema);
export default Movie;
That { type, required }
syntax for the title
property is part of Mongoose's validation.
Before we go further, we need an actual MongoDB database to store our data. Follow the MongoDB installation steps and then run the mongod
process. You should see terminal output that includes the line:
[initandlisten] waiting for connections on port 27017
To connect Mongoose to MongoDB, we'll call mongoose.connect(...)
. Since this is an app-specific call, put it in index.ts
for now.
// src/index.ts
// ...
import { connect } from 'mongoose';
// Connect the database
// We'll remove the hardcoded URL later.
const mongoUrl = 'mongodb://127.0.0.1:27017/moviesapp';
connect(
mongoUrl,
// Make sure to include these settings:
{ useNewUrlParser: true, dbName: 'movies' }
)
.then(() => {
console.log('Connected to MongoDB');
})
.catch(err => {
console.error('MongoDB connection error:', err);
});
// ...
ℹ️ What is that then/catch pattern? Promises in TS vs. Tasks in C#
A Promise is a pattern for doing asynchronous programming in JS. It represents something that happens over time, and either resolves or rejects. The callbacks provided for a resolved or rejected promise are .then(resolvedCallback)
or .catch(rejectedCallback)
, respectively.
A near-equivalent in C# are Tasks. What's interesting to note is that, in TypeScript, the async/await
model can be used for promises as well, so in an async function, the above code can be written as:
async function connectToDatabase(url) {
try {
// This would be e.g.,
// const response = await connect(url);
// but in this case, it resolves with undefined.
await connect(url);
console.log('Connected to database');
} catch(err) {
console.error('MongoDB connection error: ', err);
}
}
// in another async function, this can be await-ed as well
connectToDatabase(mongoUrl);
Of course, hard-coding the connection string isn't the best idea, especially if we want this to be configurable in different environments. In Node, environment variables are loaded into the global process.env
object. For example, let's make the port configurable by editing the "serve"
script in package.json
and using the PORT
environment variable to serve our app:
// package.json
// ...
"scripts": {
"start": "npm run serve",
"serve": "PORT=5001 node dist/index.js",
"build-ts": "tsc",
"watch-ts": "tsc --watch",
"test": "echo \"Error: no test specified\" && exit 1"
},
// ...
// src/index.ts
// ...
const port = process.env.PORT || 5000;
// Starts the app on the configured port, then calls the callback when
// the app successfully starts.
app.listen(port, () => {
console.log(`Listening on port ${port}: http://localhost:${port}`);
});
That PORT=5001
command will put "5001"
into process.env.PORT
, which we can use directly in the Node scripts. Now when you build and run the app, you should see Listening on port 5001: http://localhost:5001
.
However, we will have many environment variables to manage. To make this easier, we can create a .env
file at the root of our project that contains these environment variables, and then automatically load them in using a handy module called dotenv
.
First, create the .env
file:
# .env
PORT=5001
MONGODB_URL=mongodb://127.0.0.1:27017/moviesapp
Install dotenv
and its types:
npm install dotenv @types/dotenv --save
And initialize dotenv
in index.ts
(this is straight from the readme):
// src/index.ts
// ...
import * as dotenv from 'dotenv';
dotenv.config();
// Connect the database
// Notice how we got rid of the hardcoded URL:
connect(
process.env.MONGODB_URL,
{ useNewUrlParser: true, dbName: 'movies' }
)
.then(() => {
console.log('Connected to MongoDB');
})
.catch(err => {
console.log(
`MongoDB connection error - could not connect to ${
process.env.MONGODB_URL
}`
);
console.error(err);
});
// ...
const port = process.env.PORT || 5000;
app.listen(process.env.PORT, () => {
console.log(`Listening on port ${port}: http://localhost:${port}`);
});
And go ahead and revert the "serve"
script change in package.json
-- it's no longer needed. Build and run your app (npm run start
) to verify that everything still works.
.env
files are ignored. Check that this is in your .gitignore
file:
# dotenv environment variables file
.env
If you find yourself repeating something a lot, it's best to automate it away. Right now, we're finding ourselves having to stop and restart the app (npm run start
) every time we make a change that we want to view.
Instead, we can use the popular nodemon
package to reload the app automatically.
- Install
nodemon
:npm install nodemon --save-dev
(not used in production) - Add this NPM script to your
package.json
file:
{
// ...
"scripts": {
// ...
"watch": "npm run watch-ts & npm run serve-dev",
"serve-dev": "./node_modules/.bin/nodemon dist/index.js",
// ...
}
}
This will:
- Run the
watch-ts
andserve-dev
in parallel serve-dev
will detect changes todist/index.js
and the files it references and reload the running app whenever a change is made, which occurs wheneverwatch-ts
rebuilds the.ts
files.
ℹ️ At the time of writing, pressing control + C will stop both processes but return an errno 130
. This is innocuous, and is a temporary issue. See here for more details.
Run the app in watch mode via npm run watch
to prevent having to manually rebuild the app.
Let's work with the actual database and persist some data into it. Here's the plan:
- Create a
postMovie(...)
method inMoviesController.ts
that will create a newMovie
model based on the JSON request body and save (persist) it - Create a
POST /movies/new
route that uses thatpostMovie(...)
controller method - Configure the app to handle JSON requests
Express includes middleware for handling requests with the header Content-Type: application/json
which (assumedly) has a body with JSON content. This middleware is built into Express: express.json()
. Configure the app to use this middleware:
// src/index.ts
// ...
app.set('view engine', 'jsx');
app.engine('jsx', require('express-react-views').createEngine());
// Parses JSON in body
app.use(express.json()); // << Add this line
// ...
Create the POST request handler in MoviesController.ts
:
// src/controllers/MoviesController.ts
// ...
export async function postMovie(req: Request, res: Response) {
try {
// Create the new movie using the JSON data from the request body
const newMovie = new Movie(req.body);
// Persist the movie to the database
const savedMovie = await newMovie.save();
// Respond with the persisted data
return res.json(savedMovie);
} catch (ex) {
// Catch any validation errors
return res.status(400).send(ex.message);
}
}
And then create the route in MoviesRouter.ts
:
// src/routes/MoviesRouter.ts
// ...
// POST /movies/new
moviesRouter.post('/new', moviesController.postMovie);
Using Postman or cURL, make a POST request with:
- Headers:
Content-Type: application/json
- Body: any sample movie data that fits the
Movie
model schema:
{
"title": "Mission Impossible",
"releaseDate": "2018-06-06",
"genre": "Action",
"price": 10.5
}
You should get a response like:
{
"_id": "5b6db66479f65945894e0d1c",
"title": "Mission Impossible",
"releaseDate": "2018-06-06T00:00:00.000Z",
"genre": "Action",
"price": 10.5,
"__v": 0
}
If you try to send an invalid request, such as missing the title
attribute, Mongoose will throw an error like:
Movie validation failed: title: Path 'title' is required.
In our code, we're surfacing that error to the user with a 400 Bad Request
status code.
When creating a document in MongoDB (that is, an instance of a model), a unique ID is automatically assigned to it. Add a GET endpoint to retrieve movies by their ID:
- Create the
getMovie
request handler inMoviesController.ts
- This will query the database for a
Movie
model by its_id
and respond with it as JSON - If not found, this should respond with
404 Not Found
.
- This will query the database for a
- Add the
GET /movies/:id
route toMoviesRouter.ts
// src/controllers/MoviesController.ts
// ...
export async function getMovie(
req: Request,
res: Response,
next: NextFunction
) {
try {
const { id } = req.params;
const movie = await Movie.findById(id).exec();
if (!movie) {
return res.status(404).send('Movie not found');
}
return res.json(movie);
} catch (e) {
next(e);
}
}
// ...
// src/routers/MoviesRouter.ts
// ...
// GET /movies/:id
moviesRouter.get('/:id', moviesController.getMovie);
// ...
You can make a POST /movies/
request to create a movie, get its ID, and verify that the GET /movies/:id
endpoint is working. Keep in mind that this route expects a JSON body. We can add middleware to the router to ensure that this is the case:
// src/routers/MoviesRouter.ts
// Create a new router to handle /movies routes
const moviesRouter = Router();
// Ensure that POST, PUT, and PATCH methods only accept Content-Type: application/json bodies
moviesRouter.use((req, res, next) => {
if (
['POST', 'PUT', 'PATCH'].indexOf(req.method) !== -1 &&
!req.is('json')
) {
return res.status(415).send('Content-Type must be application/json');
}
return next();
});
At this point, we have a solid MVP for our Movies app, and we can deploy it to the cloud. We'll be using Azure App Service to do this. If you want more info on how to deploy a Node and MongoDB app to Azure App Service, check out this tutorial, which we will be following.
Here's the plan:
- Create a production MongoDB instance using Cosmos DB
- Configure environment variables on Azure
- Test the production environment locally
- Deploy app to Azure App Service
- Configure a deployment user
- Create an App Service Plan
- Create a Web App using local git
- Push to Azure from Git
This resource group will contain the web app and CosmosDB instance. https://docs.microsoft.com/en-us/azure/app-service/app-service-web-tutorial-nodejs-mongodb-app#create-a-resource-group
az group create \
--name moviesResourceGroup \
--location "East US"
az appservice plan create \
--name moviesServicePlan \
--resource-group moviesResourceGroup \
--sku FREE
To find the available Node runtimes, run az webapp list-runtimes | grep node
az webapp create \
--resource-group moviesResourceGroup \
--plan moviesServicePlan \
--name someGloballyUniqueMoviesApp \
--runtime "node|8.1" \
--deployment-local-git
Using Cosmos DB, we can interface with a database instance with the MongoDB API. This is done by specifying the --kind MongoDB
argument. Create a database with a globally unique name. Grab a coffee, because this might take a while. https://docs.microsoft.com/en-us/azure/app-service/app-service-web-tutorial-nodejs-mongodb-app#create-a-cosmos-db-account
globally-unique-movies
, I'm already using it.
az cosmosdb create \
--name globally-unique-movies \
--resource-group moviesResourceGroup \
--kind MongoDB
Once created, the database keys can be retrieved. https://docs.microsoft.com/en-us/azure/app-service/app-service-web-tutorial-nodejs-mongodb-app#connect-app-to-production-mongodb
az cosmosdb list-keys --name globally-unique-movies --resource-group moviesResourceGroup
The linked tutorial stores the production-specific environment variables locally; however, this is not recommended. Instead, you should be using Azure itself to manage production environment variables, and only keeping local environment variables locally.
There's an important non-obvious step that must be done for this Node app to deploy successfully. We need to create two files:
.deployment
- Contains the Azure deployment configdeploy.sh
- The actual script to run upon deployment
These files are automatically generated with the Azure-CLI deploymentscript
command:
azure site deploymentscript --node
When these files are generated, open deploy.sh
. You'll see all the fun stuff that's happening to succesfully deploy the Node project, but we need to add one thing. Go to step 3 and add eval $NPM_CMD run build-ts
:
# deploy.sh
# ...
# 3. Install npm packages
if [ -e "$DEPLOYMENT_TARGET/package.json" ]; then
cd "$DEPLOYMENT_TARGET"
eval $NPM_CMD install --production
eval $NPM_CMD run build-ts # << Add this line
exitWithMessageOnError "npm failed"
cd - > /dev/null
fi
This is so that TypeScript can compile the .ts
files into .js
files, output to the dist/
folder (remember: this is .gitignore
-d).
Finally, in order to make Kudu happy, add a server.js
to the root of the project that points directly to the compiled dist/index.js
file:
// server.js
require('./dist/index.js');
This is because Kudu looks for a root server.js
or app.js
file to generate a web.config
, which tells the IIS Server how to serve the app.
We'll need to configure one environment variable to connect to MongoDB:
MONGODB_URL
- the database connection URL (e.g.,"mongodb://127.0.0.1:27017/moviesapp"
)
To set this in Azure, use az webapp config appsettings set
:
az webapp config appsettings set \
--name someGloballyUniqueMoviesApp \
--resource-group moviesResourceGroup \
--settings MONGODB_URL="mongodb://<cosmosdb_name>:<primary_master_key>@<cosmosdb_name>.documents.azure.com:10250/mean?ssl=true"
Replace <cosmosdb_name>
with the created Cosmos DB database name (e.g., globally-unique-moviesmovies
) and <primary_master_key>
with the primaryMasterKey
property from running az cosmosdb list-keys --name globally-unique-movies --resource-group moviesResourceGroup
previously.
This will allow us to work with two separate environments - our local development
environment as well as our production
environment:
- The local development app will find the
MONGODB_URL
in the local.env
file (which is not checked in, thanks to the.gitignore
file) - The production app will have the
MONGODB_URL
environment variable set by Azure, and will read it from there.
Create deployment credentials using the az webapp deployment user set
command. https://docs.microsoft.com/en-us/azure/app-service/app-service-web-tutorial-nodejs-mongodb-app#deploy-app-to-azure
az webapp deployment user set --user-name <username> --password <password>
We will use Git to deploy the app, using the deploymentLocalGitUrl
from the az webapp create
step:
https://@.scm.azurewebsites.net/.git
Create a new remote URL for the above local Git URL:
git remote add azure <URL above>
This will let you deploy the app to Azure just by running:
git push azure head