refactor: Cleanup (#21)

* refactor(routes): remove unused sitemap route

* style(comments): enforce consistent comment style

* style(exports): enforce consistent export style

* chore: Add jsDoc for contentful service

* chore: Add jsDoc for contentful service \n \n closes #23

* refactor(app): move query parameter comment to right position and mention comment route middleware

* test(npm): add temporary test script

* refactor(middlewares): split up bootstrap middleware - fixes #23759

* refactor(cookies): use constances to give context the maxAge cookie setting

* refactor(variables): use more descriptive names for variables

* space became spaceId when it was just the id not the full space instance
* all (access) token variable name variants became [api-type]Token
* all clients are now called deliveryClient or previewClient
* cpa and cda only remain when they are used as actual API id
* env variables names adjusted

* perf(helpers): only run marked when content is not empty

* refactor(comments): fix typos

* refactor(comments): add hint why error is logged to console in settings

* chore: Add comments to routes and services

* refactor(requires): order and group requires

* fix(settings): add validation for wrong preview token

* chore: Add comments to routes

* chore: fix typo

* chore: console.error -> throw

* chore: move cookie name to a constant

* chore: Fix app.js comment

* typo: removing t

* chore: typos

* chore: removed unnecessary comment line sign

* chore: newline for readabillity

* chore: remove dangling `t`

* chore: remove `t`, add `l`

* chore: typos

* Fleshed out title

* build(npm): remove unused dependencies

* build(npm): upgrade to latest stable contentful sdk

* chore: Addressing David feedbak

* fix(credentials): update to match the new space

* chore: Addressing code review comments

* chore: Addressing code review comments

* chore: res -> response, req-> request

* chore: include exactly what we need

* chore: Address JPs comments

* chore: Address JPs comments

* chore: Address Fredericks comments

* chore: Address Fredericks comments

* fixup! chore: Address Fredericks comments

* fixup! fixup! chore: Address Fredericks comments

* fixup! fixup! fixup! chore: Address Fredericks comments

* fixup! fixup! fixup! fixup! chore: Address Fredericks comments

* fixup! fixup! fixup! fixup! fixup! chore: Address Fredericks comments

* fixup! fixup! fixup! fixup! fixup! fixup! chore: Address Fredericks comments
This commit is contained in:
Benedikt Rötsch
2017-11-03 10:58:42 +01:00
parent 730ce24cc9
commit 7ad2c1fa4f
25 changed files with 507 additions and 340 deletions

View File

@@ -4,5 +4,15 @@ module.exports = {
'plugins': [
'standard',
'promise'
]
}
],
'env': {
'node': true
},
'rules': {
"capitalized-comments": [
"error",
"always"
],
"spaced-comment": ["error", "always"]
}
}

View File

@@ -1,5 +1,5 @@
# the-example-app.js
The Contentful example app, written in JS
# The Node.js example app
The Contentful example app, written in node.js.
## Requirements
@@ -30,6 +30,6 @@ Open http://localhost:3000/?enable_editorial_features in your browser.
The following deep links are supported:
* `?enable_editorial_features` - Shows `Edit in web app` button on every content type plus `Draft` and `Pending Changes` status pills
* `?space_id=xxx&delivery_access_token=xxx&preview_access_token=xxx` - Configure the connected space
* `?space_id=xxx&delivery_token=xxx&preview_token=xxx` - Configure the connected space

123
app.js
View File

