First commit

This commit is contained in:
Franco Colmenarez 2021-09-15 13:47:11 -05:00
commit d1089b77e4
20 changed files with 8193 additions and 0 deletions

9
.env.example Normal file
View File

@ -0,0 +1,9 @@
// CONFIG
PORT=3000
CORS=*
// MONGO
DB_USER=
DB_PASSWORD=
DB_HOST=
DB_NAME=

14
.eslintrc.json Normal file
View File

@ -0,0 +1,14 @@
{
"parserOptions": {
"ecmaVersion": 2018
},
"extends": ["eslint:recommended", "prettier"],
"env": {
"es6": true,
"node": true,
"mocha": true
},
"rules": {
"no-console": "warn"
}
}

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
/node_modules/
.env

5
.prettierrc.json Normal file
View File

@ -0,0 +1,5 @@
{
"tabWidth": 2,
"semi": true,
"singleQuote": true
}

13
config/index.js Normal file
View File

@ -0,0 +1,13 @@
require('dotenv').config();
const config = {
dev: process.env.NODE_ENV !== 'production',
port: process.env.PORT || 3030,
CORS: process.env.CORS,
dbUser: process.env.DB_USER,
dbPassword: process.env.DB_PASSWORD,
dbHost: process.env.DB_HOST,
dbName: process.env.DB_NAME,
}
module.exports = { config }

22
index.js Normal file
View File

@ -0,0 +1,22 @@
const app = require('express')();
const bodyParser = require('body-parser');
const multer = require('multer');
const upload = multer();
const { config } = require('./config/index');
const { logErrors, wrapErrors, errorHandler } = require('./utils/middleware/errorHandlers');
const notFoundHandler = require('./utils/middleware/notFoundHandler');
const moviesApi = require('./routes/movies.js');
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
moviesApi(app);
app.use(notFoundHandler);
app.use(logErrors);
app.use(wrapErrors);
app.use(errorHandler);
app.listen(config.port, () => {
console.log(`listening http://localhost:${config.port}`);
});

71
lib/mongo.js Normal file
View File

@ -0,0 +1,71 @@
const { MongoClient, ObjectId } = require('mongodb');
const { config } = require('../config');
const USER = encodeURIComponent(config.dbUser);
const PASSWORD = encodeURIComponent(config.dbPassword);
const DB_NAME = config.dbName;
const HOST = config.dbHost;
const PORT = config.dbPort;
// const MONGO_URI = `mongodb+srv://${USER}:${PASSWORD}@${HOST}/${DB_NAME}?retryWrites=true&w=majority`
const MONGO_URI = `mongodb://127.0.0.1:27017/${DB_NAME}`
class MongoLib {
constructor() {
this.client = new MongoClient(MONGO_URI, { useNewUrlParser: true });
this.dbName = DB_NAME;
}
connect() {
if (MongoLib.connection) return MongoLib.connection;
MongoLib.connection = new Promise((resolve, reject) => {
this.client.connect(err => {
if (err) {
reject(err);
}
console.log('MongoDB connected');
resolve(this.client.db(this.dbName))
});
});
return MongoLib.connection;
}
getAll(collection, query) {
return this.connect().then(db => {
return db.collection(collection).find(query).toArray();
});
}
get(collection, id) {
return this.connect().then(db => {
return db.collection(collection).findOne({_id: ObjectId(id)});
});
}
create(collection, data) {
return this.connect()
.then(db => {
return db.collection(collection).insertOne(data);
})
.then(result => result.insertedId);
}
update(collection, id, data) {
return this.connect()
.then(db => {
return db.collection(collection).updateOne({_id: ObjectId(id) }, { $set: data });
})
.then(result => result.upsertedId || id);
}
delete(collection, id) {
return this.connect()
.then(db => {
return db.collection(collection).deleteOne({_id: ObjectId(id)});
})
.then(() => id);
}
}
module.exports = MongoLib;

7543
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
package.json Normal file
View File

