diff --git a/.eslintrc.js b/.eslintrc.js index e3fae43..5a306a0 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -4,5 +4,15 @@ module.exports = { 'plugins': [ 'standard', 'promise' - ] -} \ No newline at end of file + ], + 'env': { + 'node': true + }, + 'rules': { + "capitalized-comments": [ + "error", + "always" + ], + "spaced-comment": ["error", "always"] + } +} diff --git a/README.md b/README.md index 32b5719..85c5c9b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app.js b/app.js index f483c82..782a3e1 100644 --- a/app.js +++ b/app.js @@ -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 diff --git a/bin/www b/bin/www index 616abe3..aebb43d 100755 --- a/bin/www +++ b/bin/www @@ -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' diff --git a/handlers/errorHandlers.js b/handlers/errorHandlers.js index 9fc3ce0..2b27c9b 100644 --- a/handlers/errorHandlers.js +++ b/handlers/errorHandlers.js @@ -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) }) } diff --git a/helpers.js b/helpers.js index 0d3abe6..7e4427d 100644 --- a/helpers.js +++ b/helpers.js @@ -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, '#') +} diff --git a/lib/breadcrumb.js b/lib/breadcrumb.js index e8fa781..54536d4 100644 --- a/lib/breadcrumb.js +++ b/lib/breadcrumb.js @@ -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() } } diff --git a/lib/cookies.js b/lib/cookies.js new file mode 100644 index 0000000..34bfa0b --- /dev/null +++ b/lib/cookies.js @@ -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 }) +} diff --git a/package.json b/package.json index c410e6a..17627b4 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/routes/categories.js b/routes/categories.js index c1fb132..f56fe00 100644 --- a/routes/categories.js +++ b/routes/categories.js @@ -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' }) } diff --git a/routes/courses.js b/routes/courses.js index 27e8dd5..5123d3a 100644 --- a/routes/courses.js +++ b/routes/courses.js @@ -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 + } +} diff --git a/routes/imprint.js b/routes/imprint.js index b7eef5b..74b3d16 100644 --- a/routes/imprint.js +++ b/routes/imprint.js @@ -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' }) } diff --git a/routes/index.js b/routes/index.js index ff8fba9..dc8f9e8 100644 --- a/routes/index.js +++ b/routes/index.js @@ -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 diff --git a/routes/landingPage.js b/routes/landingPage.js index 9940acb..fc23f10 100644 --- a/routes/landingPage.js +++ b/routes/landingPage.js @@ -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 }) } diff --git a/routes/settings.js b/routes/settings.js index c69f327..164c269 100644 --- a/routes/settings.js +++ b/routes/settings.js @@ -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, diff --git a/routes/sitemap.js b/routes/sitemap.js deleted file mode 100644 index f0ddf62..0000000 --- a/routes/sitemap.js +++ /dev/null @@ -1,5 +0,0 @@ -/* GET sitemap page. */ -exports.getSitemap = async (req, res, next) => { - res.render('sitemap', { title: 'Sitemap' }) -} - diff --git a/services/contentful.js b/services/contentful.js index 23573f3..fd5bbf8 100644 --- a/services/contentful.js +++ b/services/contentful.js @@ -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} + */ +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`) diff --git a/test/.eslintrc.js b/test/.eslintrc.js new file mode 100644 index 0000000..6a91e84 --- /dev/null +++ b/test/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + 'env': { + 'node': true, + 'jest': true + } +} diff --git a/test/integration/courses.test.js b/test/integration/courses.test.js index db64590..afe2e81 100644 --- a/test/integration/courses.test.js +++ b/test/integration/courses.test.js @@ -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') diff --git a/test/integration/index.test.js b/test/integration/index.test.js index 77105c6..e5504a8 100644 --- a/test/integration/index.test.js +++ b/test/integration/index.test.js @@ -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) diff --git a/test/integration/settings.test.js b/test/integration/settings.test.js index ef69817..da644f8 100644 --- a/test/integration/settings.test.js +++ b/test/integration/settings.test.js @@ -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() }) }) }) diff --git a/test/unit/index.test.js b/test/unit/index.test.js index 98a05c0..54e8b1c 100644 --- a/test/unit/index.test.js +++ b/test/unit/index.test.js @@ -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) }) }) diff --git a/variables.env b/variables.env index 6424492..39ceb8c 100644 --- a/variables.env +++ b/variables.env @@ -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 diff --git a/views/settings.pug b/views/settings.pug index 8e1a4cd..d70eafd 100644 --- a/views/settings.pug +++ b/views/settings.pug @@ -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.  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).  a(href='https://www.contentful.com/developers/docs/references/content-preview-api/' target='_blank' rel='noopener') Content Preview API. diff --git a/views/sitemap.pug b/views/sitemap.pug deleted file mode 100644 index fe047a3..0000000 --- a/views/sitemap.pug +++ /dev/null @@ -1,6 +0,0 @@ -extends layout - -block content - .layout-centered - h1= title - p Welcome to #{title}