Skip to content

Commit

Permalink
ft(create-article): create user article
Browse files Browse the repository at this point in the history
- Write unit tests
- Create model for article and association with user model
- Create middleware to validate data to create article
- Create helper function to create article
- Create helper function to generate unique slugs
- Add image upload feature using cloudinary
- Create route and controller to create article
- Create route and controller get an article using slug parameter
- Create route and controller to get all articles

[Delivers #159206054]
  • Loading branch information
Olumide Ogundele committed Aug 3, 2018
1 parent a42b857 commit 4e28259
Show file tree
Hide file tree
Showing 18 changed files with 993 additions and 33 deletions.
16 changes: 14 additions & 2 deletions config/index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
module.exports = {
secret: process.env.SECRET
import dotenv from 'dotenv';

dotenv.config();

const config = {
secret: process.env.SECRET,
jwtSecret: process.env.JWT_TOKEN_SECRET,
cloudinary: {
cloud_name: process.env.CLOUD_NAME,
api_key: process.env.API_KEY,
api_secret: process.env.API_SECRET,
}
};

export default config;
105 changes: 105 additions & 0 deletions controllers/ArticleController.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import cloudinary from 'cloudinary';

import { Article, User } from '../models';
import createArticleHelper from '../helpers/createArticleHelper';

/** Class representing controller for articles. */
class ArticleController {
/**
* Create an article for a user
* @param {object} req - The request object
* @param {object} res - The response object sent to user
* @return {object} A object containing created articles.
*/
static createArticle(req, res) {
const {
title, description, body, tagList, imageUrl
} = req.body.article;

const { userId } = req;

const articleObject = {
title, description, body, tagList, imageUrl, userId
};
/** check if image was provided in the request
* upload the image to cloudinary, save the article
* with the cloudinary URL in database but if an error
* was encountered from cloudinary go ahead and create the article
*/
if (imageUrl) {
return cloudinary.v2.uploader.upload(imageUrl, { tags: 'basic_sample' })
.then(image => createArticleHelper(res, articleObject, image.url))
.catch(() => createArticleHelper(res, articleObject));
}

/** if there no image was provided go ahead to create the article */
return createArticleHelper(res, articleObject);
}

/**
* get an article using slug as query parameter
* @param {object} req - request object
* @param {object} res - response object
* @returns {object} - the found article from database or error if not found
*/
static getArticle(req, res) {
const { slug } = req.params;

return Article
.findOne({
where: { slug, },
include: [{
model: User,
attributes: { exclude: ['id', 'email', 'hashedPassword', 'createdAt', 'updatedAt'] }
}],
attributes: { exclude: ['id', 'userId'] }
})
.then((article) => {
/** if the article does not exist */
if (!article) {
return res.status(404).json({
errors: {
body: [
'Ooops! the article cannot be found.'
]
}
});
}

return res.status(200).json({ article });
})
.catch(() => res.status(501).send('oops seems there is an error find the article'));
}

/**
* get all articles created
* @param {object} req - request object
* @param {object} res - response object
* @returns {object} - the found article from database or empty if not found
*/
static listAllArticles(req, res) {
return Article
.findAll({
include: [{
model: User,
attributes: { exclude: ['id', 'email', 'hashedPassword', 'createdAt', 'updatedAt'] }
}],
attributes: { exclude: ['id', 'userId'] }
})
.then((articles) => {
/** check if there was no article created */
if (articles.length === 0) {
return res.status(200).json({
message: 'Your request was successful but no articles created',
articles,
});
}

return res.status(200).json({ articles, articlesCount: articles.length });
})
.catch(() => res.status(501).send('oops seems there is an error find all articles'));
}
}


export default ArticleController;
46 changes: 46 additions & 0 deletions helpers/createArticleHelper.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import generateUniqueSlug from './generateUniqueSlug';
import { Article, User } from '../models';


/**
* @description an helper function to help create article in database
* @param {object} res - response object
* @param {object} articleObject - contains extracted article fields
* @param {string} imageUrl - image url from cloudinary
* @returns object - the created article from the database
*/

const createArticleHelper = (res, articleObject, imageUrl = null) => {
const {
title, description, body, tagList, userId
} = articleObject;

return Article
.create({
slug: generateUniqueSlug(title),
title,
description,
body,
userId,
tagList,
imageUrl,
})
.then(article => Article.findById(article.id, {
include: [{
model: User,
attributes: { exclude: ['id', 'email', 'hashedPassword', 'createdAt', 'updatedAt'] }
}],
attributes: { exclude: ['id', 'userId'] }
}))
.then(article => res.status(201).json({ article }))
.catch(err => res.status(400).send({
errors: {
body: [
'Sorry, there was an error creating your article',
err
]
}
}));
};

export default createArticleHelper;
15 changes: 15 additions & 0 deletions helpers/generateUniqueSlug.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import slugify from 'slugify';
import cuid from 'cuid';

/** create a string as slug to make articles unique
* @param {string} title - title of the article from request body
* @returns {string} slug - timestamped string with randomly generated slug to avoid collision
*/
const generateUniqueSlug = (title) => {
const sluggedTitle = slugify(title);
const slug = `${sluggedTitle}-${cuid()}`;
return slug.toLowerCase();
};


export default generateUniqueSlug;
11 changes: 9 additions & 2 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import errorhandler from 'errorhandler';
import methodOverride from 'method-override';
import morgan from 'morgan';
import debugLog from 'debug';

import { } from 'dotenv/config';
import cloudinary from 'cloudinary';
import config from './config';

import routes from './routes';

Expand All @@ -19,6 +19,13 @@ const debug = debugLog('index');
// Create global app object
const app = express();

/** configure cloudinary to be able to upload image */
cloudinary.config({
cloud_name: config.cloudinary.cloud_name,
api_key: config.cloudinary.api_key,
api_secret: config.cloudinary.api_secret,
});

app.use(cors());

// Normal express config defaults;
Expand Down
27 changes: 27 additions & 0 deletions middlewares/getUser.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import db from '../models';

const { User } = db;
const getUser = (req, res, next) => {
User.findById(req.userId)
.then((user) => {
if (!user || user.rowCount === 0) {
return res.status(404).json({
success: false,
errors: {
body: ['The user does not exist']
},
});
}
req.userObject = user;
next();
return null;
})
.catch(err => res.status(500).json({
success: false,
errors: {
body: [err]
},
}));
};

export default getUser;
29 changes: 29 additions & 0 deletions middlewares/validateArticle.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* @description this function checks that fields are not empty
* @param {object} req
* @param {object} res
* @param {function} next
* @returns {object} a status code and json object if theres error
* or continue to the next middleware using next()
*/
const validateArticle = (req, res, next) => {
let { title, description, body } = req.body.article;

if (!title || !description || !body) {
return res.status(400).json({
errors: {
body: [
'Please check that your title, description or body field is not empty'
]
}
});
}

title = title.trim();
description = description.trim();
body = body.trim();

next();
};

export default validateArticle;
20 changes: 20 additions & 0 deletions middlewares/verifyToken.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import jwt from 'jsonwebtoken';
import config from '../config';

const verifyToken = (req, res, next) => {
const token = req.headers.authorization.split(' ')[1];
if (!token || !req.headers.authorization) {
return res.status(401).send('No token has been provided in the request');
}
jwt.verify(token, config.jwtSecret, (err, decoded) => {
if (err) {
return res.status(401).json({ message: 'Could not authenticate the provided token' });
}
req.userId = decoded.id;
next();
return null;
});
return null;
};

export default verifyToken;
56 changes: 56 additions & 0 deletions migrations/20180730142739-create-article.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
module.exports = {
up: (queryInterface, Sequelize) => {
return queryInterface.createTable('Articles', {
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: Sequelize.INTEGER
},
slug: {
type: Sequelize.STRING
},
title: {
type: Sequelize.STRING
},
description: {
type: Sequelize.STRING
},
body: {
type: Sequelize.STRING
},
tagList: {
type: Sequelize.ARRAY(Sequelize.TEXT)
},
favorited: {
type: Sequelize.BOOLEAN
},
favoritesCount: {
type: Sequelize.INTEGER
},
imageUrl: {
type: Sequelize.STRING
},
createdAt: {
allowNull: false,
type: Sequelize.DATE
},
updatedAt: {
allowNull: false,
type: Sequelize.DATE
},
userId: {
type: Sequelize.INTEGER,
onDelete: 'CASCADE',
references: {
model: 'Users',
key: 'id',
as: 'userId',
},
},
});
},
down: (queryInterface, Sequelize) => {
return queryInterface.dropTable('Articles');
}
};
35 changes: 35 additions & 0 deletions models/Article.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
module.exports = (sequelize, DataTypes) => {
const Article = sequelize.define('Article', {
slug: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
},
title: {
type: DataTypes.STRING,
allowNull: false,
},
description: {
type: DataTypes.STRING,
allowNull: false,
},
body: {
type: DataTypes.STRING,
allowNull: false,
},
tagList: DataTypes.ARRAY(DataTypes.STRING),
favorited: DataTypes.BOOLEAN,
favoritesCount: DataTypes.INTEGER,
imageUrl: DataTypes.STRING
}, {});
Article.associate = (models) => {
// associations can be defined here
Article.belongsTo(models.User,
{
foreignKey: 'userId',
onDelete: 'CASCADE',
});
};

return Article;
};
7 changes: 7 additions & 0 deletions models/User.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,12 @@ module.exports = (sequelize, DataTypes) => {
type: DataTypes.STRING,
},
}, {});

User.associate = (models) => {
User.hasMany(models.Article, {
foreignKey: 'userId',
as: 'articles',
})
};
return User;
};
Loading

0 comments on commit 4e28259

Please sign in to comment.