@ -0,0 +1,34 @@
{
"name": "movies-api",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "mocha --exit",
"dev": "DEBUG=app:* nodemon index",
"start": "NODE_ENV=production node index"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"eslint": "^7.32.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0",
"mocha": "^9.1.1",
"multer": "^1.4.3",
"nodemon": "^2.0.12",
"prettier": "^2.4.0",
"proxyquire": "^2.1.3",
"sinon": "^11.1.2",
"supertest": "^6.1.6"
},
"dependencies": {
"@hapi/boom": "^9.1.4",
"@hapi/joi": "^17.1.1",
"body-parser": "^1.19.0",
"dotenv": "^10.0.0",
"express": "^4.17.1",
"mongodb": "^4.1.2"
}
}

87
routes/movies.js Normal file
View File

@ -0,0 +1,87 @@
const express = require('express');
const { moviesMock } = require('../utils/mocks/movies');
const MoviesService = require('../services/movies');
const {
movieIdSchema,
createMovieSchema,
updateMovieSchema,
} = require('../utils/schemas/movies');
const validationHandler = require('../utils/middleware/validationHandler');
function moviesApi(app) {
const router = express.Router();
app.use('/api/movies', router);
const moviesService = new MoviesService();
router.get('/', async (req, res, next) => {
const { tags } = req.query;
try {
const movies = await moviesService.list({ tags });
res.status(200).json({
data: movies,
message: 'movies listed',
});
} catch (err) {
next(err);
}
});
router.get('/:id', validationHandler({id:movieIdSchema}, 'params'), async (req, res, next) => {
const { id } = req.params;
try {
const movie = await moviesService.get(id);
res.status(200).json({
data: movie,
message: 'Movie retrieved',
});
} catch (err) {
next(err);
}
});
router.post('/', validationHandler(createMovieSchema, 'body'), async (req, res, next) => {
const { body: movie } = req;
try {
const createdMovie = await moviesService.create(movie);
res.status(201).json({
data: createdMovie,
message: 'Movie created',
});
} catch (err) {
next(err);
}
});
router.put(
'/:id', validationHandler({id:movieIdSchema}, 'params'), validationHandler(updateMovieSchema, 'body'),
async (req, res, next) => {
const { body: movie } = req;
const { id } = req.params;
try {
const updatedMovie = await moviesService.update(id, movie);
res.status(200).json({
data: updatedMovie,
message: 'Movie updated',
});
} catch (err) {
next(err);
}
});
router.delete('/:id', validationHandler({id:movieIdSchema}, 'params'), async (req, res, next) => {
const { id } = req.params;
try {
const movie = await moviesService.delete(id);
res.status(200).json({
data: id,
message: 'Movie deleted',
});
} catch (err) {
next(err);
}
});
}
module.exports = moviesApi;

33
services/movies.js Normal file
View File

@ -0,0 +1,33 @@
const MongoLib = require('../lib/mongo');
class MoviesService {
constructor() {
this.collection = 'movies';
this.mongoDB = new MongoLib();
}
async list({ tags }) {
const query = tags && { tags: { $in: tags }};
const movies = await this.mongoDB.getAll(this.collection, query);
return movies || [];
}
async get(id) {
const movie = await this.mongoDB.get(this.collection, id);
return movie || {};
}
async create(movie) {
return await this.mongoDB.create(this.collection, movie);
}
async update(id, movie = {}) {
return await this.mongoDB.update(this.collection, id, movie);
}
async delete(id) {
return await this.mongoDB.delete(this.collection, id);
}
}
module.exports = MoviesService;

28
test/route.movies.test.js Normal file
View File

@ -0,0 +1,28 @@
const assert = require('assert');
const proxyquire = require('proxyquire');
const {moviesMock, MoviesServiceMock } = require('../utils/mocks/movies.js')
const testServer = require('../utils/testServer');
describe('routes - movies', function() {
const route = proxyquire('../routes/movies', {
'../services/movies': MoviesServiceMock,
});
const request = testServer(route);
describe('GET /movies', function() {
it('should respond with status 200', function(done) {
request.get('/api/movies').expect(200, done);
});
it('should respond with the list of movies', function(done) {
request.get('/api/movies').end((err, res) => {
assert.deepEqual(res.body, {
data: moviesMock,
message: 'movies listed'
});
done();
});
});
})
});

