diff --git a/packages/cli/README.md b/packages/cli/README.md index d9d4fcf83206..9040a0e13d1a 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -56,6 +56,27 @@ Options: ``` +3. To scaffold a controller into your application + +```sh + cd + lb4 controller +``` + +``` +Usage: + lb4 controller [options] [] + +Options: + -h, --help # Print the generator's options and usage + --skip-cache # Do not remember prompt answers Default: false + --skip-install # Do not automatically install dependencies Default: false + --controllerType # Type for the controller + +Arguments: + name # Name for the controller Type: String Required: false +``` + # Tests run `npm test` from the root folder. diff --git a/packages/cli/generators/controller/index.js b/packages/cli/generators/controller/index.js index a252a7256bfd..0e3f0b3efc7d 100644 --- a/packages/cli/generators/controller/index.js +++ b/packages/cli/generators/controller/index.js @@ -4,25 +4,56 @@ // License text available at https://opensource.org/licenses/MIT 'use strict'; +const _ = require('lodash'); const ArtifactGenerator = require('../../lib/artifact-generator'); const debug = require('../../lib/debug')('controller-generator'); const inspect = require('util').inspect; +const path = require('path'); const utils = require('../../lib/utils'); +// Exportable constants module.exports = class ControllerGenerator extends ArtifactGenerator { // Note: arguments and options should be defined in the constructor. constructor(args, opts) { super(args, opts); } + static get BASIC() { + return 'Empty Controller'; + } + + static get REST() { + return 'REST Controller with CRUD functions'; + } + _setupGenerator() { this.artifactInfo = { type: 'controller', - outdir: 'src/controllers/', + rootDir: 'src', }; - if (debug.enabled) { - debug(`artifactInfo: ${inspect(this.artifactInfo)}`); - } + + // XXX(kjdelisle): These should be more extensible to allow custom paths + // for each artifact type. + + this.artifactInfo.outdir = path.resolve( + this.artifactInfo.rootDir, + 'controllers' + ); + this.artifactInfo.modelDir = path.resolve( + this.artifactInfo.rootDir, + 'models' + ); + this.artifactInfo.repositoryDir = path.resolve( + this.artifactInfo.rootDir, + 'repositories' + ); + + this.option('controllerType', { + type: String, + required: false, + description: 'Type for the ' + this.artifactInfo.type, + }); + return super._setupGenerator(); } @@ -34,25 +65,154 @@ module.exports = class ControllerGenerator extends ArtifactGenerator { return super.promptArtifactName(); } + promptArtifactType() { + debug('Prompting for controller type'); + return this.prompt([ + { + type: 'list', + name: 'controllerType', + message: 'What kind of controller would you like to generate?', + when: this.artifactInfo.controllerType === undefined, + choices: [ControllerGenerator.BASIC, ControllerGenerator.REST], + default: ControllerGenerator.BASIC, + }, + ]) + .then(props => { + Object.assign(this.artifactInfo, props); + return props; + }) + .catch(err => { + debug(`Error during controller type prompt: ${err.stack}`); + return this.exit(err); + }); + } + + promptArtifactCrudVars() { + let modelList = []; + let repositoryList = []; + if ( + !this.artifactInfo.controllerType || + this.artifactInfo.controllerType === ControllerGenerator.BASIC + ) { + return; + } + return utils + .getArtifactList(this.artifactInfo.modelDir, 'model') + .then(list => { + if (_.isEmpty(list)) { + return Promise.reject( + new Error(`No models found in ${this.artifactInfo.modelDir}`) + ); + } + modelList = list; + return utils.getArtifactList( + this.artifactInfo.repositoryDir, + 'repository', + true + ); + }) + .then(list => { + if (_.isEmpty(list)) { + return Promise.reject( + new Error( + `No repositories found in ${this.artifactInfo.repositoryDir}` + ) + ); + } + repositoryList = list; + return this.prompt([ + { + type: 'list', + name: 'modelName', + message: + 'What is the name of the model to use with this CRUD repository?', + choices: modelList, + when: this.artifactInfo.modelName === undefined, + default: modelList[0], + validate: utils.validateClassName, + }, + { + type: 'list', + name: 'repositoryName', + message: 'What is the name of your CRUD repository?', + choices: repositoryList, + when: this.artifactInfo.repositoryName === undefined, + default: repositoryList[0], + validate: utils.validateClassName, + }, + { + type: 'list', + name: 'idType', + message: 'What is the type of your ID?', + choices: ['number', 'string', 'object'], + when: this.artifactInfo.idType === undefined, + default: 'number', + }, + ]).then(props => { + debug(`props: ${inspect(props)}`); + Object.assign(this.artifactInfo, props); + // Ensure that the artifact names are valid. + [ + this.artifactInfo.name, + this.artifactInfo.modelName, + this.artifactInfo.repositoryName, + ].forEach(item => { + item = utils.toClassName(item); + }); + // Create camel-case names for variables. + this.artifactInfo.repositoryNameCamel = utils.camelCase( + this.artifactInfo.repositoryName + ); + this.artifactInfo.modelNameCamel = utils.camelCase( + this.artifactInfo.modelName + ); + return props; + }); + }) + .catch(err => { + debug(`Error during prompt for controller variables: ${err}`); + return this.exit(err); + }); + } + scaffold() { - super.scaffold(); + // We don't want to call the base scaffold function since it copies + // all of the templates! if (this.shouldExit()) return false; + this.artifactInfo.name = utils.toClassName(this.artifactInfo.name); this.artifactInfo.filename = utils.kebabCase(this.artifactInfo.name) + '.controller.ts'; if (debug.enabled) { debug(`Artifact filename set to: ${this.artifactInfo.filename}`); } // renames the file - const source = this.destinationPath( - this.artifactInfo.outdir + 'controller-template.ts' - ); + let template = 'controller-template.ts'; + switch (this.artifactInfo.controllerType) { + case ControllerGenerator.REST: + template = 'controller-rest-template.ts'; + break; + default: + break; + } + const source = this.templatePath(path.join('src', 'controllers', template)); + if (debug.enabled) { + debug(`Using template at: ${source}`); + } const dest = this.destinationPath( - this.artifactInfo.outdir + this.artifactInfo.filename + path.join(this.artifactInfo.outdir, this.artifactInfo.filename) ); + if (debug.enabled) { + debug(`artifactInfo: ${inspect(this.artifactInfo)}`); debug(`Copying artifact to: ${dest}`); } - this.fs.move(source, dest, {globOptions: {dot: true}}); + this.fs.copyTpl( + source, + dest, + this.artifactInfo, + {}, + {globOptions: {dot: true}} + ); return; } diff --git a/packages/cli/generators/controller/templates/src/controllers/controller-rest-template.ts b/packages/cli/generators/controller/templates/src/controllers/controller-rest-template.ts new file mode 100644 index 000000000000..803333101cda --- /dev/null +++ b/packages/cli/generators/controller/templates/src/controllers/controller-rest-template.ts @@ -0,0 +1,57 @@ +import {Filter, Where} from '@loopback/repository'; +import {post, param, get, put, patch, del} from '@loopback/openapi-v2'; +import {inject} from '@loopback/context'; +import {<%= modelName %>} from '../models'; +import {<%= repositoryName %>} from '../repositories'; + +export class <%= name %>Controller { + + constructor( + @inject('repositories.<%= repositoryName %>') + public <%= repositoryNameCamel %> : <%= repositoryName %>, + ) {} + + @post('/<%= modelNameCamel %>') + async create(@param.body('obj') obj: <%= modelName %>) + : Promise<<%= modelName %>> { + return await this.<%= repositoryNameCamel %>.create(obj); + } + + @get('/<%= modelNameCamel %>/count') + async count(@param.query.string('where') where: Where) : Promise { + return await this.<%= repositoryNameCamel %>.count(where); + } + + @get('/<%= modelNameCamel %>') + async find(@param.query.string('filter') filter: Filter) + : Promise<<%= modelName %>[]> { + return await this.<%= repositoryNameCamel %>.find(filter); + } + + @patch('/<%= modelNameCamel %>') + async updateAll(@param.query.string('where') where: Where, + @param.body('obj') obj: <%= modelName %>) : Promise { + return await this.<%= repositoryNameCamel %>.updateAll(where, obj); + } + + @del('/<%= modelNameCamel %>') + async deleteAll(@param.query.string('where') where: Where) : Promise { + return await this.<%= repositoryNameCamel %>.deleteAll(where); + } + + @get('/<%= modelNameCamel %>/{id}') + async findById(@param.path.number('id') id: <%= idType %>) : Promise<<%= modelName %>> { + return await this.<%= repositoryNameCamel %>.findById(id); + } + + @patch('/<%= modelNameCamel %>/{id}') + async updateById(@param.path.number('id') id: <%= idType %>, @param.body('obj') + obj: <%= modelName %>) : Promise { + return await this.<%= repositoryNameCamel %>.updateById(id, obj); + } + + @del('/<%= modelNameCamel %>/{id}') + async deleteById(@param.path.number('id') id: <%= idType %>) : Promise { + return await this.<%= repositoryNameCamel %>.deleteById(id); + } +} diff --git a/packages/cli/lib/artifact-generator.js b/packages/cli/lib/artifact-generator.js index aa0ac36aa6d6..57ac46951353 100644 --- a/packages/cli/lib/artifact-generator.js +++ b/packages/cli/lib/artifact-generator.js @@ -82,6 +82,7 @@ module.exports = class ArtifactGenerator extends BaseGenerator { return this.prompt(prompts).then(props => { Object.assign(this.artifactInfo, props); + return props; }); } diff --git a/packages/cli/lib/base-generator.js b/packages/cli/lib/base-generator.js index 09c00941ad5e..bb75741c6b29 100644 --- a/packages/cli/lib/base-generator.js +++ b/packages/cli/lib/base-generator.js @@ -21,7 +21,9 @@ module.exports = class BaseGenerator extends Generator { * Subclasses can extend _setupGenerator() to set up the generator */ _setupGenerator() { - // No operation + this.artifactInfo = { + rootDir: 'src', + }; } /** diff --git a/packages/cli/lib/utils.js b/packages/cli/lib/utils.js index 660b116214e7..638434103002 100644 --- a/packages/cli/lib/utils.js +++ b/packages/cli/lib/utils.js @@ -5,14 +5,26 @@ 'use strict'; +const debug = require('../lib/debug')('utils'); const fs = require('fs'); +const path = require('path'); const util = require('util'); const regenerate = require('regenerate'); const _ = require('lodash'); const pascalCase = require('change-case').pascalCase; +const promisify = util.promisify || require('util.promisify/implementation'); +const camelCase = require('change-case').camelCase; const validate = require('validate-npm-package-name'); const Conflicter = require('yeoman-generator/lib/util/conflicter'); +const readdirAsync = promisify(fs.readdir); + +/** + * Either a reference to util.promisify or its polyfill, depending on + * your version of Node. + */ +exports.promisify = promisify; + /** * Returns a valid variable name regex; * taken from https://gist.github.com/mathiasbynens/6334847 @@ -97,6 +109,7 @@ exports.toClassName = function(name) { exports.kebabCase = _.kebabCase; exports.pascalCase = pascalCase; +exports.camelCase = camelCase; exports.validate = function(name) { const isValid = validate(name).validForNewPackages; @@ -121,3 +134,52 @@ exports.StatusConflicter = class StatusConflicter extends Conflicter { }); } }; + +/** + * Find all artifacts in the given path whose type matches the provided + * filetype. + * For example, a fileType of "model" will search the target path for matches to + * "*.model.js" + * @param {string} path The directory path to search. This search is *not* + * recursive. + * @param {string} artifactType The type of the artifact in string form. + * @param {Function=} reader An optional reader function to retrieve the + * paths. Must return a Promise. + * @returns {Promise} The filtered list of paths. + */ +exports.findArtifactPaths = function(path, artifactType, reader) { + const readdir = reader || readdirAsync; + debug(`Finding artifact paths at: ${path}`); + // Wrapping readdir in case it's not a promise. + return Promise.resolve(readdir(path)).then(files => { + return _.filter(files, f => { + return ( + _.endsWith(f, `${artifactType}.js`) || + _.endsWith(f, `${artifactType}.ts`) + ); + }); + }); +}; +/** + * Parses the files of the target directory and returns matching JavaScript + * or TypeScript artifact files. NOTE: This function does not examine the + * contents of these files! + * @param {string} dir The target directory from which to load artifacts. + * @param {string} artifactType The artifact type (ex. "model", "repository") + * @param {boolean} addSuffix Whether or not to append the artifact type to the + * results. (ex. [Foo,Bar] -> [FooRepository, BarRepository]) + * @param {Function} reader An alternate function to replace the promisified + * fs.readdir (useful for testing and for custom overrides). + */ +exports.getArtifactList = function(dir, artifactType, addSuffix, reader) { + return exports.findArtifactPaths(dir, artifactType, reader).then(paths => { + debug(`Filtering artifact paths: ${paths}`); + return paths.map(p => { + const result = _.first(_.split(_.last(_.split(p, path.sep)), '.')); + // + return addSuffix + ? exports.toClassName(result) + exports.toClassName(artifactType) + : exports.toClassName(result); + }); + }); +}; diff --git a/packages/cli/package.json b/packages/cli/package.json index 774f375bad3a..95a8c6107543 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -48,6 +48,7 @@ "request": "^2.83.0", "tar-fs": "^1.16.0", "unicode-10.0.0": "^0.7.4", + "util.promisify": "^1.0.0", "validate-npm-package-name": "^3.0.0", "yeoman-generator": "^2.0.1" }, diff --git a/packages/cli/test/controller.js b/packages/cli/test/controller.js index 2ec4dd764326..9624e133ad99 100644 --- a/packages/cli/test/controller.js +++ b/packages/cli/test/controller.js @@ -5,100 +5,325 @@ 'use strict'; +const expect = require('@loopback/testlab').expect; +const debug = require('../lib/debug')('controller-test'); const path = require('path'); const assert = require('yeoman-assert'); const helpers = require('yeoman-test'); const fs = require('fs'); +const util = require('util'); +const testUtils = require('./test-utils'); +const ControllerGenerator = require('../generators/controller'); const generator = path.join(__dirname, '../generators/controller'); const tests = require('./artifact')(generator); const baseTests = require('./base-generator')(generator); -const templateName = '/src/controllers/controller-template.ts'; +const templateName = testUtils.givenAControllerPath( + null, + 'controller-template.ts' +); const withInputProps = { name: 'fooBar', }; -const withInputName = '/src/controllers/foo-bar.controller.ts'; +const withInputName = testUtils.givenAControllerPath( + null, + 'foo-bar.controller.ts' +); describe('controller-generator extending BaseGenerator', baseTests); describe('generator-loopback4:controller', tests); describe('lb4 controller', () => { it('does not run without package.json', () => { - helpers - .run(generator) - .withPrompts(withInputProps) + return testUtils + .runGeneratorWith( + generator, + withInputProps, + testUtils.givenAnApplicationDir + ) .then(() => { assert.noFile(withInputName); }); }); it('does not run without the loopback keyword', () => { - let tmpDir; - helpers - .run(generator) - .inTmpDir(dir => { - tmpDir = dir; - fs.writeFileSync( - path.join(tmpDir, 'package.json'), - JSON.stringify({ - keywords: ['foobar'], - }) - ); - }) - .withPrompts(withInputProps) + return testUtils + .runGeneratorWith( + generator, + withInputProps, + testUtils.givenAnApplicationDir + ) .then(() => { assert.noFile(withInputName); }); }); - describe('with input', () => { - let tmpDir; - before(() => { - return helpers - .run(generator) - .inTmpDir(dir => { - tmpDir = dir; - fs.writeFileSync( - path.join(tmpDir, 'package.json'), - JSON.stringify({ - keywords: ['loopback'], - }) - ); - }) - .withPrompts(withInputProps); - }); - it('writes correct file name', () => { - assert.file(tmpDir + withInputName); - assert.noFile(tmpDir + templateName); + describe('basic', () => { + describe('with input', () => { + let tmpDir; + before(() => { + return testUtils + .runGeneratorWith(generator, withInputProps, dir => { + tmpDir = dir; + testUtils.givenAnApplicationDir(dir); + }) + .toPromise(); + }); + it('writes correct file name', () => { + assert.file(tmpDir + withInputName); + assert.noFile(tmpDir + templateName); + }); + it('scaffolds correct files', () => { + checkBasicContents(tmpDir); + }); }); - it('scaffolds correct files', () => { - assert.fileContent(tmpDir + withInputName, /class FooBarController/); - assert.fileContent(tmpDir + withInputName, /constructor\(\) {}/); + describe('with arg', () => { + let tmpDir; + before(() => { + return testUtils + .runGeneratorWith(generator, null, dir => { + tmpDir = dir; + testUtils.givenAnApplicationDir(dir); + }) + .withArguments('fooBar'); + }); + it('writes correct file name', () => { + assert.file(tmpDir + withInputName); + assert.noFile(tmpDir + templateName); + }); + it('scaffolds correct files', () => { + checkBasicContents(tmpDir); + }); }); }); - describe('with arg', () => { - let tmpDir; - before(() => { - return helpers - .run(generator) - .inTmpDir(dir => { - tmpDir = dir; - fs.writeFileSync( - path.join(tmpDir, 'package.json'), - JSON.stringify({ - keywords: ['loopback'], - }) - ); - }) - .withArguments('fooBar'); + + describe('REST CRUD', () => { + const baseInput = { + name: 'fooBar', + controllerType: ControllerGenerator.REST, + }; + it('creates REST CRUD template with valid input', () => { + let tmpDir; + return testUtils + .runGeneratorWith( + generator, + Object.assign( + { + modelName: 'Foo', + repositoryName: 'BarRepository', + id: 'number', + }, + baseInput + ), + dir => { + tmpDir = dir; + testUtils.givenAnApplicationDir(tmpDir); + fs.writeFileSync( + testUtils.givenAModelPath(tmpDir, 'foo.model.ts'), + '--DUMMY VALUE--' + ); + fs.writeFileSync( + testUtils.givenARepositoryPath(tmpDir, 'bar.repository.ts'), + '--DUMMY VALUE--' + ); + } + ) + .then(() => { + return checkRestCrudContents(tmpDir); + }); + }); + + it('fails when no model is given', () => { + return noModelGiven(baseInput).catch(err => { + expect(err.message).to.match(/No models found in /); + }); }); - it('writes correct file name', () => { - assert.file(tmpDir + withInputName); - assert.noFile(tmpDir + templateName); + + it('fails when no repository is given', () => { + return noRepositoryGiven(baseInput).catch(err => { + expect(err.message).to.match(/No repositories found in /); + }); }); - it('scaffolds correct files', () => { - assert.fileContent(tmpDir + withInputName, /class FooBarController/); - assert.fileContent(tmpDir + withInputName, /constructor\(\) {}/); + + it('fails when no model directory present', () => { + return noModelFolder(baseInput).catch(err => { + expect(err.message).to.match( + /ENOENT: no such file or directory, scandir(.*?)models\b/ + ); + }); + }); + + it('fails when no repository directory present', () => { + return noRepositoryFolder(baseInput).catch(err => { + expect(err.message).to.match( + /ENOENT: no such file or directory, scandir(.*?)repositories\b/ + ); + }); }); }); + + /** + * Helper function for testing behaviour without model input. + * @param {object} baseInput The base input for the controller type. + */ + function noModelGiven(baseInput) { + let tmpDir; + return testUtils.runGeneratorWith( + generator, + Object.assign( + { + repositoryName: 'BarRepository', + id: 'number', + }, + baseInput + ), + dir => { + testUtils.givenAnApplicationDir(dir); + fs.writeFileSync( + testUtils.givenARepositoryPath(dir, 'bar.repository.ts'), + '--DUMMY VALUE--' + ); + } + ); + } + /** + * Helper function for testing behaviour without repository input. + * @param {object} baseInput The base input for the controller type. + */ + function noRepositoryGiven(baseInput) { + let tmpDir; + return testUtils.runGeneratorWith( + generator, + Object.assign( + { + modelName: 'Foo', + id: 'number', + }, + baseInput + ), + dir => { + testUtils.givenAnApplicationDir(dir); + fs.writeFileSync( + testUtils.givenAModelPath(dir, 'foo.model.ts'), + '--DUMMY VALUE--' + ); + } + ); + } + + /** + * Helper function for testing behaviour without a model folder. + * @param {object} baseInput The base input for the controller type. + */ + function noModelFolder(baseInput) { + let tmpDir; + return testUtils.runGeneratorWith( + generator, + Object.assign( + { + modelName: 'Foo', + repositoryName: 'BarRepository', + id: 'number', + }, + baseInput + ), + dir => { + testUtils.givenAnApplicationDir(dir, {omitModelDir: true}); + fs.writeFileSync( + testUtils.givenARepositoryPath(dir, 'bar.repository.ts'), + '--DUMMY VALUE--' + ); + } + ); + } + + /** + * Helper function for testing behaviour without a repository folder. + * @param {object} baseInput The base input for the controller type. + */ + function noRepositoryFolder(baseInput) { + let tmpDir; + return testUtils.runGeneratorWith( + generator, + Object.assign( + { + modelName: 'Foo', + repositoryName: 'BarRepository', + id: 'number', + }, + baseInput + ), + dir => { + testUtils.givenAnApplicationDir(dir, {omitRepositoryDir: true}); + fs.writeFileSync( + testUtils.givenAModelPath(dir, 'foo.model.ts'), + '--DUMMY VALUE--' + ); + } + ); + } + + function checkBasicContents(tmpDir) { + assert.fileContent(tmpDir + withInputName, /class FooBarController/); + assert.fileContent(tmpDir + withInputName, /constructor\(\) {}/); + } + + /** + * Assertions against the template to determine if it contains the + * required signatures for a REST CRUD controller, specifically to ensure + * that decorators are grouped correctly (for their corresponding + * target functions) + * + * This function calls checkCrudContents. + * @param {String} tmpDir + */ + function checkRestCrudContents(tmpDir) { + assert.fileContent(tmpDir + withInputName, /class FooBarController/); + + // Repository and injection + assert.fileContent( + tmpDir + withInputName, + /\@inject\('repositories.BarRepository'\)/ + ); + assert.fileContent( + tmpDir + withInputName, + /barRepository \: BarRepository/ + ); + + // Assert that the decorators are present in the correct groupings! + assert.fileContent( + tmpDir + withInputName, + /\@post\('\/foo'\)\s{1,}async create\(\@param.body\('obj'\)/ + ); + + assert.fileContent( + tmpDir + withInputName, + /\@get\('\/foo\/count'\)\s{1,}async count\(\@param.query.string\('where'\)/ + ); + + assert.fileContent( + tmpDir + withInputName, + /\@get\('\/foo'\)\s{1,}async find\(\@param.query.string\('filter'\)/ + ); + assert.fileContent( + tmpDir + withInputName, + /\@patch\('\/foo'\)\s{1,}async updateAll\(\@param.query.string\('where'\) where: Where,\s{1,}\@param.body\('obj'\)/ + ); + assert.fileContent( + tmpDir + withInputName, + /\@del\('\/foo'\)\s{1,}async deleteAll\(\@param.query.string\('where'\)/ + ); + assert.fileContent( + tmpDir + withInputName, + /\@get\('\/foo\/{id}'\)\s{1,}async findById\(\@param.path.number\('id'\)/ + ); + assert.fileContent( + tmpDir + withInputName, + /\@patch\('\/foo\/{id}'\)\s{1,}async updateById\(\@param.path.number\('id'\) id: number, \@param.body\('obj'\)/ + ); + assert.fileContent( + tmpDir + withInputName, + /\@del\('\/foo\/{id}'\)\s{1,}async deleteById\(\@param.path.number\('id'\) id: number\)/ + ); + } }); diff --git a/packages/cli/test/test-utils.js b/packages/cli/test/test-utils.js index 5ba386ada440..42c43d19c060 100644 --- a/packages/cli/test/test-utils.js +++ b/packages/cli/test/test-utils.js @@ -5,9 +5,13 @@ 'use strict'; +const _ = require('lodash'); const yeoman = require('yeoman-environment'); const path = require('path'); const helpers = require('yeoman-test'); +const fs = require('fs'); +const util = require('util'); +const RunContext = require('yeoman-test/lib/run-context'); exports.testSetUpGen = function(genName, arg) { arg = arg || {}; @@ -44,3 +48,98 @@ exports.executeGenerator = function(GeneratorOrNamespace, settings) { return runner; }; + +/** + * Helper function for running the generator with custom inputs, artifacts, + * and prompts. + * @param {Generator} generator The generator to run. + * @param {Object} prompts The prompts object to use with the generator under + * test. + * @param {Function} createArtifacts The create artifacts function. Takes + * a directory. Use it to create folders and files for your tests. + */ +exports.runGeneratorWith = function runGeneratorWith( + generator, + prompts, + createArtifacts +) { + return exports + .executeGenerator(generator) + .inTmpDir(dir => { + createArtifacts(dir); + }) + .withPrompts(prompts); +}; +/** + * Setup the target directory with the required package.json and folder + * structure. + * @param {string} tmpDir Path to the temporary directory to setup + * @param {object} options + * @property {boolean} omitModelDir Do not create "models" directory + * @property {boolean} omitRepositoryDir Do not create "repositories" directory + * @property {boolean} omitControllerDir Do not create "controllers" directory + */ +exports.givenAnApplicationDir = function(tmpDir, options) { + options = options || {}; + const srcDir = path.join(tmpDir, 'src'); + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ + keywords: ['loopback'], + }) + ); + fs.mkdirSync(srcDir); + if (!options.omitModelDir) { + fs.mkdirSync(path.join(srcDir, 'models')); + } + if (!options.omitRepositoryDir) { + fs.mkdirSync(path.join(srcDir, 'repositories')); + } + if (!options.omitControllerDir) { + fs.mkdirSync(path.join(srcDir, 'controllers')); + } +}; + +/** + * Return the default path for the specified repository and temp directory. + * @param {string=} tmpDir The temporary directory path. If omitted, the + * returned path will be relative (prefixed with either "/" or "\", + * @param {string} fileName The repository name. + */ +exports.givenARepositoryPath = function(tmpDir, fileName) { + return exports.givenAnArtifactPath(tmpDir, 'repositories', fileName); +}; + +/** + * Return the default path for the specified model and temp directory. + * @param {string=} tmpDir The temporary directory path. If omitted, the + * returned path will be relative (prefixed with either "/" or "\", + * depending on OS). + * @param {string} fileName The model name. + */ +exports.givenAModelPath = function(tmpDir, fileName) { + return exports.givenAnArtifactPath(tmpDir, 'models', fileName); +}; + +/** + * Return the default path for the specified controller and temp directory. + * @param {string=} tmpDir The temporary directory path. If omitted, the + * returned path will be relative (prefixed with either "/" or "\", + * depending on OS). + * @param {string} fileName The controller name. + */ +exports.givenAControllerPath = function(tmpDir, fileName) { + return exports.givenAnArtifactPath(tmpDir, 'controllers', fileName); +}; + +/** + * @param {string=} tmpDir The temporary directory path. If omitted, the + * returned path will be relative (prefixed with either "/" or "\", + * depending on OS). + * @param {string} artifactDir The artifact directory name. + * @param {string} fileName The artifact fileName. + */ +exports.givenAnArtifactPath = function(tmpDir, artifactDir, fileName) { + if (!tmpDir) tmpDir = path.sep; // To allow use for relative pathing. + return path.join(tmpDir, 'src', artifactDir, fileName); +}; diff --git a/packages/cli/test/utils.js b/packages/cli/test/utils.js index 2ab920f2f1ae..802555322721 100644 --- a/packages/cli/test/utils.js +++ b/packages/cli/test/utils.js @@ -1,5 +1,6 @@ require('mocha'); const expect = require('@loopback/testlab').expect; +const path = require('path'); const utils = require('../lib/utils'); describe('Utils', () => { @@ -116,4 +117,108 @@ describe('Utils', () => { }); } }); + describe('findArtifactPaths', () => { + it('returns all matching paths of type', () => { + const expected = [ + path.join('tmp', 'app', 'models', 'foo.model.js'), + path.join('tmp', 'app', 'models', 'baz.model.js'), + ]; + + const reader = () => { + // Add the expected values to some extras. + return [ + path.join('tmp', 'app', 'models', 'bar.js'), + path.join('tmp', 'app', 'models', 'README.js'), + ].concat(expected); + }; + + return utils + .findArtifactPaths(path.join('fake', 'path'), 'model', reader) + .then(results => { + expect(results).to.eql(expected); + }); + }); + }); + describe('getArtifactList', () => { + const expectedModels = ['Foo', 'Bar']; + const expectedRepos = ['FooRepository', 'BarRepository']; + it('finds JS models', () => { + const files = [ + path.join('tmp', 'app', 'foo.model.js'), + path.join('tmp', 'app', 'bar.model.js'), + path.join('tmp', 'app', 'README.md'), + ]; + return verifyArtifactList('model', 'models', false, files).then( + results => { + expect(results).to.eql(expectedModels); + } + ); + }); + + it('finds TS models', () => { + const files = [ + path.join('tmp', 'app', 'foo.model.ts'), + path.join('tmp', 'app', 'bar.model.ts'), + path.join('tmp', 'app', 'README.md'), + ]; + return verifyArtifactList('model', 'models', false, files).then( + results => { + expect(results).to.eql(expectedModels); + } + ); + }); + + it('finds JS repositories', () => { + const files = [ + path.join('tmp', 'app', 'foo.repository.js'), + path.join('tmp', 'app', 'bar.repository.js'), + path.join('tmp', 'app', 'foo.model.js'), + ]; + return verifyArtifactList( + 'repository', + 'repositories', + true, + files, + expectedRepos + ).then(results => { + expect(results).to.eql(expectedRepos); + }); + }); + + it('finds TS repositories', () => { + const files = [ + path.join('tmp', 'app', 'foo.repository.ts'), + path.join('tmp', 'app', 'bar.repository.ts'), + path.join('tmp', 'app', 'foo.model.ts'), + ]; + return verifyArtifactList( + 'repository', + 'repositories', + true, + files, + expectedRepos + ).then(results => { + expect(results).to.eql(expectedRepos); + }); + }); + + /** + * Testing function for evaluating the lists returned from + * the getArtifactList function. + * + * @param {string} artifactType The artifact type under test + * @param {string} folder The name of the folder (usually the plural of the + * artifactType) + * @param {boolean} suffix Whether or not to expect the artifactType as the suffix + * (ex. the "foo" repository is FooRepository) + * @param {string[]} files An array of fake filepaths to test with + */ + function verifyArtifactList(artifactType, folder, suffix, files) { + const reader = () => { + return files; + }; + const artifactPath = path.join('tmp', 'app', folder); + return utils.getArtifactList(artifactPath, artifactType, suffix, reader); + } + }); }); diff --git a/packages/core/package.json b/packages/core/package.json index 393718664526..9f45cb617201 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -25,7 +25,8 @@ "dependencies": { "@loopback/context": "^4.0.0-alpha.25", "lodash": "^4.17.4", - "topo": "^3.0.0" + "topo": "^3.0.0", + "util.promisify": "^1.0.0" }, "devDependencies": { "@loopback/build": "^4.0.0-alpha.8", diff --git a/packages/core/src/promisify.ts b/packages/core/src/promisify.ts index d64984548379..a600f88559e0 100644 --- a/packages/core/src/promisify.ts +++ b/packages/core/src/promisify.ts @@ -9,6 +9,9 @@ // tslint:disable:no-any import * as util from 'util'; +// The @types/util.promisify conflicts with @types/node due to rescoping +// issues, so falling back to legacy import. +const promisifyPolyfill = require('util.promisify/implementation'); const nativePromisify = (util as any).promisify; @@ -31,17 +34,7 @@ export function promisify( ): (...args: any[]) => Promise { if (nativePromisify) return nativePromisify(func); - // The simplest implementation of Promisify - return (...args) => { - return new Promise((resolve, reject) => { - try { - func(...args, (err?: any, result?: any) => { - if (err) reject(err); - else resolve(result); - }); - } catch (err) { - reject(err); - } - }); - }; + // TODO(kjdelisle): Once Node 6 has been dropped, we can remove this + // compatibility support. + return promisifyPolyfill(func); }