Source: index.js

  1. 'use strict';
  2. var _ = require('lodash');
  3. /**
  4. * Options
  5. *
  6. * - Model
  7. * + rest {Boolean}
  8. * + restOptions {String} 'list show create update delete'
  9. *
  10. * - Methods
  11. * + restHooks {Object}
  12. *
  13. * {
  14. list: [listMiddleware],
  15. show: [showMiddleware],
  16. create: [createMiddleware],
  17. update: [updateMiddleware],
  18. delete: [deleteMiddleware]
  19. }
  20. *
  21. * - Fields
  22. * + restSelected {Boolean}
  23. * + restEditable {Boolean}
  24. */
  25. /**
  26. * API Blueprint Documentation templates
  27. * Variables
  28. * - name
  29. * - root
  30. * - endpoint
  31. * - attributes
  32. *
  33. * + Ignored
  34. * - id
  35. */
  36. var api_blueprint = {
  37. new_line: '\n',
  38. tab: ' ',
  39. api_doc_templates: {
  40. model: '# Endpoint for {name} [{root}{endpoint}]\nThis endpoint will provide all the required methods available for {name}',
  41. list: '## List all {name} [GET {root}{endpoint}]\nRetrieves the list of {name}\n\n+ Response 200 (application/json)',
  42. show: '## Retrieve {name} [GET {root}{endpoint}/{id}]\nRetrieves item with the id\n\n+ Response 200 (application/json)',
  43. create: '## Create a {name} [POST {root}{endpoint}]\n\n+ Attributes\n{attributes}\n\n+ Response 200 (application/json)',
  44. update: '## Updates a {name} [PUT {root}{endpoint}]\n\n+ Attributes\n{attributes}\n\n+ Response 200 (application/json)',
  45. delete: '## Deletes an item from {name} [DELETE {root}{endpoint}/{id}]\nDelete a {name}. **Warning:** This action **permanently** removes the {name} from the database.\n\n+ Response 200 (application/json)',
  46. },
  47. /**
  48. * Gets a method type and the vars and created the documentation Text
  49. *
  50. * @param {String} type Method type
  51. * @param {Object} vars Variables to be merged
  52. * @returns {String} Template output
  53. */
  54. _docTemplate: function (type, vars) {
  55. var tmp = api_blueprint.api_doc_templates[type];
  56. _.forEach(vars, function (val, key) {
  57. tmp = tmp.replace(new RegExp('{' + key + '}', 'g'), val);
  58. });
  59. return tmp;
  60. },
  61. convertType: function (type) {
  62. type = type.toLowerCase();
  63. if (type == 'objectid') {
  64. type = 'object';
  65. }
  66. return type;
  67. },
  68. convertDefault: function (value) {
  69. if (typeof (value) == 'string' && value == '') {
  70. return "''";
  71. }
  72. if (!value) {
  73. return "''";
  74. }
  75. return value;
  76. }
  77. };
  78. /**
  79. * @constructor
  80. */
  81. function KeystoneRest() {
  82. var self = this;
  83. /**
  84. * Root of the API
  85. */
  86. var apiRoot = '/api/';
  87. var api_doc = {};
  88. // Mongoose instance attached to keystone object.
  89. // Assigned in addRoutes
  90. var mongoose,
  91. keystone;
  92. /**
  93. * Array containing routes and handlers
  94. * @type {Array}
  95. */
  96. self.routes = [];
  97. /**
  98. * Send a 404 response
  99. * @param {Object} res Express response
  100. * @param {String} message Message
  101. */
  102. var _send404 = function (res, message) {
  103. res.status(404);
  104. res.json({
  105. status: 'missing',
  106. message: message
  107. });
  108. };
  109. /**
  110. * Send an error response
  111. * @param {Object} err Error response object
  112. * @param {Object} res Express response
  113. */
  114. var _sendError = function (err, req, res, next) {
  115. /*jslint unparam: true */
  116. next(err);
  117. };
  118. /**
  119. * Convert fields that are relationships to _ids
  120. * @param {Object} model instance of mongoose model
  121. */
  122. var _flattenRelationships = function (model, body) {
  123. _.each(body, function (field, key) {
  124. var schemaField = model.schema.paths[key];
  125. // return if value is a string
  126. if (typeof field === 'string' || !schemaField || _.isEmpty(schemaField)) {
  127. return;
  128. }
  129. if (schemaField.options.ref) {
  130. body[key] = field._id;
  131. }
  132. if (_.isArray(schemaField.options.type)) {
  133. if (schemaField.options.type[0].ref) {
  134. _.each(field, function (value, i) {
  135. if (typeof value === 'string' || !value) {
  136. return;
  137. }
  138. body[key][i] = value._id;
  139. });
  140. }
  141. }
  142. });
  143. };
  144. /**
  145. * Get list of selected fields based on options in schema
  146. *
  147. * @param {Schema} schema Mongoose schema
  148. */
  149. var _getSelectedFieldsArray = function (schema) {
  150. var selected = [];
  151. _.each(schema.paths, function (path) {
  152. if (path.options.restSelected !== false) {
  153. selected.push(path);
  154. }
  155. });
  156. return selected;
  157. };
  158. /**
  159. * Get list of selected fields based on options in schema
  160. *
  161. * @param {Schema} schema Mongoose schema
  162. */
  163. var _getSelectedArray = function (schema) {
  164. /*var selected = [];
  165. _.each(schema.paths, function (path) {
  166. if (path.options.restSelected !== false) {
  167. selected.push(path.path);
  168. }
  169. });*/
  170. return _.pluck(_getSelectedFieldsArray(schema), 'path');
  171. };
  172. /**
  173. * Get list of selected fields based on options in schema
  174. * @param {Schema} schema Mongoose schema
  175. */
  176. var _getSelected = function (schema) {
  177. return _getSelectedArray(schema).join(' ');
  178. };
  179. /**
  180. * Get Uneditable
  181. * @param {Schema} schema Mongoose schema
  182. */
  183. var _getUneditable = function (schema) {
  184. var uneditable = [];
  185. _.each(schema.paths, function (path) {
  186. if (path.options.restEditable === false) {
  187. uneditable.push(path.path);
  188. return;
  189. }
  190. if (path.options.type.constructor.name === 'Array') {
  191. if (path.options.type[0].restEditable === false) {
  192. uneditable.push(path.path);
  193. }
  194. }
  195. });
  196. return uneditable;
  197. };
  198. /**
  199. * Get name of reference model
  200. * @param {Model} Model Mongoose model
  201. * @param {String} path Ref path to get name from
  202. */
  203. var _getRefName = function (Model, path) {
  204. var options = Model.schema.paths[path].options;
  205. // One to one relationship
  206. if (options.ref) {
  207. return options.ref;
  208. }
  209. // One to many relationsihp
  210. return options.type[0].ref;
  211. };
  212. var _registerList = function (md) {
  213. // Check if Rest must be enabled
  214. try {
  215. if (md && md.options.rest) {
  216. // Register the model
  217. addRoutes(md, md.options.restOptions, md.restHooks, md.model.collection.name);
  218. } else {
  219. console.info('Rest is not enabled for ' + md.model.collection.name);
  220. }
  221. } catch (e) {
  222. console.info('Error registering List. Please verify')
  223. }
  224. };
  225. /**
  226. * Register the models that has the Rest Option enabled
  227. *
  228. * @param {Object} app Keystone App
  229. */
  230. var _registerRestModels = function (app) {
  231. // Get the models
  232. var keys = Object.keys(app.mongoose.models);
  233. for (var i = 0; i < keys.length; i++) {
  234. // Get the Keyston List
  235. var md = app.list(keys[i]);
  236. _registerList(md);
  237. }
  238. };
  239. /**
  240. * Creates the documentation for an endpoint
  241. *
  242. * @param {String} method The method to be created
  243. * @param {Object} Model The Model
  244. */
  245. var _createDocumentation = function (method, Model) {
  246. var collectionName = Model.collection.name;
  247. // Defaults
  248. var vars = {
  249. name: collectionName,
  250. root: apiRoot,
  251. endpoint: collectionName.toLowerCase()
  252. };
  253. // create / update
  254. if (method == 'create' || method == 'update') {
  255. var selecteds = _getSelectedFieldsArray(Model.schema);
  256. var attributes = [];
  257. _.forEach(selecteds, function (selected) {
  258. var tmp = api_blueprint.tab + '+ ' + selected.path;
  259. if (selected.instance != undefined) {
  260. tmp += ' (' + api_blueprint.convertType(selected.instance) + ')';
  261. }
  262. if (_.has(selected.options, 'default') && typeof (selected.options.default) != 'Function') {
  263. tmp += api_blueprint.new_line + api_blueprint.tab + api_blueprint.tab + '+ Default: ' + api_blueprint.convertDefault(selected.options.default);
  264. }
  265. if (_.has(selected.options, 'ref')) {
  266. tmp += api_blueprint.new_line + api_blueprint.tab + api_blueprint.tab + '+ Reference: ' + selected.options.ref;
  267. }
  268. attributes.push(tmp);
  269. });
  270. vars['attributes'] = attributes.join(api_blueprint.new_line);
  271. }
  272. api_doc[collectionName.toLowerCase()][method] = api_blueprint._docTemplate(method, vars);
  273. };
  274. /**
  275. * Add get route
  276. * @param {Model} model Mongoose Model
  277. * @param {Mixed} middleware Express middleware to execute before route handler
  278. * @param {String} selected String passed to mongoose "select" method
  279. */
  280. var _addList = function (Model, middleware, selected, relationships) {
  281. // Create Docs
  282. _createDocumentation('show', Model);
  283. // Get a list of items
  284. self.routes.push({
  285. method: 'get',
  286. middleware: middleware,
  287. route: apiRoot + Model.collection.name.toLowerCase(),
  288. handler: function (req, res, next) {
  289. var populated = req.query.populate ? req.query.populate.split(',') : [],
  290. criteria = _.omit(req.query, ['populate', '_', 'limit', 'skip', 'sort', 'select']),
  291. querySelect;
  292. if (req.query.select) {
  293. querySelect = req.query.select.split(',');
  294. querySelect = querySelect.filter(function (field) {
  295. return (selected.indexOf(field) > -1);
  296. }).join(' ');
  297. }
  298. Model.find().count(function (err, count) {
  299. if (err) {
  300. return _sendError(err, req, res, next);
  301. }
  302. var query = Model.find(criteria).skip(req.query.skip)
  303. .limit(req.query.limit)
  304. .sort(req.query.sort)
  305. .select(querySelect || selected);
  306. populated.forEach(function (path) {
  307. query.populate({
  308. path: path,
  309. select: _getSelected(mongoose.model(_getRefName(Model, path)).schema)
  310. });
  311. });
  312. query.exec(function (err, response) {
  313. if (err) {
  314. return _sendError(err, req, res, next);
  315. }
  316. // Make total total accessible via response headers
  317. res.setHeader('total', count);
  318. res.json(response);
  319. });
  320. });
  321. }
  322. });
  323. // Get a list of relationships
  324. if (relationships) {
  325. _.each(relationships, function (relationship) {
  326. self.routes.push({
  327. method: 'get',
  328. middleware: [],
  329. route: apiRoot + Model.collection.name.toLowerCase() + '/:id/' + relationship,
  330. handler: function (req, res, next) {
  331. Model.findById(req.params.id).exec(function (err, result) {
  332. var total,
  333. criteria = _.omit(req.query, ['populate', '_', 'limit', 'skip', 'sort', 'select']),
  334. ref,
  335. query,
  336. querySelect,
  337. refSelected,
  338. sortedResults = [];
  339. if (err && err.type !== 'ObjectId') {
  340. return _sendError(err, req, res, next);
  341. }
  342. if (!result) {
  343. return _send404(res, 'Could not find ' + Model.collection.name.toLowerCase() + ' with id ' + req.params.id);
  344. }
  345. total = result[relationship].length;
  346. ref = Model.schema.paths[relationship].caster.options.ref;
  347. refSelected = _getSelected(mongoose.model(ref).schema);
  348. query = mongoose.model(ref)
  349. .find(criteria)
  350. .in('_id', result[relationship])
  351. .limit(req.query.limit)
  352. .skip(req.query.skip)
  353. .sort(req.query.sort);
  354. if (req.query.select) {
  355. querySelect = req.query.select.split(',');
  356. querySelect = querySelect.filter(function (field) {
  357. return (refSelected.indexOf(field) > -1);
  358. }).join(' ');
  359. query.select(querySelect);
  360. }
  361. if (req.query.populate && typeof req.query.populate === 'string') {
  362. query.populate(req.query.populate);
  363. }
  364. query.exec(function (err, response) {
  365. if (err) {
  366. return _sendError(err, req, res, next);
  367. }
  368. // Put relationship results into same order
  369. // that they appear in document
  370. if (!req.query.sort) {
  371. result[relationship].forEach(function (_id, i) {
  372. sortedResults[i] = _.findWhere(response, {
  373. _id: _id
  374. });
  375. });
  376. response = sortedResults;
  377. }
  378. // Make total total accessible via response headers
  379. res.setHeader('total', total);
  380. res.json(response);
  381. });
  382. });
  383. }
  384. });
  385. });
  386. }
  387. };
  388. /**
  389. * Add list route
  390. * @param {Model} model Mongoose Model
  391. * @param {Mixed} middleware Express middleware to execute before route handler
  392. * @param {String} selected String passed to mongoose "select" method
  393. */
  394. var _addShow = function (Model, middleware, selected, findBy) {
  395. // Create Docs
  396. _createDocumentation('list', Model);
  397. var collectionName = Model.collection.name.toLowerCase();
  398. var paramName = Model.modelName.toLowerCase();
  399. // Get one item
  400. self.routes.push({
  401. method: 'get',
  402. middleware: middleware,
  403. route: apiRoot + collectionName + '/:' + paramName,
  404. handler: function (req, res, next) {
  405. var populated = req.query.populate ? req.query.populate.split(',') : [];
  406. var criteria = {};
  407. var querySelect;
  408. if (req.query.select) {
  409. querySelect = req.query.select.split(',');
  410. querySelect = querySelect.filter(function (field) {
  411. return (selected.indexOf(field) > -1);
  412. }).join(' ');
  413. }
  414. criteria[findBy] = req.params[paramName];
  415. var query = Model.findOne(criteria)
  416. .select(querySelect || selected);
  417. populated.forEach(function (path) {
  418. query.populate({
  419. path: path,
  420. select: _getSelected(mongoose.model(_getRefName(Model, path)).schema)
  421. });
  422. });
  423. query.exec(function (err, result) {
  424. if (err && err.type !== 'ObjectId') {
  425. return _sendError(err, req, res, next);
  426. }
  427. if (!result) {
  428. return _send404(res, 'Could not find ' + Model.collection.name.toLowerCase() + ' with id ' + req.params.id);
  429. }
  430. res.json(result);
  431. });
  432. }
  433. });
  434. };
  435. /**
  436. * Add post route
  437. * @param {Model} Model Mongoose Model
  438. * @param {Mixed} middleware Express middleware to execute before route handler
  439. * @param {String} selected String passed to mongoose "select" method
  440. */
  441. var _addCreate = function (Model, middleware, selected) {
  442. // Create Docs
  443. _createDocumentation('create', Model);
  444. // Create a new item
  445. self.routes.push({
  446. method: 'post',
  447. middleware: middleware,
  448. route: apiRoot + Model.collection.name.toLowerCase(),
  449. handler: function (req, res, next) {
  450. var item;
  451. _flattenRelationships(Model, req.body);
  452. var md = new Model();
  453. // Get the UpdateHandler from Keystone and process the Request
  454. md.getUpdateHandler(req).process(req.body, {
  455. flashErrors: false
  456. }, function (err, item) {
  457. if (err) {
  458. return _sendError(err, req, res, next);
  459. }
  460. res.json(item);
  461. });
  462. }
  463. });
  464. };
  465. /**
  466. * Add put route
  467. * @param {Model} Model Mongoose Model
  468. * @param {Mixed} middleware Express middleware to execute before route handler
  469. * @param {String} selected String passed to mongoose "select" method
  470. * @param {Array} uneditable Array of fields to remove from post
  471. */
  472. var _addUpdate = function (Model, middleware, uneditable, selected, findBy) {
  473. // Create Docs
  474. _createDocumentation('update', Model);
  475. var collectionName = Model.collection.name.toLowerCase();
  476. var paramName = Model.modelName.toLowerCase();
  477. var versionKey = Model.schema.options.versionKey;
  478. var handler = function (req, res, next) {
  479. var populated = req.query.populate ? req.query.populate.split(',') : '';
  480. var criteria = {};
  481. var querySelect;
  482. if (req.query.select) {
  483. querySelect = req.query.select.split(',');
  484. querySelect = querySelect.filter(function (field) {
  485. return (selected.indexOf(field) > -1);
  486. }).join(' ');
  487. }
  488. criteria[findBy] = req.params[paramName];
  489. _flattenRelationships(Model, req.body);
  490. req.body = _.omit(req.body, uneditable);
  491. Model.findOne(criteria).exec(function (err, item) {
  492. /*jslint unparam: true */
  493. if (err && err.type !== 'ObjectId') {
  494. return _sendError(err, req, res, next);
  495. }
  496. if (!item) {
  497. return _send404(res, 'Could not find ' + Model.collection.name.toLowerCase() + ' with id ' + req.params.id);
  498. }
  499. if (req.body[versionKey] < item[versionKey]) {
  500. return _sendError(new mongoose.Error.VersionError(), req, res, next);
  501. }
  502. //_.extend(item, req.body); // Not sure about extending with UpdateHandler
  503. // Get the UpdateHandler and update
  504. item.getUpdateHandler(req).process(req.body, {
  505. flashErrors: false
  506. }, function (err, item) {
  507. if (err) {
  508. return _sendError(err, req, res, next);
  509. }
  510. Model.findOne(criteria).select(querySelect || selected).populate(populated).exec(function (err, item) {
  511. if (err) {
  512. return _sendError(err, req, res, next);
  513. }
  514. res.json(item);
  515. });
  516. });
  517. });
  518. };
  519. // Update an item having a given key
  520. self.routes.push({
  521. method: 'put',
  522. middleware: middleware,
  523. route: apiRoot + collectionName + '/:' + paramName,
  524. handler: handler
  525. });
  526. self.routes.push({
  527. method: 'patch',
  528. middleware: middleware,
  529. route: apiRoot + collectionName + '/:' + paramName,
  530. handler: handler
  531. });
  532. };
  533. /**
  534. * Add delete route
  535. * @param {Model} model Mongoose Model
  536. * @param {Mixed} middleware Express middleware to execute before route handler
  537. */
  538. var _addDelete = function (Model, middleware, findBy) {
  539. // Create Docs
  540. _createDocumentation('delete', Model);
  541. var collectionName = Model.collection.name.toLowerCase();
  542. var paramName = Model.modelName.toLowerCase();
  543. // Delete an item having a given id
  544. self.routes.push({
  545. method: 'delete',
  546. middleware: middleware,
  547. route: apiRoot + collectionName + '/:' + paramName,
  548. handler: function (req, res, next) {
  549. var criteria = {};
  550. criteria[findBy] = req.params[paramName];
  551. // First find so middleware hooks (pre,post) will execute
  552. Model.findOne(criteria, function (err, item) {
  553. if (err && err.type !== 'ObjectId') {
  554. return _sendError(err, req, res, next);
  555. }
  556. if (!item) {
  557. return _send404(res, 'Could not find ' + Model.collection.name.toLowerCase() + ' with id ' + req.params.id);
  558. }
  559. item.remove(function (err) {
  560. if (err) {
  561. return _sendError(err, req, res, next);
  562. }
  563. res.json({
  564. message: 'Successfully deleted ' + collectionName
  565. });
  566. });
  567. });
  568. }
  569. });
  570. };
  571. /**
  572. * Add routes
  573. *
  574. * @param {Object} keystoneList Instance of KeystoneList
  575. * @param {String} methods Methods to expose('list show create update delete')
  576. * @param {Object} middleware Map containing middleware to execute for each action ({ list: [middleware] })
  577. * @param {String} relationships Space separated list of relationships to build routes for
  578. */
  579. var addRoutes = function (keystoneList, methods, middleware, relationships) {
  580. // Get reference to mongoose for internal use
  581. mongoose = keystone.mongoose;
  582. var findBy;
  583. var Model = keystoneList.model;
  584. if (!Model instanceof mongoose.model) {
  585. throw new Error('keystoneList is required');
  586. }
  587. if (!methods) {
  588. throw new Error('Methods are required');
  589. }
  590. if (!mongoose) {
  591. throw new Error('Keystone must be initialized before attempting to add routes');
  592. }
  593. var collectionName = Model.collection.name;
  594. if (!_.has(api_doc, collectionName.toLowerCase())) {
  595. api_doc[collectionName.toLowerCase()] = {
  596. model: api_blueprint._docTemplate('model', {
  597. name: collectionName,
  598. root: apiRoot,
  599. endpoint: collectionName.toLowerCase()
  600. })
  601. };
  602. }
  603. var selected = _getSelected(Model.schema),
  604. uneditable = _getUneditable(Model.schema),
  605. listMiddleware,
  606. showMiddleware,
  607. createMiddleware,
  608. updateMiddleware,
  609. deleteMiddleware;
  610. methods = methods.split(' ');
  611. // Use autoKey to find doc if it exists
  612. if (keystoneList.options.autokey) {
  613. findBy = keystoneList.options.autokey.path;
  614. } else {
  615. findBy = '_id';
  616. }
  617. // Set up default middleware
  618. middleware = middleware || {};
  619. listMiddleware = middleware.list || [];
  620. showMiddleware = middleware.show || [];
  621. createMiddleware = middleware.create || [];
  622. updateMiddleware = middleware.update || [];
  623. deleteMiddleware = middleware.delete || [];
  624. relationships = relationships ? relationships.split(' ') : [];
  625. if (methods.indexOf('list') !== -1) {
  626. _addList(Model, listMiddleware, selected, relationships);
  627. }
  628. if (methods.indexOf('show') !== -1) {
  629. _addShow(Model, showMiddleware, selected, findBy);
  630. }
  631. if (methods.indexOf('create') !== -1) {
  632. _addCreate(Model, createMiddleware, selected);
  633. }
  634. if (methods.indexOf('update') !== -1) {
  635. _addUpdate(Model, updateMiddleware, uneditable, selected, findBy);
  636. }
  637. if (methods.indexOf('delete') !== -1) {
  638. _addDelete(Model, deleteMiddleware, findBy);
  639. }
  640. };
  641. /**
  642. * Register a Keystone List manually.
  643. *
  644. * @param {Object} list Object of Type Keystone List
  645. */
  646. this.registerList = function (list) {
  647. _registerList(list);
  648. };
  649. /**
  650. * Creates Rest
  651. *
  652. * @param {Object} app Keystone instance
  653. */
  654. this.createRest = function (kref, options) {
  655. keystone = kref; // Get the app reference of Keystone
  656. if (options == undefined) {
  657. options = {};
  658. };
  659. if (_.has(options, 'apiRoot') && options.apiRoot != '') {
  660. apiRoot = options.apiRoot;
  661. }
  662. // Get and register the models
  663. _registerRestModels(keystone);
  664. _.each(self.routes, function (route) {
  665. keystone.app[route.method](route.route, route.middleware, route.handler);
  666. });
  667. };
  668. /**
  669. * Returns the API Docs for the API Created
  670. *
  671. * @returns {String} The Blueprint formatted API
  672. */
  673. this.apiDocs = function () {
  674. var md_doc = [];
  675. _.forEach(api_doc, function (model_doc, key) {
  676. var documentation = [];
  677. _.forEach(model_doc, function (doc, k) {
  678. documentation.push(doc);
  679. });
  680. md_doc.push(documentation.join((api_blueprint.new_line + api_blueprint.new_line)));
  681. });
  682. return md_doc.join((api_blueprint.new_line + api_blueprint.new_line));
  683. };
  684. }
  685. /*
  686. ** Exports
  687. */
  688. module.exports = new KeystoneRest();