View File

@ -0,0 +1,27 @@
const assert = require('assert');
const proxyquire = require('proxyquire');
const { MongoLibMock, getAllStub } = require('../utils/mocks/mongoLib');
const { moviesMock } = require('../utils/mocks/movies');
describe('services - movies', function() {
const MoviesServices = proxyquire('../services/movies', {
'../lib/mongo': MongoLibMock
});
const moviesService = new MoviesServices();
describe('when getMovies method is called', async function() {
it('should call the getall MongoLib method', async function() {
await moviesService.list({});
assert.strictEqual(getAllStub.called, true);
});
it('should return an array of movies', async function() {
const result = await moviesService.list({});
const expected = moviesMock;
assert.deepEqual(result, expected);
});
});
});

View File

@ -0,0 +1,38 @@
const boom = require('@hapi/boom');
const { config } = require('../../config');
function withErrorStack(error, stack) {
if (config.dev) {
return { ...error, stack }
}
return error;
}
function wrapErrors(err, req, res, next) {
if (!err.isBoom) {
next(boom.badImplementation(err));
}
next(err);
}
function logErrors(err, req, res, next) {
console.log(err);
next(err);
}
function errorHandler(err, req, res, next) {
const {
output: {
statusCode, payload
}
} = err;
res.status(statusCode || 500);
res.json(withErrorStack(payload, err.stack));
}
module.exports = {
logErrors,
wrapErrors,
errorHandler
}

View File

@ -0,0 +1,13 @@
const boom = require('@hapi/boom');
function notFoundHandler(req, res) {
const {
output: {
statusCode,
payload,
}
} = boom.notFound();
res.status(statusCode).json(payload);
}
module.exports = notFoundHandler;

View File

@ -0,0 +1,16 @@
const boom = require('@hapi/boom');
const joi = require('@hapi/joi');
function validate(data, schema) {
const { error } = joi.object(schema).validate(data);
return error;
}
function validationHandler(schema, check = "") {
return function(req, res, next) {
const error = validate(req[check], schema);
error ? next(boom.badRequest(error)) : next();
}
}
module.exports = validationHandler;

27
utils/mocks/mongoLib.js Normal file
View File

@ -0,0 +1,27 @@
const sinon = require('sinon');
const { moviesMock, filteredMoviesMock } = require('./movies');
const getAllStub = sinon.stub();
getAllStub.withArgs('movies').resolves(moviesMock);
const tagQuery = { tags: { $in: ['Drama'] } };
getAllStub.withArgs('movies', tagQuery).resolves(filteredMoviesMock('Drama'));
const createStub = sinon.stub().resolves(moviesMock[0].id);
class MongoLibMock {
list(collection, query) {
return getAllStub(collection, query);
}
create(collection, data) {
return createStub(collection, data);
}
}
module.exports = {
getAllStub,
createStub,
MongoLibMock
};

161
utils/mocks/movies.js Normal file
View File