@@ -1,19 +1,25 @@
require('dotenv').config({ path: 'variables.env' })
const express = require('express')
const querystring = require('querystring')
const path = require('path')
const helpers = require('./helpers')
const logger = require('morgan')
const cookieParser = require('cookie-parser')
const bodyParser = require('body-parser')
const cookieParser = require('cookie-parser')
const express = require('express')
const logger = require('morgan')
const querystring = require('querystring')
const routes = require('./routes/index')
// Load environment variables using dotenv
require('dotenv').config({ path: 'variables.env' })
const { initClient, getSpace } = require('./services/contentful')
const helpers = require('./helpers')
const breadcrumb = require('./lib/breadcrumb')
const routes = require('./routes/index')
const { initClients, getSpace } = require('./services/contentful')
const { updateCookie } = require('./lib/cookies')
const app = express()
// view engine setup
const SETTINGS_NAME = 'theExampleAppSettings'
// View engine setup
app.set('views', path.join(__dirname, 'views'))
app.set('view engine', 'pug')
@@ -24,43 +30,46 @@ app.use(cookieParser())
app.use(express.static(path.join(__dirname, 'public')))
app.use(breadcrumb())
// Pass our application state and custom helpers to all our templates
app.use(async function (req, res, next) {
// Allow setting of API credentials via query parameters
// Set our application state based on environment variables or query parameters
app.use(async function (request, response, next) {
// Set default settings based on environment variables
let settings = {
space: process.env.CF_SPACE,
cda: process.env.CF_ACCESS_TOKEN,
cpa: process.env.CF_PREVIEW_ACCESS_TOKEN,
spaceId: process.env.CONTENTFUL_SPACE_ID,
deliveryToken: process.env.CONTENTFUL_DELIVERY_TOKEN,
previewToken: process.env.CONTENTFUL_PREVIEW_TOKEN,
editorialFeatures: false,
...req.cookies.theExampleAppSettings
// Overwrite default settings using those stored in a cookie
...request.cookies.theExampleAppSettings
}
const { space_id, preview_access_token, delivery_access_token } = req.query
if (space_id && preview_access_token && delivery_access_token) { // eslint-disable-line camelcase
// Allow setting of API credentials via query parameters
const { space_id, preview_token, delivery_token } = request.query
if (space_id && preview_token && delivery_token) { // eslint-disable-line camelcase
settings = {
...settings,
space: space_id,
cda: delivery_access_token,
cpa: preview_access_token
spaceId: space_id,
deliveryToken: delivery_token,
previewToken: preview_token
}
res.cookie('theExampleAppSettings', settings, { maxAge: 31536000, httpOnly: true })
updateCookie(response, SETTINGS_NAME, settings)
}
// Allow enabling of editorial features via query parameters
const { enable_editorial_features } = req.query
const { enable_editorial_features } = request.query
if (enable_editorial_features !== undefined) { // eslint-disable-line camelcase
delete req.query.enable_editorial_features
settings = {
...settings,
editorialFeatures: true
}
res.cookie('theExampleAppSettings', settings, { maxAge: 31536000, httpOnly: true })
delete request.query.enable_editorial_features
settings.editorialFeatures = true
updateCookie(response, SETTINGS_NAME, settings)
}
initClient(settings)
res.locals.settings = settings
initClients(settings)
response.locals.settings = settings
next()
})
// Manage language and API type state and make it globally available
// Make data available for our views to consume
app.use(async function (request, response, next) {
// Set active api based on query parameter
const apis = [
{
id: 'cda',
@@ -72,54 +81,56 @@ app.use(async function (req, res, next) {
}
]
res.locals.currentApi = apis
.find((api) => api.id === (req.query.api || 'cda'))
response.locals.currentApi = apis
.find((api) => api.id === (request.query.api || 'cda'))
// Get enabled locales from Contentful
const space = await getSpace()
res.locals.locales = space.locales
response.locals.locales = space.locales
const defaultLocale = res.locals.locales
const defaultLocale = response.locals.locales
.find((locale) => locale.default)
if (req.query.locale) {
res.locals.currentLocale = space.locales
.find((locale) => locale.code === req.query.locale)
if (request.query.locale) {
response.locals.currentLocale = space.locales
.find((locale) => locale.code === request.query.locale)
}
if (!res.locals.currentLocale) {
res.locals.currentLocale = defaultLocale
if (!response.locals.currentLocale) {
response.locals.currentLocale = defaultLocale
}
// Inject custom helpers
res.locals.helpers = helpers
response.locals.helpers = helpers
// Make query string available in templates
const qs = querystring.stringify(req.query)
res.locals.queryString = qs ? `?${qs}` : ''
res.locals.query = req.query
res.locals.currentPath = req.path
// Make query string available in templates to render links properly
const qs = querystring.stringify(request.query)
response.locals.queryString = qs ? `?${qs}` : ''
response.locals.query = request.query
response.locals.currentPath = request.path
next()
})
// Initialize the route handling
// Check ./routes/index.js to get a list of all implemented routes
app.use('/', routes)
// catch 404 and forward to error handler
app.use(function (req, res, next) {
// Catch 404 and forward to error handler
app.use(function (request, response, next) {
var err = new Error('Not Found')
err.status = 404
next(err)
})
// error handler
app.use(function (err, req, res, next) {
// set locals, only providing error in development
res.locals.error = req.app.get('env') === 'development' ? err : {}
// Error handler
app.use(function (err, request, response, next) {
// Set locals, only providing error in development
response.locals.error = request.app.get('env') === 'development' ? err : {}
// render the error page
res.status(err.status || 500)
res.render('error')
// Render the error page
response.status(err.status || 500)
response.render('error')
})
module.exports = app

30
bin/www
View File

@@ -1,46 +1,42 @@
#!/usr/bin/env node
/**
* Module dependencies.
* Module dependencies
*/
const app = require('../app')
const http = require('http')
/**
* Get port from environment and store in Express.
*/
/**
* Get port from environment and store in Express
*/
const port = normalizePort(process.env.PORT || '3000')
app.set('port', port)
/**
* Create HTTP server.
* Create HTTP server
*/
const server = http.createServer(app)
/**
* Listen on provided port, on all network interfaces.
* Listen on provided port, on all network interfaces
*/
server.listen(port)
server.on('error', onError)
server.on('listening', onListening)
/**
* Normalize a port into a number, string, or false.
* Normalize a port into a number, string, or false
*/
function normalizePort (val) {
const port = parseInt(val, 10)
if (isNaN(port)) {
// named pipe
// Named pipe
return val
}
if (port >= 0) {
// port number
// Port number
return port
}
@@ -48,9 +44,8 @@ function normalizePort (val) {
}
/**
* Event listener for HTTP server "error" event.
* Event listener for HTTP server "error" event
*/
function onError (error) {
if (error.syscall !== 'listen') {
throw error
@@ -60,7 +55,7 @@ function onError (error) {
? 'Pipe ' + port
: 'Port ' + port
// handle specific listen errors with friendly messages
// Handle specific listen errors with friendly messages
switch (error.code) {
case 'EACCES':
console.error(bind + ' requires elevated privileges')
@@ -76,9 +71,8 @@ function onError (error) {
}
/**
* Event listener for HTTP server "listening" event.
* Event listener for HTTP server "listening" event
*/
function onListening () {
const addr = server.address()
const bind = typeof addr === 'string'

View File

@@ -1,13 +1,12 @@
/*
Catch Errors Handler
With async/await, you need some way to catch errors.
Instead of using try{} catch(e) {} in each controller, we wrap the function in
catchErrors(), catch any errors they throw, and pass it along to our express middleware with next().
*/
/**
* Catch Errors Handler
* Instead of using try{} catch(e) {} in each controller, we wrap the function in
* catchErrors(), catch any errors they throw, and pass it along to our express middleware with next().
*/
exports.catchErrors = (fn) => {
return function (req, res, next) {
return fn(req, res, next).catch((e) => {
module.exports.catchErrors = (fn) => {
return function (request, response, next) {
return fn(request, response, next).catch((e) => {
next(e)
})
}

View File

@@ -1,23 +1,28 @@
const marked = require('marked')
// Parse markdown text
exports.markdown = (content) => {
content = content || ''
return marked(removeIvalidDataURL(content), {sanitize: true})
module.exports.markdown = (content = '') => {
if (!content.trim()) {
return ''
}
return marked(removeInvalidDataURL(content), {sanitize: true})
}
// Dump is a handy debugging function we can use to sort of "console.log" our data
exports.dump = (obj) => JSON.stringify(obj, null, 2)
// A handy debugging function we can use to sort of "console.log" our data
module.exports.dump = (obj) => JSON.stringify(obj, null, 2)
// Evil users might try to add base64 url data to execute js
// so we should take care of that
function removeIvalidDataURL (content) {
let regex = /data:\S+;base64\S*/gm
return content.replace(regex, '#')
}
exports.formatMetaTitle = (title) => {
module.exports.formatMetaTitle = (title) => {
if (!title) {
return 'The Example App'
}
return `${title.charAt(0).toUpperCase()}${title.slice(1)} — The Example App`
}
/**
* Evil users might try to add base64 url data to execute js code
* so we should purge any potentially harmful data to mitigate risk
*/
function removeInvalidDataURL (content) {
let regex = /data:\S+;base64\S*/gm
return content.replace(regex, '#')
}

View File

@@ -1,9 +1,9 @@
const url = require('url')
module.exports = (modifier) => {
return (req, res, next) => {
const baseUrl = url.format({ protocol: req.protocol, host: req.get('host') })
const parts = url.parse(req.url).pathname.split('/').filter(Boolean)
return (request, response, next) => {
const baseUrl = url.format({ protocol: request.protocol, host: request.get('host') })
const parts = url.parse(request.url).pathname.split('/').filter(Boolean)
let items = []
items.push({ label: 'Home', url: baseUrl })
@@ -19,9 +19,8 @@ module.exports = (modifier) => {
if (modifier) {
items = items.map(modifier)
}
// make it global
req.app.locals.breadcrumb = items
// next operation
// Make it global
request.app.locals.breadcrumb = items
next()
}
}

4
lib/cookies.js Normal file
View File

@@ -0,0 +1,4 @@
const ONE_YEAR_IN_SECONDS = 31536000
module.exports.updateCookie = (response, cookieName, value) => {
response.cookie(cookieName, value, { maxAge: ONE_YEAR_IN_SECONDS, httpOnly: true })
}

View File

@@ -7,6 +7,7 @@
"start": "node ./bin/www",
"lint": "eslint ./app.js routes",
"format": "eslint --fix . bin --ignore public node_modules",
"test": "echo 'test'",
"test:integration": "jest test/integration",
"test:integration:watch": "jest test/integration --watch",
"test:unit": "jest test/unit",
@@ -17,16 +18,13 @@
},
"dependencies": {
"body-parser": "~1.15.2",
"contentful": "^5.0.0-rc3",
"contentful": "^5.0.2",
"cookie-parser": "~1.4.3",
"debug": "~2.2.0",
"dotenv": "^4.0.0",
"express": "~4.14.0",
"jstransformer-markdown-it": "^2.0.0",
"marked": "^0.3.6",
"morgan": "~1.7.0",
"pug": "~2.0.0-beta6",
"safe-json-stringify": "^1.0.4"
"pug": "~2.0.0-beta6"
},
"devDependencies": {
"cheerio": "^1.0.0-rc.2",
@@ -37,8 +35,6 @@
"eslint-plugin-standard": "^2.0.1",
"jest": "^21.2.1",
"nodemon": "^1.12.1",
"pug-lint": "^2.5.0",
"superagent": "^3.6.3",
"supertest": "^3.0.0"
}
}

View File

@@ -1,5 +1,9 @@
/* GET category listing. */
exports.getCategories = async (req, res, next) => {
res.render('categories', { title: 'Categories' })
/*
* The purpose of this module is to render the category page when the route is requested
*/
// Renders categories page when `/categories` route is requested
module.exports.getCategories = async (request, response, next) => {
response.render('categories', { title: 'Categories' })
}

View File

@@ -1,80 +1,124 @@
const {getCourses, getCourse, getCategories, getCoursesByCategory} = require('./../services/contentful')
const attachEntryState = require('./../lib/entry-state')
/*
* The purpose of this module is to render the category page when the route is requested
*/
exports.getCourses = async (req, res, next) => {
// We get all the entries with the content type `course`
const {
getCourses,
getCourse,
getCategories,
getCoursesByCategory
} = require('./../services/contentful')
const attachEntryState = require('../lib/entry-state')
const { updateCookie } = require('../lib/cookies')
/**
* Renders courses list when `/courses` route is requested
*
* @param request - Object - Express request object
* @param response - Object - Express response object
* @param next - Function - express callback
*
* @returns {undefined}
*/
module.exports.getCourses = async (request, response, next) => {
// Get all the entries of content type course
let courses = []
let categories = []
courses = await getCourses(res.locals.currentLocale.code, res.locals.currentApi.id)
courses = await getCourses(response.locals.currentLocale.code, response.locals.currentApi.id)
// Attach entry state flags when using preview API
if (res.locals.settings.editorialFeatures && res.locals.currentApi.id === 'cpa') {
if (response.locals.settings.editorialFeatures && response.locals.currentApi.id === 'cpa') {
courses = await Promise.all(courses.map(attachEntryState))
}
categories = await getCategories(res.locals.currentLocale.code, res.locals.currentApi.id)
res.render('courses', { title: `All Courses (${courses.length})`, categories, courses })
categories = await getCategories(response.locals.currentLocale.code, response.locals.currentApi.id)
response.render('courses', { title: `All Courses (${courses.length})`, categories, courses })
}
exports.getCourse = async (req, res, next) => {
let course = await getCourse(req.params.slug, res.locals.currentLocale.code, res.locals.currentApi.id)
/**
* Renders a course when `/courses/:slug` route is requested
*
* @param request - Object - Express request object
* @param response - Object - Express response object
* @param next - Function - express callback
*
* @returns {undefined}
*/
module.exports.getCourse = async (request, response, next) => {
let course = await getCourse(request.params.slug, response.locals.currentLocale.code, response.locals.currentApi.id)
// Get lessons
const lessons = course.fields.lessons
const lessonIndex = lessons.findIndex((lesson) => lesson.fields.slug === req.params.lslug)
const lesson = lessons[lessonIndex]
let {lesson, lessonIndex} = getNextLesson(lessons, request.params.lslug)
// Save visited lessons
const cookie = req.cookies.visitedLessons
// Manage state of viewed lessons
const cookie = request.cookies.visitedLessons
let visitedLessons = cookie || []
visitedLessons.push(course.sys.id)
visitedLessons = [...new Set(visitedLessons)]
res.cookie('visitedLessons', visitedLessons, { maxAge: 900000, httpOnly: true })
updateCookie(response, 'visitedLessons', visitedLessons)
// Attach entry state flags when using preview API
if (res.locals.settings.editorialFeatures && res.locals.currentApi.id === 'cpa') {
if (response.locals.settings.editorialFeatures && response.locals.currentApi.id === 'cpa') {
course = await attachEntryState(course)
}
res.render('course', {title: course.fields.title, course, lesson, lessons, lessonIndex, visitedLessons})
response.render('course', {title: course.fields.title, course, lesson, lessons, lessonIndex, visitedLessons})
}
exports.getCoursesByCategory = async (req, res, next) => {
/**
* Renders a courses list by a category when `/courses/category/:category` route is requested
*
* @param request - Object - Express request object
* @param response - Object - Express response object
* @param next - Function - Express callback
*
* @returns {undefined}
*/
module.exports.getCoursesByCategory = async (request, response, next) => {
// We get all the entries with the content type `course` filtered by a category
let courses = []
let categories = []
let activeCategory = ''
try {
categories = await getCategories()
activeCategory = categories.find((category) => category.fields.slug === req.params.category)
courses = await getCoursesByCategory(activeCategory.sys.id, res.locals.currentLocale.code, res.locals.currentApi.id)
activeCategory = categories.find((category) => category.fields.slug === request.params.category)
courses = await getCoursesByCategory(activeCategory.sys.id, response.locals.currentLocale.code, response.locals.currentApi.id)
} catch (e) {
console.log('Error ', e)
}
res.render('courses', { title: `${activeCategory.fields.title} (${courses.length})`, categories, courses })
response.render('courses', { title: `${activeCategory.fields.title} (${courses.length})`, categories, courses })
}
/* GET course lesson detail. */
exports.getLesson = async (req, res, next) => {
let course = await getCourse(req.params.cslug, res.locals.currentLocale.code, res.locals.currentApi.id)
/**
* Renders a lesson details when `/courses/:courseSlug/lessons/:lessonSlug` route is requested
*
* @param request - Object - Express request object
* @param response - Object - Express response object
* @param next - Function - express callback
*
* @returns {undefined}
*/
module.exports.getLesson = async (request, response, next) => {
let course = await getCourse(request.params.cslug, response.locals.currentLocale.code, response.locals.currentApi.id)
const lessons = course.fields.lessons
const lessonIndex = lessons.findIndex((lesson) => lesson.fields.slug === req.params.lslug)
let lesson = lessons[lessonIndex]
const nextLesson = lessons[lessonIndex + 1] || null
let {lesson, nextLesson} = getNextLesson(lessons, request.params.lslug)
// Save visited lessons
const cookie = req.cookies.visitedLessons
const cookie = request.cookies.visitedLessons
let visitedLessons = cookie || []
visitedLessons.push(lesson.sys.id)
visitedLessons = [...new Set(visitedLessons)]
res.cookie('visitedLessons', visitedLessons, { maxAge: 900000, httpOnly: true })
updateCookie(response, 'visitedLessons', visitedLessons)
// Attach entry state flags when using preview API
if (res.locals.settings.editorialFeatures && res.locals.currentApi.id === 'cpa') {
if (response.locals.settings.editorialFeatures && response.locals.currentApi.id === 'cpa') {
lesson = await attachEntryState(lesson)
}
res.render('course', {
response.render('course', {
title: `${course.fields.title} | ${lesson.fields.title}`,
course,
lesson,
@@ -84,3 +128,14 @@ exports.getLesson = async (req, res, next) => {
})
}
function getNextLesson (lessons, lslug) {
const lessonIndex = lessons.findIndex((lesson) => lesson.fields.slug === lslug)
let lesson = lessons[lessonIndex]
const nextLesson = lessons[lessonIndex + 1] || null
return {
lessonIndex,
lesson,
nextLesson
}
}

View File

@@ -1,4 +1,11 @@
exports.getImprint = (req, res, next) => {
res.render('imprint', { title: 'Imprint' })
/**
* Renders imprint page when `/imprint` route is requested
* @param request - Object - Express request
* @param response - Object - Express response
* @param next - Function - Express callback
* @returns {undefined}
*/
module.exports.getImprint = (request, response, next) => {
response.render('imprint', { title: 'Imprint' })
}

View File

@@ -1,30 +1,32 @@
/**
* This module connects rendering modules to routes
*/
const express = require('express')
const { catchErrors } = require('../handlers/errorHandlers')
const { getCourses, getCourse, getLesson, getCoursesByCategory } = require('./courses')
const { getSettings, postSettings } = require('./settings')
const { getSitemap } = require('./sitemap')
const { getLandingPage } = require('./landingPage')
const { getImprint } = require('./imprint')
const router = express.Router()
/* GET the home landing page. */
const { catchErrors } = require('../handlers/errorHandlers')
const { getCourses, getCourse, getLesson, getCoursesByCategory } = require('./courses')
const { getSettings, postSettings } = require('./settings')
const { getLandingPage } = require('./landingPage')
const { getImprint } = require('./imprint')
// GET the home landing page
router.get('/', catchErrors(getLandingPage))
/* Courses Routes */
// Courses routes
router.get('/courses', catchErrors(getCourses))
router.get('/courses/categories/:category', catchErrors(getCoursesByCategory))
router.get('/courses/:slug', catchErrors(getCourse))
router.get('/courses/:slug/lessons', catchErrors(getCourse))
router.get('/courses/:cslug/lessons/:lslug', catchErrors(getLesson))
/* Settings Routes */
// Settings routes
router.get('/settings', catchErrors(getSettings))
router.post('/settings', catchErrors(postSettings))
/* Sitemap Route */
router.get('/sitemap', catchErrors(getSitemap))
/* Imprint Route */
// Imprint route
router.get('/imprint', catchErrors(getImprint))
module.exports = router

View File

@@ -1,21 +1,36 @@
const { getLandingPage } = require('../services/contentful')
const attachEntryState = require('./../lib/entry-state')
/**
* This module renders a layout when its route is requested
* It is used for pages like home page
*/
const url = require('url')
exports.getLandingPage = async (req, res, next) => {
let pathname = url.parse(req.url).pathname.split('/').filter(Boolean)[0]
const { getLandingPage } = require('../services/contentful')
const attachEntryState = require('./../lib/entry-state')
/**
* Renders a landing page when `/` route is requested
* based on the pathname an entry is queried from contentful
* and a view is rendered from the pulled data
*
* @param request - Object - Express request
* @param response - Object - Express response
* @param next - Function - Express callback
* @returns {undefined}
*/
module.exports.getLandingPage = async (request, response, next) => {
let pathname = url.parse(request.url).pathname.split('/').filter(Boolean)[0]
pathname = pathname || 'home'
let landingPage = await getLandingPage(
pathname,
res.locals.currentLocale.code,
res.locals.currentApi.id
response.locals.currentLocale.code,
response.locals.currentApi.id
)
// Attach entry state flags when using preview APIgs
if (res.locals.settings.editorialFeatures && res.locals.currentApi.id === 'cpa') {
// Attach entry state flags when using preview API
if (response.locals.settings.editorialFeatures && response.locals.currentApi.id === 'cpa') {
landingPage = await attachEntryState(landingPage)
}
res.render('landingPage', { title: pathname, landingPage })
response.render('landingPage', { title: pathname, landingPage })
}

View File

@@ -1,16 +1,25 @@
/**
* This module renders the settings page when `settings` route is requested
* it also saves the settings to a cookie
*/
const { createClient } = require('contentful')
const { initClient, getSpace } = require('./../services/contentful')
const { initClients, getSpace } = require('./../services/contentful')
const { updateCookie } = require('../lib/cookies')
async function renderSettings (res, opts) {
// Get connectred space to display the space name on top of the settings
const SETTINGS_NAME = 'theExampleAppSettings'
async function renderSettings (response, opts) {
// Get connected space to display the space name on top of the settings
let space = false
try {
space = await getSpace()
} catch (error) {
console.error(error)
// We throw the error here, it will be handled by the error middleware
// We keep space false to ensure the "Connected to" box is not shown.
throw (error)
}
res.render('settings', {
response.render('settings', {
title: 'Settings',
errors: {},
hasErrors: false,
@@ -20,68 +29,85 @@ async function renderSettings (res, opts) {
})
}
/* GET settings page. */
exports.getSettings = async (req, res, next) => {
const { settings } = res.locals
await renderSettings(res, {
/**
* Renders the settings page when `/settings` route is requested
*
* @param request - Object - Express request
* @param response - Object - Express response
* @param next - Function - Express callback
*
* @returns {undefined}
*/
module.exports.getSettings = async (request, response, next) => {
const { settings } = response.locals
await renderSettings(response, {
settings
})
}
/* POST settings page. */
exports.postSettings = async (req, res, next) => {
/**
* Save settings when POST request is triggered to the `/settings` route
* and render the settings page
*
* @param request - Object - Express request
* @param response - Object - Express response
* @param next - Function - Express callback
*
* @returns {undefined}
*/
module.exports.postSettings = async (request, response, next) => {
const errorList = []
const { space, cda, cpa, editorialFeatures } = req.body
const { spaceId, deliveryToken, previewToken, editorialFeatures } = request.body
const settings = {
space,
cda,
cpa,
spaceId,
deliveryToken,
previewToken,
editorialFeatures: !!editorialFeatures
}
// Validate required fields.
if (!space) {
if (!spaceId) {
errorList.push({
field: 'space',
field: 'spaceId',
message: 'This field is required'
})
}
if (!cda) {
if (!deliveryToken) {
errorList.push({
field: 'cda',
field: 'deliveryToken',
message: 'This field is required'
})
}
if (!cpa) {
if (!previewToken) {
errorList.push({
field: 'cpa',
field: 'previewToken',
message: 'This field is required'
})
}
// Validate space and CDA access token.
if (space && cda) {
// Validate space and delivery access token.
if (spaceId && deliveryToken) {
try {
await createClient({
space,
accessToken: cda
space: spaceId,
accessToken: deliveryToken
}).getSpace()
} catch (err) {
if (err.response.status === 401) {
errorList.push({
field: 'cda',
field: 'deliveryToken',
message: 'Your Delivery API key is invalid.'
})
} else if (err.response.status === 404) {
errorList.push({
field: 'space',
field: 'spaceId',
message: 'This space does not exist or your access token is not associated with your space.'
})
} else {
errorList.push({
field: 'cda',
field: 'deliveryToken',
message: `Something went wrong: ${err.response.data.message}`
})
}
@@ -89,24 +115,27 @@ exports.postSettings = async (req, res, next) => {
}
// Validate space and CPA access token.
if (space && cpa) {
if (spaceId && previewToken) {
try {
await createClient({
space,
accessToken: cpa,
space: spaceId,
accessToken: previewToken,
host: 'preview.contentful.com'
}).getSpace()
} catch (err) {
if (err.response.status === 401) {
errorList.push({
field: 'cpa',
field: 'previewToken',
message: 'Your Preview API key is invalid.'
})
} else if (err.response.status === 404) {
// Already validated via CDA
errorList.push({
field: 'spaceId',
message: 'This space does not exist or your delivery token is not associated with your space.'
})
} else {
errorList.push({
field: 'cpa',
field: 'previewToken',
message: `Something went wrong: ${err.response.data.message}`
})
}
@@ -116,11 +145,11 @@ exports.postSettings = async (req, res, next) => {
// When no errors occurred
if (!errorList.length) {
// Store new settings
res.cookie('theExampleAppSettings', settings, { maxAge: 31536000, httpOnly: true })
res.locals.settings = settings
updateCookie(response, SETTINGS_NAME, settings)
response.locals.settings = settings
// Reinit clients
initClient(settings)
initClients(settings)
}
// Generate error dictionary
@@ -135,7 +164,7 @@ exports.postSettings = async (req, res, next) => {
}
}, {})
await renderSettings(res, {
await renderSettings(response, {
settings,
errors,
hasErrors: errorList.length > 0,

View File

@@ -1,5 +0,0 @@
/* GET sitemap page. */
exports.getSitemap = async (req, res, next) => {
res.render('sitemap', { title: 'Sitemap' })
}

View File

@@ -1,107 +1,148 @@
/**
* The purpose of this module is to get data from contentful
*/
const { createClient } = require('contentful')
let cdaClient = null
let cpaClient = null
let deliveryClient = null
let previewClient = null
// Initialize our client
exports.initClient = (options) => {
// Getting the version the app version
/**
* Initialize the contentful Client
* @param options {space: string, cda: string, cpa: string}
*
* @returns {undefined}
*/
module.exports.initClients = (options) => {
// Getting the app version
const { version } = require('../package.json')
const config = options || {
space: process.env.CF_SPACE,
cda: process.env.CF_ACCESS_TOKEN,
cpa: process.env.CF_PREVIEW_ACCESS_TOKEN
spaceId: process.env.CF_SPACE_ID,
deliveryToken: process.env.CF_DELIVERY_TOKEN,
previewToken: process.env.CF_PREVIEW_TOKEN
}
cdaClient = createClient({
application: `contentful.the-example-app.node/${version}`,
space: config.space,
accessToken: config.cda
deliveryClient = createClient({
application: `the-example-app.node/${version}`,
space: config.spaceId,
accessToken: config.deliveryToken
})
cpaClient = createClient({
application: `contentful.the-example-app.node/${version}`,
space: config.space,
accessToken: config.cpa,
previewClient = createClient({
application: `the-example-app.node/${version}`,
space: config.spaceId,
accessToken: config.previewToken,
host: 'preview.contentful.com'
})
}
// Get the Space the app is connected to. Used for the settings form and to get all available locales.
exports.getSpace = assert((api = `cda`) => {
const client = api === 'cda' ? cdaClient : cpaClient
return client.getSpace()
/**
* Get the Space the app is connected to. Used for the settings form and to get all available locales
* @param api - string - the api to use, cda or cap. Default: 'cda'
* @returns {undefined}
*/
module.exports.getSpace = assert((api = `cda`) => {
return getClient(api).getSpace()
}, 'Space')
// Get a single entry. Used to detect the `Draft` or `Pending Changes` state.
exports.getEntry = assert((entryId, api = `cda`) => {
const client = api === 'cda' ? cdaClient : cpaClient
return client.getEntry(entryId)
/**
* Gets an entry. Used to detect the `Draft` or `Pending Changes` state
* @param entryId - string - the entry id
* @param api - string - the api to use fetching the entry
*
* @returns {Object}
*/
module.exports.getEntry = assert((entryId, api = `cda`) => {
return getClient(api).getEntry(entryId)
}, 'Entry')
// to get all the courses we request all the entries
// with the content_type `course` from Contentful
exports.getCourses = assert((locale = 'en-US', api = `cda`) => {
const client = api === 'cda' ? cdaClient : cpaClient
return client.getEntries({
/**
* Get all entries with content_type `course`
* @param locale - string - the locale of the entry [default: 'en-US']
* @param api - string the api enpoint to use when fetching the data
* @returns {Array<Object>}
*/
module.exports.getCourses = assert((locale = 'en-US', api = `cda`) => {
return getClient(api).getEntries({
content_type: 'course',
locale,
order: 'sys.createdAt',
include: 10
order: 'sys.createdAt', // Ordering the entries by creation date
include: 6 // We use include param to increase the link level, the include value goes from 1 to 6
})
.then((response) => response.items)
}, 'Course')
// Landing pages like the home or about page are fully controlable via Contentful.
exports.getLandingPage = (slug, locale = 'en-US', api = `cda`) => {
const client = api === 'cda' ? cdaClient : cpaClient
return client.getEntries({
/**
* Get entries of content_type `layout` e.g. Landing page
* @param slug - string - the slug of the entry to use in the query
* @param locale - string - locale of the entry to request [default: 'en-US']
* @param api - string - the api enpoint to use when fetching the data
* @returns {Object}
*/
module.exports.getLandingPage = (slug, locale = 'en-US', api = `cda`) => {
// Even though we need a single entry, we request it using the collection endpoint
// To get all the linked refs in one go, the SDK will use the data and resolve the links automatically
return getClient(api).getEntries({
content_type: 'layout',
locale,
'fields.slug': slug,
include: 10
include: 6
})
.then((response) => response.items[0])
}
// the SDK supports link resolution only when you call the collection endpoints
// That's why we are using getEntries with a query instead of getEntry(entryId)
// make sure to specify the content_type whenever you want to perform a query
exports.getCourse = assert((slug, locale = 'en-US', api = `cda`) => {
const client = api === 'cda' ? cdaClient : cpaClient
return client.getEntries({
/**
* Get an entry with content_type `course`
* @param slug - string - the slug of the entry to use in the query
* @param locale - string - locale of the entry to request [default: 'en-US']
* @param api - string - the api enpoint to use when fetching the data
* @returns {Object}
*/
module.exports.getCourse = assert((slug, locale = 'en-US', api = `cda`) => {
// Even though we need a single entry, we request it using the collection endpoint
// To get all the linked refs in one go, the SDK will use the data and resolve the links automatically
return getClient(api).getEntries({
content_type: 'course',
'fields.slug': slug,
locale,
include: 10
include: 6
})
.then((response) => response.items[0])
}, 'Course')
exports.getCategories = assert((locale = 'en-US', api = `cda`) => {
const client = api === 'cda' ? cdaClient : cpaClient
return client.getEntries({content_type: 'category', locale})
module.exports.getCategories = assert((locale = 'en-US', api = `cda`) => {
return getClient(api).getEntries({content_type: 'category', locale})
.then((response) => response.items)
}, 'Course')
// Getting a course by Category is simply querying all entries
// with a query params `fields.categories.sys.id` equal to the desired category id
// Note that you need to send the `content_type` param to be able to query the entry
exports.getCoursesByCategory = assert((category, locale = 'en-US', api = `cda`) => {
const client = api === 'cda' ? cdaClient : cpaClient
return client.getEntries({
/**
* Get Courses by Categories
* To get a course by category, simply query all entries
* with a query params `fields.categories.sys.id` equal to the desired category id
* Note that you need to send the `content_type` param to be able to query the entry
* @param category - string - the id of the category
* @param locale - string - locale of the entry to request [default: 'en-US']
* @param api - string - the api enpoint to use when fetching the data
* @returns {Object}
*/
module.exports.getCoursesByCategory = assert((category, locale = 'en-US', api = `cda`) => {
return getClient(api).getEntries({
content_type: 'course',
'fields.categories.sys.id': category,
locale,
order: '-sys.createdAt',
include: 10
include: 6
})
.then((response) => response.items)
}, 'Category')
// Utitlities functions
// Utility function
function getClient (api = 'cda') {
return api === 'cda' ? deliveryClient : previewClient
}
function assert (fn, context) {
return function (req, res, next) {
return fn(req, res, next)
return function (request, response, next) {
return fn(request, response, next)
.then((data) => {
if (!data) {
var err = new Error(`${context} Not Found`)

6
test/.eslintrc.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
'env': {
'node': true,
'jest': true
}
}

View File

@@ -1,7 +1,7 @@
/* global describe, test, expect */
const app = require('../../app')
const request = require('supertest')
const app = require('../../app')
describe('courses', () => {
test('it should render a list of courses', () => {
return request(app).get('/courses')

View File

@@ -1,7 +1,7 @@
/* global describe, test */
const app = require('../../app')
const request = require('supertest')
const app = require('../../app')
describe('Home page', () => {
test('it should render the landing page', () => {
return request(app).get('/').expect(200)

View File

@@ -1,12 +1,13 @@
const app = require('../../app')
const request = require('supertest')
const cheerio = require('cheerio')
const cookie = require('cookie')
const cookieParser = require('cookie-parser')
const request = require('supertest')
function getSettingsCookie (res) {
const app = require('../../app')
function getSettingsCookie (response) {
try {
const cookies = res.headers['set-cookie']
const cookies = response.headers['set-cookie']
const settingsCookie = cookies.find((cookie) => cookie.startsWith('theExampleAppSettings='))
const parsedCookie = cookie.parse(settingsCookie)
return cookieParser.JSONCookie(parsedCookie.theExampleAppSettings)
@@ -38,15 +39,15 @@ describe('settings', () => {
expect(inputCpa.val()).toBe(process.env.CF_PREVIEW_ACCESS_TOKEN)
const inputEditorialFeatures = $('#input-editorial-features')
expect(inputEditorialFeatures.prop('checked')).toBeFalsy()
expect(inputEditorialFeaturesponse.prop('checked')).toBeFalsy()
})
})
test('should have the editorial features enabled when query parameter is set and set cookie for it', () => {
return request(app).get('/settings?enable_editorial_features')
.expect(200)
.expect((res) => {
const cookie = getSettingsCookie(res)
.expect((response) => {
const cookie = getSettingsCookie(response)
if (!cookie.editorialFeatures) {
throw new Error('Did not set cookie value for editorial features')
}
@@ -67,7 +68,7 @@ describe('settings', () => {
const $ = cheerio.load(response.text)
const inputEditorialFeatures = $('#input-editorial-features')
expect(inputEditorialFeatures.prop('checked')).toBeTruthy()
expect(inputEditorialFeaturesponse.prop('checked')).toBeTruthy()
})
})
})

View File

@@ -6,8 +6,8 @@ const { mockCourse, mockCategory } = require('./mocks/index')
jest.mock('../../services/contentful')
const contentful = require('../../services/contentful')
const req = {}
const res = {
const request = {}
const response = {
locals: {
currentLocale: {
code: 'en-US'
@@ -25,58 +25,58 @@ beforeAll(() => {
contentful.getCategories.mockImplementation(() => [mockCategory])
contentful.getCoursesByCategory.mockImplementation(() => [])
res.render = jest.fn()
res.cookie = jest.fn()
req.cookies = { visitedLessons: [] }
response.render = jest.fn()
response.cookie = jest.fn()
request.cookies = { visitedLessons: [] }
})
afterEach(() => {
res.render.mockClear()
res.render.mockReset()
response.render.mockClear()
response.render.mockReset()
})
describe('Courses', () => {
test('it should courses list once', async () => {
await getCourses(req, res)
expect(res.render.mock.calls[0][0]).toBe('courses')
expect(res.render.mock.calls[0][1].title).toBe('All Courses (1)')
expect(res.render.mock.calls[0][1].courses.length).toBe(1)
expect(res.render.mock.calls.length).toBe(1)
await getCourses(request, response)
expect(response.render.mock.calls[0][0]).toBe('courses')
expect(response.render.mock.calls[0][1].title).toBe('All Courses (1)')
expect(response.render.mock.calls[0][1].courses.length).toBe(1)
expect(response.render.mock.calls.length).toBe(1)
})
test('it should render single course once', async () => {
req.params = {slug: 'slug', lslug: 'lessonSlug'}
await getCourse(req, res)
expect(res.render.mock.calls[0][0]).toBe('course')
expect(res.render.mock.calls[0][1].title).toBe(mockCourse.fields.title)
expect(res.render.mock.calls[0][1].course.sys.id).toBe(mockCourse.sys.id)
expect(res.render.mock.calls[0][1].lesson.sys.id).toBe(mockCourse.fields.lessons[0].sys.id)
expect(res.render.mock.calls.length).toBe(1)
request.params = {slug: 'slug', lslug: 'lessonSlug'}
await getCourse(request, response)
expect(response.render.mock.calls[0][0]).toBe('course')
expect(response.render.mock.calls[0][1].title).toBe(mockCourse.fields.title)
expect(response.render.mock.calls[0][1].course.sys.id).toBe(mockCourse.sys.id)
expect(response.render.mock.calls[0][1].lesson.sys.id).toBe(mockCourse.fields.lessons[0].sys.id)
expect(response.render.mock.calls.length).toBe(1)
})
test('it should render list of courses by categories', async () => {
req.params = {slug: 'slug', lslug: 'lslug', category: 'categorySlug'}
await getCoursesByCategory(req, res)
expect(res.render.mock.calls[0][0]).toBe('courses')
expect(res.render.mock.calls[0][1].title).toBe(`${mockCategory.fields.title} (0)`)
expect(res.render.mock.calls.length).toBe(1)
request.params = {slug: 'slug', lslug: 'lslug', category: 'categorySlug'}
await getCoursesByCategory(request, response)
expect(response.render.mock.calls[0][0]).toBe('courses')
expect(response.render.mock.calls[0][1].title).toBe(`${mockCategory.fields.title} (0)`)
expect(response.render.mock.calls.length).toBe(1)
})
})
describe('Lessons', () => {
test('it should render a lesson', async () => {
req.params = { cslug: 'courseSlug', lslug: 'lessonSlug' }
await getLesson(req, res)
expect(res.render.mock.calls[0][0]).toBe('course')
expect(res.render.mock.calls[0][1].title).toBe('Course title | Lesson title')
expect(res.render.mock.calls[0][1].course.sys.id).toBe(mockCourse.sys.id)
expect(res.render.mock.calls[0][1].lesson.sys.id).toBe(mockCourse.fields.lessons[0].sys.id)
expect(res.render.mock.calls.length).toBe(1)
request.params = { cslug: 'courseSlug', lslug: 'lessonSlug' }
await getLesson(request, response)
expect(response.render.mock.calls[0][0]).toBe('course')
expect(response.render.mock.calls[0][1].title).toBe('Course title | Lesson title')
expect(response.render.mock.calls[0][1].course.sys.id).toBe(mockCourse.sys.id)
expect(response.render.mock.calls[0][1].lesson.sys.id).toBe(mockCourse.fields.lessons[0].sys.id)
expect(response.render.mock.calls.length).toBe(1)
})
})
describe('Settings', () => {
test('It should render settings', async () => {
res.locals = {
response.locals = {
settings: {
space: 'spaceId',
cda: 'cda',
@@ -84,9 +84,9 @@ describe('Settings', () => {
editorialFeatures: false
}
}
await getSettings(req, res)
expect(res.render.mock.calls[0][0]).toBe('settings')
expect(res.render.mock.calls[0][1].title).toBe('Settings')
expect(res.render.mock.calls[0][1].settings).toBe(res.locals.settings)
await getSettings(request, response)
expect(response.render.mock.calls[0][0]).toBe('settings')
expect(response.render.mock.calls[0][1].title).toBe('Settings')
expect(response.render.mock.calls[0][1].settings).toBe(response.locals.settings)
})
})

View File

@@ -1,5 +1,5 @@
NODE_ENV=development
CF_SPACE=8im83r7wdkz2
CF_ACCESS_TOKEN=febda697c8d0031e6da7df9c56c97c1b19a8a8f5c400c0deacfdf7c25397d5ae
CF_PREVIEW_ACCESS_TOKEN=7480498b164ce9d580c811ce3ccd1c73518f967ba7afd83384654aff866bd5d2
CONTENTFUL_SPACE_ID=ft4tkuv7nwl0
CONTENTFUL_DELIVERY_TOKEN=57459fe48bd2b1bef4855294455af52562dbc0c7f0eb84f8b2cd68692c186417
CONTENTFUL_PREVIEW_TOKEN=a9972e3cd83528def2fc9d3428c67cd622eb26d0a24239718c6ac61fe0288f2f
PORT=3000

View File

@@ -39,26 +39,26 @@ block content
form(action=`/settings` method="POST" class="form")
.form-item
label(for="input-space") Space ID
input(type="text" name="space" id="input-space" value=settings.space)
if 'space' in errors
+renderErrors(errors.space)
label(for="input-space-id") Space ID
input(type="text" name="spaceId" id="input-space-id" value=settings.spaceId)
if 'spaceId' in errors
+renderErrors(errors.spaceId)
.form-item__help-text The Space ID is a unique identifier for your space.
.form-item
label(for="input-cda") Content Delivery API - access token
input(type="text" name="cda" id="input-cda" value=settings.cda)
if 'cda' in errors
+renderErrors(errors.cda)
label(for="input-delivery-token") Content Delivery API - access token
input(type="text" name="deliveryToken" id="input-delivery-token" value=settings.deliveryToken)
if 'deliveryToken' in errors
+renderErrors(errors.deliveryToken)
.form-item__help-text
| View published content using this API.&nbsp;
a(href='https://www.contentful.com/developers/docs/references/content-delivery-api/' target='_blank' rel='noopener') Content Delivery API.
.form-item
label(for="input-cpa") Content Preview API - access token
input(type="text" name="cpa" id="input-cpa" value=settings.cpa)
if 'cpa' in errors
+renderErrors(errors.cpa)
label(for="input-preview-token") Content Preview API - access token
input(type="text" name="previewToken" id="input-preview-token" value=settings.previewToken)
if 'previewToken' in errors
+renderErrors(errors.previewToken)
.form-item__help-text
| Preview unpublished content using this API (i.e. content with “Draft” status).&nbsp;
a(href='https://www.contentful.com/developers/docs/references/content-preview-api/' target='_blank' rel='noopener') Content Preview API.

View File

@@ -1,6 +0,0 @@
extends layout
block content
.layout-centered
h1= title
p Welcome to #{title}