Tredun ohjelmistokehittäjien kurssimateriaaleja
Node-express on yksinkertainen ja simppeli framework websovelluksille. Se sisältää HTTP-pyyntöjen reitityksen eli router:in sekä ketjuttuvia pyyntöjen käsittelijöitä eli middleware:ja.
Tässä tehdaan perus backend, seuraten väljästi näitä ohjeita.
Tee uusi projektikansio ja aja siellä express-generator. Tämä luo valmiin rungon webpalvelulle. Koska teemme frontin React:illa emme tarvitse view:tä (–no-view). Generoidaan myös .gitignore (–git).
npx express-generator --no-view --git
Asenna nyt tarvittavat kirjastot:
npm install
Asenna lisäksi nodemon, dotenv, knex, mysql2, bcryptjs, jsonwebtoken ja ajv.
npm install nodemon --save-dev
npm install dotenv --save
npm install mysql2 --save
npm install knex --save
npm install bcryptjs --save
npm install jsonwebtoken --save
npm install ajv --save
Tarkista, että .gitignore:ssa, jossa on vähintään (lisää, jos ei ole):
node_modules/
/node_modules
*.env
/build
build/
knexfile.*
Tee .env-tiedosto, jossa on kaikki ympäristömuuttujat. DEBUG-muuttuja aktivoi debug-tulostukset.
PORT=3001
DB_USER=root
DB_PASS=mypass123
DB_HOST=localhost
DB_PORT=3306
DB_TYPE=mysql2
DB_DATABASE=notes_db
SECRET=tosisalainensalasanainen
DEBUG=notesmiddleware:*
Ympäristömuuttujat voidaan ottaa käyttöön lisäämällä tiedoston alkuun:
require('dotenv').config()
Tehdään vielä apukirjasto ./utils/config.js, johon tallennetaan tietokantaparametrit knex:in vaatimassa muodossa (DATABASE_OPTIONS):
require('dotenv').config()
let PORT = process.env.PORT
let SECRET = process.env.SECRET
let DATABASE_OPTIONS = {
client: process.env.DB_TYPE,
connection: {
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_DATABASE
}
}
module.exports = {
DATABASE_OPTIONS,
PORT,
SECRET
}
Testataan, että backend käynnistyy. express-generaattori on luonut tiedoston /bin/www, jonka voi käynnistää nodemon:illa. Lisää siihen .env.
require('dotenv').config()
Lisää myös package.json -tiedostoon backendin käynnistyskomennot:
"start": "node ./bin/www",
"startdev": "nodemon ./bin/www"
Nyt kokeile käynnistää backend konsolilta:
npm run startdev
Avaa selaimeen http://localhost:3001, ruudulla pitäisi selaimessa näkyä: Express ja osoitteesta Http://localhost:3001/users pitäisi ilmestyä viesti: respond with a resource.
Reititys (router) toimii niin, että app.js tiedostosta ohjataan pyynnöt tarkemmalle käsittelijälle, joka sijaitsee toisessa tiedostossa. Näin saadaan modulaarinen rakenne viestien käsittelyyn. Oikeastaan router:it ovat eräänlaisia middleware:ja, jotka ketjutetaan yhteen next:in avulla.
Esimerkissä on valmiina kaksi endpoint:ia, jotka on tuotu app.js -tiedostoon.
app.use('/', indexRouter);
app.use('/users', usersRouter);
Tämän rakenteen avulla usersRouter:ssa endpoint:n nimet lyhenevät (huomaa next-parametri, jonka avulla näitä voidaan liittää ketjuksi):
router.get('/', function(req, res, next) {
res.send('respond with a resource');
});
Siirretään nyt notesdemon koodi erillisiin router-tiedostoihin. Rekisteröityminen registerRouter.js, kirjautuminen loginRouter.js ja muut tiedostoon notesRouter.js:
Uudet endpointit ovat nyt:
POST /register
POST /login
GET /notes
POST /notes
DELETE /notes/:id
PUT /notes/:id
loginRouter.js ja registerRouter.js sisältävät nyt:
var express = require('express');
var router = express.Router();
const config = require('../utils/config')
const options = config.DATABASE_OPTIONS;
const knex = require('knex')(options);
router.post('/', (request, response, next) => {
// koodia...
})
module.exports = router;
notesRouter.js sisältää nyt:
var express = require('express'); //uusi
var router = express.Router(); //uusi
const config = require('../utils/config')
const options = config.DATABASE_OPTIONS;
const knex = require('knex')(options);
router.get('/', (request, response, next) => {
// koodia
})
router.delete('/:id', (request, response, next) => {
// koodia
})
router.post('/', (request, response, next) => {
// koodia
})
router.put('/:id', (request, response, next) => {
// koodia
})
module.exports = router;
var express = require('express');
var router = express.Router();
const config = require('../utils/config')
const options = config.DATABASE_OPTIONS;
const knex = require('knex')(options);
const bcrypt = require('bcryptjs')
const jwt = require('jsonwebtoken')
router.post('/', (req, res, next) => {
const user = req.body;
console.log(user);
knex('users').select('*').where('username', '=', user.username)
.then((dbuser) => {
if (dbuser.length == 0) {
return res.status(401).json(
{ error: "invalid username or password" }
)
}
const tempUser = dbuser[0];
bcrypt.compare(user.password, tempUser.password)
.then((passwordCorrect) => {
if (!passwordCorrect) {
return res.status(401).json(
{ error: "invalid username or password" }
)
}
const userForToken = {
username: tempUser.username,
id: tempUser.id
}
const token = jwt.sign(userForToken, config.SECRET)
res.status(200).send({
token,
username: tempUser.username,
role: "regularuser"
})
})
})
.catch((err) => {
res.status(500).json(
{ error: err }
)
})
})
module.exports = router;
var express = require('express');
var router = express.Router();
const config = require('../utils/config')
const options = config.DATABASE_OPTIONS;
const knex = require('knex')(options);
const bcrypt = require('bcryptjs')
router.post('/', (req, res, next) => {
const user = req.body;
const saltRounds = 10;
console.log(user);
bcrypt.hash(user.password, saltRounds)
.then((passwordHash) => {
const newUser = {
username: user.username,
password: passwordHash,
email: user.email
}
knex('users').insert(newUser)
.then(() => {
res.status(204).end()
})
.catch((err) => {
console.log(err);
res.status(500).json(
{ error: err }
)
})
})
})
module.exports = router;
var express = require('express');
var router = express.Router();
const config = require('../utils/config')
const options = config.DATABASE_OPTIONS;
const knex = require('knex')(options);
router.get('/', (req, res, next) => {
const decodedTokenId = res.locals.auth.userId; // NEW
knex('notes').select('*').where('user_id', '=', decodedTokenId /*NEW*/)
.then((rows) => {
res.json(rows);
})
.catch((err) => {
console.log('SELECT * NOTES failed')
res.status(500).json(
{ error: err }
)
})
})
router.post('/', (req, res, next) => {
const note = req.body;
console.log(note);
note.user_id = res.locals.auth.userId; // NEW
const newNote = {
content: note.content,
important: note.important,
date: new Date(note.date),
user_id: note.user_id
}
knex('notes').insert(newNote)
.then(id_arr => {
console.log(id_arr);
note.id = id_arr[0];
res.json(note);
})
.catch((err) => {
console.log(err);
res.status(500).json(
{ error: err }
)
})
})
router.delete('/:id', (req, res, next) => {
const id = req.params.id;
console.log(id);
const decodedTokenId = res.locals.auth.userId; // NEW
knex('notes').where('user_id', "=", decodedTokenId).andWhere('id', '=', id).del()
.then(status => {
console.log("deleted ok")
res.status(204).end();
})
.catch((err) => {
console.log(err);
res.status(500).json(
{ error: err }
)
})
})
router.put('/:id', (req, res, next) => {
const id = req.params.id;
const note = req.body;
const decodedTokenId = res.locals.auth.userId; // NEW
const updatedNote = {
content: note.content,
important: note.important,
date: new Date(note.date)
}
knex('notes').update(updatedNote).where('user_id', "=", decodedTokenId /*NEW*/)
.andWhere('id', '=', id)
.then((response) => {
console.log(response)
res.status(204).end();
})
.catch((err) => {
console.log(err);
res.status(500).json(
{ error: err }
)
})
})
module.exports = router;
Tee middleware-kansio ja sen sisään auth.js-tietosto:
const jwt = require('jsonwebtoken')
const config = require('../utils/config')
const getTokenFrom = req => {
const authorization = req.get('authorization');
if (authorization && authorization.toLowerCase().startsWith('bearer ')) {
return authorization.substring(7)
} else {
return null
}
}
const isAuthenticated = (req, res, next) => {
const token = getTokenFrom(req);
console.log(token);
if (!token) {
return res.status(401).json(
{ error: "auth token missing" }
)
}
let decodedToken = null;
try {
decodedToken = jwt.verify(token, config.SECRET);
}
catch (error) {
console.log("jwt error")
}
if (!decodedToken || !decodedToken.id) {
return res.status(401).json(
{ error: "invalid token" }
)
}
res.locals.auth = { userId: decodedToken.id }; // NEW
next(); // NEW
}
module.exports = isAuthenticated;
Otetaan auth-middleware käyttöön notesRouter:ille:
app.use('/notes', isAuthenticated, notesRouter);
Otetaan auth-middleware käyttöön notesRouter:issa (poimitaan dekoodattu userId response.locals.auth - kentästä):
const userId = response.locals.auth.userId;
Koska backendin pitää testata sille tuleva data (tietotyypit, kenttien pituudet yms.) kätevintä on käyttää siihen tarkoitettua middleware-kirjastoa. Sellaisen voi tehdä myös itse. JSON-body:n sisällön vaatimukset voi esittää monella tavalla, mutta yksi standarditapa on käyttää JSON Schema - muotoa ja regexp-notaatiota. Sille on oma validaattorikirjastonsa ajv.
Koska tämä middleware tarvitsee eri scheman jokaiselle body:lle, se annetaan parametrina kutsuttaessa.
Tee ensin jokaiselle json-formaatille oma json-schema (omat tiedostot jokaiselle), laita nämä omaan kansioonsa schemas:
userschema.json:
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "user",
"type": "object",
"properties": {
"email": {
"type": "string",
"pattern": "^\\S+@\\S+\\.\\S+$",
"minLength": 5,
"maxLength": 50
},
"username": {
"type": "string",
"minLength": 6,
"maxLength": 32
},
"password": {
"type": "string",
"minLength": 8,
"maxLength": 32
},
"phonenumber": {
"type": "string",
"pattern": "\\+(9[976]\\d|8[987530]\\d|6[987]\\d|5[90]\\d|42\\d|3[875]\\d|2[98654321]\\d|9[8543210]|8[6421]|6[6543210]|5[87654321]|4[987654310]|3[9643210]|2[70]|7|1)\\d{1,14}$",
"minLength": 8,
"maxLength": 32
}
},
"required": [
"username",
"password"
]
}
notesschema.json:
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "note",
"type": "object",
"properties": {
"content": {
"type": "string",
"minLength": 1,
"maxLength": 500
},
"date": {
"type": "string",
"pattern": "\\b[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}.[0-9]{3}Z\\b"
},
"important": {
"type": "boolean"
},
"user_id": {
"type": "integer"
}
},
"required": [
"content",
"date",
"important"
]
}
Lisää app.js:ään seuraavat require-lauseet:
var userschema = require('./schemas/userschema.json');
var noteschema = require('./schemas/noteschema.json');
var validateSchema = require('./middleware/validate');
Lisää validateSchema-middleware-reitteihin:
app.use('/register', validateSchema(userschema), registerRouter);
app.use('/login', loginRouter);
app.use('/notes', isAuthenticated, validateSchema(noteschema), notesRouter);
Varsinainen validateSchema-middleware käyttää Ajv-kirjastoa validointiin. Se saa scheman parametrina ja palauttaa varsinaisen middleware - funtion (return palauttaa nimettömän funktion määrittelyn):
validate.js
const Ajv = require('ajv');
var ajv = new Ajv(); // options can be passed, e.g. {allErrors: true}
const validateSchema = (schema) => {
return function(req, res, next){
console.log("starting middleware2")
const reqmethod = req.method;
if(reqmethod === "POST" || reqmethod === "PUT"){
const body = req.body;
var validate = ajv.compile(schema);
var valid = validate(body);
if (!valid){
console.log(validate.errors);
return res.status(401).json(
{ error: "check json-data" })
} else {
next();
}
}
else {
next();
}
}
}
module.exports = validateSchema;
Notes-demo:ssa on tehty valmiiksi frontend, jonka avulla voi lisätä uusia muistiinpanoja, poistaa muistiinpanoja sekä muokata muistiinpanon tärkeyttä.
Jotta React-front saataisiin toimimaan tehdyn backendin kanssa, siitä pitää tehdä build. Prosessissa React-koodi (JSX) muutetaan tavalliseksi HTML:ksi sekä javascriptiksi. Valmis build syntyy build-kansioon.
Aja notes-frontend:in juuressa:
npm run build
Kopioi nyt koko build-kansio notesmiddleware:n juureen. Jos express-reititys ei löydä annettua reittiä, se voidaan ohjata palauttamaan staattisia webbisivuja. Tämä saadaan aikaan ottamalla käytöön express.static-middleware (on valmiina express-generaattorin tekemässä koodissa), riittää, että muutat sen käyttämäksi kansioksi build:
app.use(express.static(path.join(__dirname, 'build')));
Jos käytät frontissa React-routeria, kun selaimessa painaa F5, yrittää se ladata frontend:in sisäistä reittiä (route) backend:iltä. Tämä aiheuttaa virheen, koska sellaista reittiä ei löydy backendissä tai jos löytyykin niin se ei palauta mitään järkevää HTML-koodia selaimelle. Jotta frontin omat reitit eivät menisi sekaisin backend:in reittien kanssa, käytä backend:in reittien edessä liitettä /api eli tässä vaihessa olisi hyvä muutta backendin reitit, sama muutos vaaditaan frontin serviceen (baseURL):
```js
app.use('/api/register', validateSchema(userschema), registerRouter);
app.use('/api/login', loginRouter);
app.use('/api/notes', isAuthenticated, validateSchema(noteschema), notesRouter);
```
Kaikki muut reitit (/* wildcard) pitäisi ohjata lataamaan react-build static-kansiosta, lisää tämä viimeiseksi default-reitiksi:
```js
app.get('/*', function(req, res) {
res.sendFile(path.join(__dirname, 'build/index.html'), function(err) {
if (err) {
res.status(500).send(err)
}
})
```
Muista lisätä myös tämä (jos ei ole jo):
```js
var path = require('path');
```
Backend:in testaaminen voidaan tehdä Visual Studio code:n REST clientilla.
Sen voi asentaa täältä.
POST http://localhost:3001/register HTTP/1.1
content-type: application/json
{
"username": "tester123",
"password": "salasana",
"email": "tester@test.net"
}
POST http://localhost:3001/login HTTP/1.1
content-type: application/json
{
"username": "tester123",
"password": "salasana"
}
GET http://localhost:3001/notes HTTP/1.1
Authorization: bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InRlc3RlcjEiLCJpZCI6NCwiaWF0IjoxNjA2NzI1MDA4fQ.diCumc1pPJZGSiFp7ysqaWc5lnoZvfpZ-mBsoXfiJ0c
POST http://localhost:3001/notes HTTP/1.1
Content-Type: application/json
Authorization: bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InRlc3RlcjEiLCJpZCI6NCwiaWF0IjoxNjA2NzI1MDA4fQ.diCumc1pPJZGSiFp7ysqaWc5lnoZvfpZ-mBsoXfiJ0c
{
"content": "Uusi viesti käyttäjältä tester123",
"date": "2020-09-11T08:49:31.098Z",
"important": true
}
DELETE http://localhost:3001/notes/8 HTTP/1.1
Authorization: bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InRlc3RlcjEiLCJpZCI6NCwiaWF0IjoxNjA2NzI1MDA4fQ.diCumc1pPJZGSiFp7ysqaWc5lnoZvfpZ-mBsoXfiJ0c
PUT http://localhost:3001/notes/9 HTTP/1.1
Content-Type: application/json
Authorization: bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InRlc3RlcjEiLCJpZCI6NCwiaWF0IjoxNjA2NzI1MDA4fQ.diCumc1pPJZGSiFp7ysqaWc5lnoZvfpZ-mBsoXfiJ0c
{
"content": "Muutettu viesti käyttäjältä tester123",
"date": "2020-09-11T08:49:31.098Z",
"important": true
}