@ -0,0 +1,161 @@
const moviesMock = [
{
"id": "94156f00-db7d-42e3-b5eb-f26e256fcae1",
"title": "CQ",
"year": 2001,
"cover": "http://dummyimage.com/130x249.png/ff4444/ffffff",
"description": "Duis consequat dui nec nisi volutpat eleifend. Donec ut dolor. Morbi vel lectus in quam fringilla rhoncus.",
"duration": 1922,
"contentRating": "PG-13",
"source": "https://cbsnews.com/eleifend/pede.png",
"tags": [
"Drama|Romance"
]
},
{
"id": "ce16b2c2-1dd0-4e69-8176-09040f11cc2d",
"title": "Spanish Earth, The",
"year": 2012,
"cover": "http://dummyimage.com/143x154.jpg/dddddd/000000",
"description": "Etiam vel augue. Vestibulum rutrum rutrum neque. Aenean auctor gravida sem. Praesent id massa id nisl venenatis lacinia. Aenean sit amet justo. Morbi ut odio.",
"duration": 1987,
"contentRating": "PG-13",
"source": "http://census.gov/volutpat.jsp",
"tags": [
"Horror",
"Documentary",
"Drama|Romance"
]
},
{
"id": "0c008a69-b8bb-4c96-97b3-2031ad0fc758",
"title": "Mean Streets",
"year": 1996,
"cover": "http://dummyimage.com/176x226.bmp/dddddd/000000",
"description": "Aenean lectus. Pellentesque eget nunc. Donec quis orci eget orci vehicula condimentum.",
"duration": 1947,
"contentRating": "PG",
"source": "http://auda.org.au/sapien/sapien/non/mi.png",
"tags": [
"Comedy|Drama"
]
},
{
"id": "38436953-f828-4000-a1e3-5ed36c633ff2",
"title": "Drop Dead Gorgeous",
"year": 1968,
"cover": "http://dummyimage.com/170x206.jpg/cc0000/ffffff",
"description": "Suspendisse potenti. In eleifend quam a odio. In hac habitasse platea dictumst. Maecenas ut massa quis augue luctus tincidunt. Nulla mollis molestie lorem. Quisque ut erat. Curabitur gravida nisi at nibh. In hac habitasse platea dictumst. Aliquam augue quam, sollicitudin vitae, consectetuer eget, rutrum at, lorem.",
"duration": 1955,
"contentRating": "R",
"source": "http://prnewswire.com/aliquam/erat.html",
"tags": [
"Thriller"
]
},
{
"id": "8ab2f271-5567-4d78-9fed-88ef660451fe",
"title": "War and Peace (Voyna i mir)",
"year": 2003,
"cover": "http://dummyimage.com/240x228.png/ff4444/ffffff",
"description": "Proin interdum mauris non ligula pellentesque ultrices. Phasellus id sapien in sapien iaculis congue. Vivamus metus arcu, adipiscing molestie, hendrerit at, vulputate vitae, nisl. Aenean lectus. Pellentesque eget nunc. Donec quis orci eget orci vehicula condimentum. Curabitur in libero ut massa volutpat convallis. Morbi odio odio, elementum eu, interdum eu, tincidunt in, leo. Maecenas pulvinar lobortis est.",
"duration": 1953,
"contentRating": "G",
"source": "https://fda.gov/non/velit/nec/nisi/vulputate/nonummy/maecenas.xml",
"tags": [
"Crime|Drama|Fantasy|Film-Noir|Mystery|Romance",
"Action|Comedy|Crime|Drama"
]
},
{
"id": "1dc54123-8186-4cb9-adbb-a145e235871d",
"title": "Thérèse",
"year": 2009,
"cover": "http://dummyimage.com/173x237.jpg/cc0000/ffffff",
"description": "Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Proin risus. Praesent lectus.",
"duration": 1912,
"contentRating": "NC-17",
"source": "https://1688.com/varius/ut.html",
"tags": [
"Drama|Film-Noir",
"Comedy|Romance",
"Action|Sci-Fi",
"Comedy|Drama",
"Comedy|Drama|Romance"
]
},
{
"id": "1cdb6171-8d12-4aea-bd86-c181d6e8cc42",
"title": "Hunky Dory",
"year": 2012,
"cover": "http://dummyimage.com/205x226.bmp/5fa2dd/ffffff",
"description": "Sed sagittis. Nam congue, risus semper porta volutpat, quam pede lobortis ligula, sit amet eleifend pede libero quis orci. Nullam molestie nibh in lectus. Pellentesque at nulla. Suspendisse potenti. Cras in purus eu magna vulputate luctus.",
"duration": 1968,
"contentRating": "NC-17",
"source": "http://amazonaws.com/augue/aliquam/erat/volutpat/in/congue/etiam.png",
"tags": [
"Action|Thriller",
"Action|Adventure",
"Drama|Romance|Sci-Fi"
]
},
{
"id": "4c1c536a-f323-4feb-80c1-ca93b896ae70",
"title": "Five Easy Pieces",
"year": 1997,
"cover": "http://dummyimage.com/153x246.png/cc0000/ffffff",
"description": "Proin eu mi. Nulla ac enim. In tempor, turpis nec euismod scelerisque, quam turpis adipiscing lorem, vitae mattis nibh ligula nec sem.",
"duration": 2039,
"contentRating": "PG",
"source": "http://nhs.uk/maecenas/leo/odio/condimentum.jsp",
"tags": [
"Adventure|Animation|Children|Drama|Fantasy"
]
},
{
"id": "a8c6c9fc-0107-494f-83ad-9ceadb11dc35",
"title": "Other, The",
"year": 1994,
"cover": "http://dummyimage.com/242x134.jpg/ff4444/ffffff",
"description": "Praesent id massa id nisl venenatis lacinia. Aenean sit amet justo. Morbi ut odio.",
"duration": 1935,
"contentRating": "G",
"source": "https://nba.com/sit.png",
"tags": [
"Crime|Drama|Mystery|Thriller"
]
},
{
"id": "5d448927-7d8e-49a7-a3bc-280938a05581",
"title": "Todos eran culpables",
"year": 1995,
"cover": "http://dummyimage.com/167x148.jpg/ff4444/ffffff",
"description": "Duis bibendum. Morbi non quam nec dui luctus rutrum. Nulla tellus.",
"duration": 1901,
"contentRating": "PG-13",
"source": "http://google.cn/at/turpis/donec/posuere/metus/vitae/ipsum.jsp",
"tags": [
"Action|Adventure|Drama"
]
}
];
function filteredMoviesMock(tag) {
return moviesMock.filter(movie => movie.tags.includes(tag));
}
class MoviesServiceMock {
async list() {
return Promise.resolve(moviesMock);
}
async create() {
return Promise.resolve(moviesMock[0]);
}
}
module.exports = {
moviesMock,
filteredMoviesMock,
MoviesServiceMock,
}

39
utils/schemas/movies.js Normal file
View File

@ -0,0 +1,39 @@
const joi = require('@hapi/joi');
const movieIdSchema = joi.string().regex(/^[0-9a-fA-F]{24}$/);
const movieTitleSchema = joi.string().max(80);
const movieYearSchema = joi.number().min(1888).max(2077);
const movieCoverSchema = joi.string().uri();
const movieDescriptionSchema = joi.string().max(300);
const movieDurationSchema = joi.number().min(1).max(300);
const movieContentRatingSchema = joi.string().max(5);
const movieSourcesSchema = joi.string().uri();
const movieTagsSchema = joi.array().items(joi.string().max(50));
const createMovieSchema = {
title: movieTitleSchema.required(),
year: movieYearSchema.required(),
cover: movieCoverSchema.required(),
description: movieDescriptionSchema.required(),
duration: movieDurationSchema.required(),
contentRating: movieContentRatingSchema.required(),
source: movieSourcesSchema.required(),
tags: movieTagsSchema,
};
const updateMovieSchema = {
title: movieTitleSchema,
year: movieYearSchema,
cover: movieCoverSchema,
description: movieDescriptionSchema,
duration: movieDurationSchema,
contentRating: movieContentRatingSchema,
source: movieSourcesSchema,
tags: movieTagsSchema,
};
module.exports = {
movieIdSchema,
createMovieSchema,
updateMovieSchema,
}

10
utils/testServer.js Normal file
View File

@ -0,0 +1,10 @@
const express = require('express');
const supertest = require('supertest');
function testServer(route) {
const app = express();
route(app);
return supertest(app);
}
module.exports = testServer;