From c73799109db7c6f031fa47cceedcb07e7c204cce Mon Sep 17 00:00:00 2001 From: Taranveer Virk Date: Wed, 13 Jun 2018 19:15:03 -0400 Subject: [PATCH 1/5] feat(cli): add lb4 datasource command Signed-off-by: Taranveer Virk --- .gitignore | 1 + packages/cli/README.md | 22 +- packages/cli/bin/cli.js | 4 + packages/cli/bin/download-connector-list.js | 64 ++++ packages/cli/docs.json | 1 + packages/cli/generators/datasource/index.js | 306 ++++++++++++++++++ .../datasource/templates/datasource.ts.ejs | 14 + packages/cli/lib/utils.js | 29 ++ packages/cli/package.json | 2 + .../test/integration/cli/cli.integration.js | 4 +- .../generators/controller.integration.js | 5 +- .../generators/datasource.integration.js | 186 +++++++++++ packages/cli/test/unit/utils.unit.js | 72 +++++ 13 files changed, 702 insertions(+), 8 deletions(-) create mode 100644 packages/cli/bin/download-connector-list.js create mode 100644 packages/cli/generators/datasource/index.js create mode 100644 packages/cli/generators/datasource/templates/datasource.ts.ejs create mode 100644 packages/cli/test/integration/generators/datasource.integration.js diff --git a/.gitignore b/.gitignore index 839d3b7c3d99..19c989169a06 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ packages/*/dist* examples/*/dist* **/package .sandbox +packages/cli/generators/datasource/connectors.json diff --git a/packages/cli/README.md b/packages/cli/README.md index 299dbfc3de7d..5acdea6d9d3b 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -76,7 +76,23 @@ Arguments: name # Name for the controller Type: String Required: false ``` -4. To download one of LoopBack example projects +4. To scaffold a DataSource into your application + + `lb4 datasource` + +```sh +Usage: + lb4 datasource [options] [] + +Options: + -h, --help # Print the generator's options and usage + --connector # Name of datasource connector + +Arguments: + name # Name for the datasource Type: String Required: true +``` + +5. To download one of LoopBack example projects `lb4 example` @@ -90,7 +106,7 @@ Options: --skip-install # Do not automatically install dependencies Default: false ``` -5. To list available commands +6. To list available commands `lb4 --commands` (or `lb4 -l`) @@ -104,7 +120,7 @@ Available commands: Please note `lb4 --help` also prints out available commands. -6. To print out version information +7. To print out version information `lb4 --version` (or `lb4 -v`) diff --git a/packages/cli/bin/cli.js b/packages/cli/bin/cli.js index 41fabeafbff6..edda9021bf26 100755 --- a/packages/cli/bin/cli.js +++ b/packages/cli/bin/cli.js @@ -61,6 +61,10 @@ function setupGenerators() { path.join(__dirname, '../generators/controller'), PREFIX + 'controller', ); + env.register( + path.join(__dirname, '../generators/datasource'), + PREFIX + 'datasource', + ); env.register( path.join(__dirname, '../generators/example'), PREFIX + 'example', diff --git a/packages/cli/bin/download-connector-list.js b/packages/cli/bin/download-connector-list.js new file mode 100644 index 000000000000..35115aa9f7bf --- /dev/null +++ b/packages/cli/bin/download-connector-list.js @@ -0,0 +1,64 @@ +const https = require('https'); +const fs = require('fs'); +const path = require('path'); +const util = require('util'); + +const readFileAsync = util.promisify(fs.readFile); +const writeFileAsync = util.promisify(fs.writeFile); + +const DEST = path.resolve('generators/datasource/connectors.json'); +const URL = + 'https://raw.githubusercontent.com/strongloop/loopback-workspace/master/available-connectors.json'; + +/** + * Function to dowload the list of available connectors from loopback-workspace + * so the list only has to be maintained in one place. + */ +async function download() { + var file = fs.createWriteStream(DEST); + var request = https + .get(URL, function(response) { + response.pipe(file); + file.on('finish', async function() { + file.close(); + await transformConnectorJSON(); + }); + }) + .on('error', function(err) { + fs.unlink(DEST); + return err; + }); +} + +/** + * This function transforms the array of Connector objects from + * loopback-workspace as follows: + * + * - Transforms the array into an object / map + * - Transforms display:password to type:password so it can be used by CLI directly + * - Transforms description to message so it can be used by CLI directly + */ +async function transformConnectorJSON() { + let data = await readFileAsync(DEST, 'utf-8'); + data = JSON.parse(data); + const out = {}; + data.forEach(item => { + if (item.settings) { + Object.entries(item.settings).forEach(([key, value]) => { + if (value.display === 'password') { + value.type = 'password'; + delete value.display; + } + + if (value.description) { + value.message = value.description; + delete value.description; + } + }); + } + out[item.name] = item; + }); + await writeFileAsync(DEST, JSON.stringify(out, null, 2)); +} + +download(); diff --git a/packages/cli/docs.json b/packages/cli/docs.json index 1211ab7810dc..c5d42107c85b 100644 --- a/packages/cli/docs.json +++ b/packages/cli/docs.json @@ -5,6 +5,7 @@ "lib/base-generator.js", "lib/debug.js", "lib/project-generator.js", + "lib/update-index.js", "lib/utils.js" ], "codeSectionDepth": 4 diff --git a/packages/cli/generators/datasource/index.js b/packages/cli/generators/datasource/index.js new file mode 100644 index 000000000000..b5d81e8ee3a9 --- /dev/null +++ b/packages/cli/generators/datasource/index.js @@ -0,0 +1,306 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/cli +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +'use strict'; + +const ArtifactGenerator = require('../../lib/artifact-generator'); +const debug = require('../../lib/debug')('datasource-generator'); +const chalk = require('chalk'); +const path = require('path'); +const utils = require('../../lib/utils'); +const connectors = require('./connectors.json'); + +/** + * DataSource Generator -- CLI + * + * Prompts for a name, connector and connector options. Creates json file + * for the DataSource as well as a Class for a user to modify. Also installs the + * appropriate connector from npm. + */ +module.exports = class DataSourceGenerator extends ArtifactGenerator { + constructor(args, opts) { + super(args, opts); + } + + _setupGenerator() { + this.artifactInfo = { + type: 'datasource', + rootDir: 'src', + }; + + // Datasources are stored in the datasources directory + this.artifactInfo.outDir = path.resolve( + this.artifactInfo.rootDir, + 'datasources', + ); + + const connectorChoices = []; + /** + * Creating a list of connectors -- and marking them as either supported by + * StrongLoop or community. + */ + Object.values(connectors).forEach(connector => { + const support = connector.supportedByStrongLoop + ? '(supported by StrongLoop)' + : '(provided by community)'; + connectorChoices.push({ + name: `${connector.description} ${chalk.gray(support)}`, + value: connector.name, + }); + }); + + this.connectorChoices = connectorChoices; + // Add `other` so users can add a connector that isn't part of the list + // Though it can be added by creating a PR and adding it to + // connectors.json + this.connectorChoices.push('other'); + + return super._setupGenerator(); + } + + /** + * Ensure CLI is being run in a LoopBack 4 project. + */ + checkLoopBackProject() { + return super.checkLoopBackProject(); + } + + /** + * Ask for DataSource Name -- Must be unique + */ + promptArtifactName() { + debug('Prompting for artifact name'); + if (this.shouldExit()) return false; + const prompts = [ + { + type: 'input', + name: 'name', + // capitalization + message: utils.toClassName(this.artifactInfo.type) + ' name:', + when: this.artifactInfo.name === undefined, + validate: utils.validateClassName, + }, + ]; + + return this.prompt(prompts).then(props => { + Object.assign(this.artifactInfo, props); + return props; + }); + } + + /** + * Ask the user to select the connector for the DataSource + */ + promptConnector() { + debug('prompting for datasource connector'); + const prompts = [ + { + name: 'connector', + message: `Select the connector for ${chalk.yellow( + this.artifactInfo.name, + )}:`, + type: 'list', + default: 'memory', + choices: this.connectorChoices, + when: this.artifactInfo.connector === undefined, + }, + ]; + + return this.prompt(prompts).then(props => { + Object.assign(this.artifactInfo, props); + return props; + }); + } + + /** + * If the user selected `other` for connector -- ask the user to provide + * `npm` module name for the connector. + */ + promptCustomConnectorInfo() { + if (this.artifactInfo.connector !== 'other') { + debug('custom connector option was not selected'); + return; + } else { + debug('prompting for custom connector'); + const prompts = [ + { + name: 'customConnector', + message: "Enter the connector's module name", + validate: utils.validate, + }, + ]; + + return this.prompt(prompts).then(props => { + this.artifactInfo.connector = props.customConnector; + return props; + }); + } + } + + /** + * Prompt the user for connector specific settings -- only applies to + * connectors in the connectors.json list + */ + promptConnectorConfig() { + debug('prompting for connector config'); + // Check to make sure connector is from connectors list (not custom) + const settings = connectors[this.artifactInfo.connector] + ? connectors[this.artifactInfo.connector]['settings'] + : {}; + + const prompts = []; + // Create list of questions to prompt the user + Object.entries(settings).forEach(([key, setting]) => { + // Set defaults and merge with `setting` to override properties + const question = Object.assign( + {}, + {name: key, message: key, suffix: ':'}, + setting, + ); + + /** + * Allowed Types: string, number, password, object, array, boolean + * Must be converted to inquirer types -- input, confirm, password + */ + switch ((setting.type || '').toLowerCase()) { + case 'string': + case 'number': + question.type = 'input'; + break; + case 'object': + case 'array': + question.type = 'input'; + question.validate = utils.validateStringObject(setting.type); + break; + case 'boolean': + question.type = 'confirm'; + break; + case 'password': + break; + default: + console.warn( + `Using default input of type input for setting ${key} as ${setting.type || + undefined} is not supported`, + ); + // Default to input type + question.type = 'input'; + } + + prompts.push(question); + }); + + debug(`connector setting questions - ${JSON.stringify(prompts)}`); + + // If no prompts, we need to return instead of attempting to ask prompts + if (!prompts.length) return; + + debug('prompting the user - length > 0 for questions'); + // Ask user for prompts + return this.prompt(prompts).then(props => { + // Convert user inputs to correct types + Object.entries(settings).forEach(([key, setting]) => { + switch ((setting.type || '').toLowerCase()) { + case 'number': + props[key] = Number(props[key]); + break; + case 'array': + case 'object': + if (props[key] == null || props[key] === '') { + delete props[key]; + } else { + props[key] = JSON.parse(props[key]); + } + break; + } + }); + this.artifactInfo = Object.assign(this.artifactInfo, {settings: props}); + }); + } + + install() { + debug('install npm dependencies'); + const pkgs = []; + + // Connector package. + const connector = connectors[this.artifactInfo.connector]; + if (connector && connector.package) { + pkgs.push( + connector.package.name + + `${connector.package.version ? '@' + connector.package.version : ''}`, + ); + + debug(`npmModule - ${pkgs[0]}`); + } + + pkgs.push('@loopback/repository'); + + this.npmInstall(pkgs, {save: true}); + } + + /** + * Scaffold DataSource related files + * super.scaffold() doesn't provide a way to rename files -- don't call it + */ + scaffold() { + // Exit if needed + if (this.shouldExit()) return false; + + // Setting up data for templates + this.artifactInfo.className = utils.toClassName(this.artifactInfo.name); + this.artifactInfo.fileName = utils.kebabCase(this.artifactInfo.name); + // prettier-ignore + this.artifactInfo.jsonFileName = `${this.artifactInfo.fileName}.datasource.json`; + // prettier-ignore + this.artifactInfo.outFile = `${this.artifactInfo.fileName}.datasource.ts`; + + // Resolved Output Paths. + const jsonPath = this.destinationPath( + this.artifactInfo.outDir, + this.artifactInfo.jsonFileName, + ); + const tsPath = this.destinationPath( + this.artifactInfo.outDir, + this.artifactInfo.outFile, + ); + + // template path + const classTemplatePath = this.templatePath('datasource.ts.ejs'); + + // Debug Info + debug(`this.artifactInfo.name => ${this.artifactInfo.name}`); + debug(`this.artifactInfo.className => ${this.artifactInfo.className}`); + debug(`this.artifactInfo.fileName => ${this.artifactInfo.fileName}`); + // prettier-ignore + debug(`this.artifactInfo.jsonFileName => ${this.artifactInfo.jsonFileName}`); + debug(`this.artifactInfo.outFile => ${this.artifactInfo.outFile}`); + debug(`jsonPath => ${jsonPath}`); + debug(`tsPath => ${tsPath}`); + + // Data to save to DataSource JSON file + const ds = Object.assign( + {name: this.artifactInfo.name, connector: this.artifactInfo.connector}, + this.artifactInfo.settings, + ); + + // From LB3 + if (ds.connector === 'ibm-object-storage') { + ds.connector = 'loopback-component-storage'; + ds.provider = 'openstack'; + ds.useServiceCatalog = true; + ds.useInternal = false; + ds.keystoneAuthVersion = 'v3'; + } + + debug(`datasource information going to file: ${JSON.stringify(ds)}`); + + // Copy Templates + this.fs.writeJSON(jsonPath, ds); + this.fs.copyTpl(classTemplatePath, tsPath, this.artifactInfo); + } + + async end() { + await super.end(); + } +}; diff --git a/packages/cli/generators/datasource/templates/datasource.ts.ejs b/packages/cli/generators/datasource/templates/datasource.ts.ejs new file mode 100644 index 000000000000..48956864fd84 --- /dev/null +++ b/packages/cli/generators/datasource/templates/datasource.ts.ejs @@ -0,0 +1,14 @@ +import {inject} from '@loopback/core'; +import {juggler, DataSource, AnyObject} from '@loopback/repository'; +const config = require('./<%= jsonFileName %>'); + +export class <%= className %>DataSource extends juggler.DataSource { + static dataSourceName = '<%= name %>'; + + constructor( + @inject('datasources.config.<%= name %>', {optional: true}) + dsConfig: AnyObject = config + ) { + super(dsConfig); + } +} diff --git a/packages/cli/lib/utils.js b/packages/cli/lib/utils.js index 5a7149f55611..84cfdb9d8e7f 100644 --- a/packages/cli/lib/utils.js +++ b/packages/cli/lib/utils.js @@ -266,3 +266,32 @@ exports.renameEJS = function() { return renameStream; }; + +/** + * Get a validate function for object/array type + * @param {String} type 'object' OR 'array' + */ +exports.validateStringObject = function(type) { + return function validate(val) { + if (val === null || val === '') { + return true; + } + + const err = `The value must be a stringified ${type}`; + + if (typeof val !== 'string') { + return err; + } + + try { + var result = JSON.parse(val); + if (type === 'array' && !Array.isArray(result)) { + return err; + } + } catch (e) { + return err; + } + + return true; + }; +}; diff --git a/packages/cli/package.json b/packages/cli/package.json index 6cc734dcec21..52f115f440bb 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -55,6 +55,8 @@ }, "scripts": { "prepublishOnly": "nsp check", + "build": "node ./bin/download-connector-list.js", + "build:current": "npm run build", "test": "lb-mocha \"test/**/*.js\"" }, "repository": { diff --git a/packages/cli/test/integration/cli/cli.integration.js b/packages/cli/test/integration/cli/cli.integration.js index fc47fa340de8..9d4e7794e145 100644 --- a/packages/cli/test/integration/cli/cli.integration.js +++ b/packages/cli/test/integration/cli/cli.integration.js @@ -23,7 +23,7 @@ describe('cli', () => { main({commands: true}, getLog(entries)); expect(entries).to.eql([ 'Available commands: ', - ' lb4 app\n lb4 extension\n lb4 controller\n lb4 example', + ' lb4 app\n lb4 extension\n lb4 controller\n lb4 datasource\n lb4 example', ]); }); @@ -40,7 +40,7 @@ describe('cli', () => { main({help: true, _: []}, getLog(entries), true); expect(entries).to.containEql('Available commands: '); expect(entries).to.containEql( - ' lb4 app\n lb4 extension\n lb4 controller\n lb4 example', + ' lb4 app\n lb4 extension\n lb4 controller\n lb4 datasource\n lb4 example', ); }); diff --git a/packages/cli/test/integration/generators/controller.integration.js b/packages/cli/test/integration/generators/controller.integration.js index 2868648978ac..b8f68bd9b05d 100644 --- a/packages/cli/test/integration/generators/controller.integration.js +++ b/packages/cli/test/integration/generators/controller.integration.js @@ -6,11 +6,9 @@ 'use strict'; const path = require('path'); -const util = require('util'); - const assert = require('yeoman-assert'); - const testlab = require('@loopback/testlab'); + const expect = testlab.expect; const TestSandbox = testlab.TestSandbox; @@ -41,6 +39,7 @@ const expectedFile = path.join( '/src/controllers/product-review.controller.ts', ); +// Base Tests describe('controller-generator extending BaseGenerator', baseTests); describe('generator-loopback4:controller', tests); diff --git a/packages/cli/test/integration/generators/datasource.integration.js b/packages/cli/test/integration/generators/datasource.integration.js new file mode 100644 index 000000000000..1f1ba6ccfda2 --- /dev/null +++ b/packages/cli/test/integration/generators/datasource.integration.js @@ -0,0 +1,186 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/cli +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +'use strict'; + +// Imports +const path = require('path'); +const assert = require('yeoman-assert'); +const testlab = require('@loopback/testlab'); + +const expect = testlab.expect; +const TestSandbox = testlab.TestSandbox; + +const debug = require('../../../lib/debug')('lb4:datasource:test'); +const DataSourceGenerator = require('../../../generators/datasource'); +const generator = path.join(__dirname, '../../../generators/datasource'); +const tests = require('../lib/artifact-generator')(generator); +const baseTests = require('../lib/base-generator')(generator); +const testUtils = require('../../test-utils'); + +// Test Sandbox +const SANDBOX_PATH = path.resolve(__dirname, '..', '.sandbox'); +const sandbox = new TestSandbox(SANDBOX_PATH); + +// CLI Inputs +const basicCLIInput = { + name: 'ds', +}; + +const cloudantCLIInput = { + name: 'ds', + connector: 'cloudant', + url: 'http://user:pass@host.com', + username: 'user', + password: 'pass', +}; + +const numberCLIInput = { + name: 'ds', + connector: 'db2', + port: '100', +}; + +const complexCLIInput = { + name: 'ds', + connector: 'rest', + options: '{"test": "value"}', + operations: '["get", "post"]', +}; + +const expectedComplexJSONOutput = { + name: 'ds', + connector: 'rest', + options: {test: 'value'}, + operations: ['get', 'post'], +}; + +// Expected File Name +const expectedTSFile = path.join( + SANDBOX_PATH, + 'src/datasources/ds.datasource.ts', +); + +const expectedJSONFile = path.join( + SANDBOX_PATH, + 'src/datasources/ds.datasource.json', +); + +const expectedIndexFile = path.join(SANDBOX_PATH, 'src/datasources/index.ts'); + +// Base Tests +describe('datasource-generator extending BaseGenerator', baseTests); +describe('generator-loopback4:datasource', tests); + +describe('lb4 datasource integration', () => { + beforeEach('reset sandbox', () => sandbox.reset()); + + it('does not run without package.json', () => { + return expect( + testUtils + .executeGenerator(generator) + .inDir(SANDBOX_PATH, () => + testUtils.givenLBProject(SANDBOX_PATH, {excludePackageJSON: true}), + ) + .withPrompts(basicCLIInput), + ).to.be.rejectedWith(/No package.json found in/); + }); + + it('does not run without the loopback keyword', () => { + return expect( + testUtils + .executeGenerator(generator) + .inDir(SANDBOX_PATH, () => + testUtils.givenLBProject(SANDBOX_PATH, {excludeKeyword: true}), + ) + .withPrompts(basicCLIInput), + ).to.be.rejectedWith(/No `loopback` keyword found in/); + }); + + describe('basic datasource', () => { + it('scaffolds correct file with input', async () => { + await testUtils + .executeGenerator(generator) + .inDir(SANDBOX_PATH, () => testUtils.givenLBProject(SANDBOX_PATH)) + .withPrompts(basicCLIInput); + + checkBasicDataSourceFiles(); + assert.jsonFileContent(expectedJSONFile, basicCLIInput); + }); + + it('scaffolds correct file with args', async () => { + await testUtils + .executeGenerator(generator) + .inDir(SANDBOX_PATH, () => testUtils.givenLBProject(SANDBOX_PATH)) + .withArguments('ds'); + + checkBasicDataSourceFiles(); + assert.jsonFileContent(expectedJSONFile, basicCLIInput); + }); + }); + + it('scaffolds correct file with cloudant input', async () => { + await testUtils + .executeGenerator(generator) + .inDir(SANDBOX_PATH, () => testUtils.givenLBProject(SANDBOX_PATH)) + .withPrompts(cloudantCLIInput); + + checkBasicDataSourceFiles(); + assert.jsonFileContent(expectedJSONFile, cloudantCLIInput); + }); + + it('correctly coerces setting input of type number', async () => { + await testUtils + .executeGenerator(generator) + .inDir(SANDBOX_PATH, () => testUtils.givenLBProject(SANDBOX_PATH)) + .withPrompts(numberCLIInput); + + checkBasicDataSourceFiles(); + assert.jsonFileContent( + expectedJSONFile, + Object.assign({}, numberCLIInput, {port: 100}), + ); + }); + + it('correctly coerces setting input of type object and array', async () => { + await testUtils + .executeGenerator(generator) + .inDir(SANDBOX_PATH, () => testUtils.givenLBProject(SANDBOX_PATH)) + .withPrompts(complexCLIInput); + + checkBasicDataSourceFiles(); + assert.jsonFileContent(expectedJSONFile, expectedComplexJSONOutput); + }); +}); + +function checkBasicDataSourceFiles() { + assert.file(expectedTSFile); + assert.file(expectedJSONFile); + assert.file(expectedIndexFile); + + assert.fileContent(expectedTSFile, /import {inject} from '@loopback\/core';/); + assert.fileContent( + expectedTSFile, + /import {juggler, DataSource, AnyObject} from '@loopback\/repository';/, + ); + assert.fileContent( + expectedTSFile, + /const config = require\('.\/ds.datasource.json'\)/, + ); + assert.fileContent( + expectedTSFile, + /export class DsDataSource extends juggler.DataSource {/, + ); + assert.fileContent(expectedTSFile, /static dataSourceName = 'ds';/); + assert.fileContent(expectedTSFile, /constructor\(/); + assert.fileContent( + expectedTSFile, + /\@inject\('datasources.config.ds', \{optional: true\}\)/, + ); + assert.fileContent(expectedTSFile, /\) \{/); + assert.fileContent(expectedTSFile, /super\(dsConfig\);/); + + assert.fileContent(expectedIndexFile, /export \* from '.\/ds.datasource';/); +} diff --git a/packages/cli/test/unit/utils.unit.js b/packages/cli/test/unit/utils.unit.js index 7fd5ead4fb2e..4dc1eefc39ff 100644 --- a/packages/cli/test/unit/utils.unit.js +++ b/packages/cli/test/unit/utils.unit.js @@ -300,4 +300,76 @@ describe('Utils', () => { return utils.getArtifactList(artifactPath, artifactType, suffix, reader); } }); + + describe('validateStringObject', () => { + it('returns true for an object check with a null value', () => { + expect(utils.validateStringObject('object')(null)).to.be.True(); + }); + + it('returns true for an array check with a null value', () => { + expect(utils.validateStringObject('array')(null)).to.be.True(); + }); + + it('returns string for an object check with an empty string', () => { + expect(utils.validateStringObject('object')('')).to.be.True(); + }); + + it('returns string for an array check with an empty string', () => { + expect(utils.validateStringObject('array')('')).to.be.True(); + }); + + it('returns true for an object check with undefined', () => { + expect(utils.validateStringObject('object')(undefined)).to.be.eql( + 'The value must be a stringified object', + ); + }); + + it('returns true for an array check with undefined', () => { + expect(utils.validateStringObject('array')(undefined)).to.be.eql( + 'The value must be a stringified array', + ); + }); + + it('returns string for an object check with a number', () => { + expect(utils.validateStringObject('object')(123)).to.be.eql( + 'The value must be a stringified object', + ); + }); + + it('returns string for an array check with a number', () => { + expect(utils.validateStringObject('array')(123)).to.be.eql( + 'The value must be a stringified array', + ); + }); + + it('returns string for an object check with an object', () => { + expect(utils.validateStringObject('object')({})).to.be.eql( + 'The value must be a stringified object', + ); + }); + + it('returns string for an array check with a number', () => { + expect(utils.validateStringObject('array')({})).to.be.eql( + 'The value must be a stringified array', + ); + }); + + it('returns true for an object check with an object', () => { + expect(utils.validateStringObject('object')('{}')).to.be.True(); + }); + + it('returns string for an array check with an object', () => { + expect(utils.validateStringObject('array')('{}')).to.be.eql( + 'The value must be a stringified array', + ); + }); + + it('returns string for an object check with an array', () => { + expect(utils.validateStringObject('object')('[1, 2, 3]')).to.be.True(); + }); + + it('returns true for an array check with an array', () => { + expect(utils.validateStringObject('array')('[1, 2, 3]')).to.be.True(); + }); + }); }); From 1d7b543ba9b543d988c2517cfa46385c98387ad4 Mon Sep 17 00:00:00 2001 From: Taranveer Virk Date: Wed, 13 Jun 2018 19:15:42 -0400 Subject: [PATCH 2/5] fix(repository): accept class and instance for app.datasource --- .../repository/src/mixins/repository.mixin.ts | 35 +++++++++-- .../test/unit/mixins/repository.mixin.unit.ts | 61 ++++++++++++++----- 2 files changed, 76 insertions(+), 20 deletions(-) diff --git a/packages/repository/src/mixins/repository.mixin.ts b/packages/repository/src/mixins/repository.mixin.ts index 6552644c3a03..70e853bdcb45 100644 --- a/packages/repository/src/mixins/repository.mixin.ts +++ b/packages/repository/src/mixins/repository.mixin.ts @@ -7,6 +7,7 @@ import {Class} from '../common-types'; import {Repository} from '../repositories/repository'; import {juggler} from '../repositories/legacy-juggler-bridge'; import {Application} from '@loopback/core'; +import {BindingScope} from '@loopback/context'; /** * A mixin class for Application that creates a .repository() @@ -97,9 +98,27 @@ export function RepositoryMixin>(superClass: T) { * } * ``` */ - dataSource(dataSource: juggler.DataSource, name?: string) { - const dataSourceKey = `datasources.${name || dataSource.name}`; - this.bind(dataSourceKey).to(dataSource); + dataSource( + dataSource: Class | juggler.DataSource, + name?: string, + ) { + // We have an instance of + if (dataSource instanceof juggler.DataSource) { + const key = `datasources.${name || dataSource.name}`; + this.bind(key) + .to(dataSource) + .tag('datasource'); + } else if (typeof dataSource === 'function') { + const key = `datasources.${name || + dataSource.dataSourceName || + dataSource.name}`; + this.bind(key) + .toClass(dataSource) + .tag('datasource') + .inScope(BindingScope.SINGLETON); + } else { + throw new Error('not a valid DataSource.'); + } } /** @@ -155,7 +174,10 @@ export interface AppWithRepository extends Application { repository(repo: Class): void; // tslint:disable-next-line:no-any getRepository>(repo: Class): Promise; - dataSource(dataSource: juggler.DataSource, name?: string): void; + dataSource( + dataSource: Class | juggler.DataSource, + name?: string, + ): void; component(component: Class<{}>): void; mountComponentRepository(component: Class<{}>): void; } @@ -236,7 +258,10 @@ export class RepositoryMixinDoc { * } * ``` */ - dataSource(dataSource: juggler.DataSource, name?: string) {} + dataSource( + dataSource: Class | juggler.DataSource, + name?: string, + ) {} /** * Add a component to this application. Also mounts diff --git a/packages/repository/test/unit/mixins/repository.mixin.unit.ts b/packages/repository/test/unit/mixins/repository.mixin.unit.ts index cafb18d34af1..124fd8f4841a 100644 --- a/packages/repository/test/unit/mixins/repository.mixin.unit.ts +++ b/packages/repository/test/unit/mixins/repository.mixin.unit.ts @@ -141,35 +141,66 @@ describe('RepositoryMixin dataSource', () => { expect(withoutDataSource).to.be.empty(); }); - it('binds dataSource to a binding key using the dataSource name property', () => { + it('binds dataSource class using the dataSourceName property', () => { const myApp = new AppWithRepoMixin(); - const fooDataSource: juggler.DataSource = new juggler.DataSource({ - name: 'foo', - connector: 'memory', - }); - myApp.dataSource(fooDataSource); - expectDataSourceToBeBound(myApp, fooDataSource, 'foo'); + + myApp.dataSource(FooDataSource); + expectDataSourceToBeBound(myApp, FooDataSource, 'foo'); + }); + + it('binds dataSource class using the given name', () => { + const myApp = new AppWithRepoMixin(); + myApp.dataSource(FooDataSource, 'bar'); + expectDataSourceToBeBound(myApp, FooDataSource, 'bar'); }); - it('binds dataSource to a binding key using the given name', () => { + it('binds dataSource class using Class name', () => { const myApp = new AppWithRepoMixin(); - const barDataSource: juggler.DataSource = new juggler.DataSource({ - connector: 'memory', - }); - myApp.dataSource(barDataSource, 'bar'); - expectDataSourceToBeBound(myApp, barDataSource, 'bar'); + myApp.dataSource(BarDataSource); + expectDataSourceToBeBound(myApp, BarDataSource, 'BarDataSource'); + }); + + it('binds dataSource class instance using dataSourceName property', () => { + const myApp = new AppWithRepoMixin(); + myApp.dataSource(new FooDataSource()); + expectDataSourceToBeBound(myApp, FooDataSource, 'foo'); + }); + + it('binds dataSource class instance using custom name', () => { + const myApp = new AppWithRepoMixin(); + myApp.dataSource(new FooDataSource(), 'bar'); + expectDataSourceToBeBound(myApp, FooDataSource, 'bar'); }); const expectDataSourceToBeBound = ( app: AppWithRepoMixin, - ds: juggler.DataSource, + ds: Class, name: string, ) => { expect(app.find('datasources.*').map(d => d.key)).to.containEql( `datasources.${name}`, ); - expect(app.getSync(`datasources.${name}`)).to.be.eql(ds); + expect(app.getSync(`datasources.${name}`)).to.be.instanceOf(ds); }; class AppWithRepoMixin extends RepositoryMixin(Application) {} + + class FooDataSource extends juggler.DataSource { + static dataSourceName = 'foo'; + constructor() { + super({ + name: 'foo', + connector: 'memory', + }); + } + } + + class BarDataSource extends juggler.DataSource { + constructor() { + super({ + name: 'foo', + connector: 'memory', + }); + } + } }); From d317d325cf09337be01c7facfa23bb61aeed927d Mon Sep 17 00:00:00 2001 From: Taranveer Virk Date: Wed, 13 Jun 2018 19:16:29 -0400 Subject: [PATCH 3/5] feat(boot): datasource booter --- packages/boot/README.md | 36 +++++-- packages/boot/docs.json | 2 + packages/boot/src/boot.component.ts | 4 +- .../boot/src/booters/datasource.booter.ts | 70 +++++++++++++ packages/boot/src/booters/index.ts | 1 + .../boot/test/fixtures/datasource.artifact.ts | 14 +++ .../datasource.booter.integration.ts | 41 ++++++++ .../boot/test/unit/boot.component.unit.ts | 27 +++++- .../unit/booters/datasource.booter.unit.ts | 97 +++++++++++++++++++ 9 files changed, 281 insertions(+), 11 deletions(-) create mode 100644 packages/boot/src/booters/datasource.booter.ts create mode 100644 packages/boot/test/fixtures/datasource.artifact.ts create mode 100644 packages/boot/test/integration/datasource.booter.integration.ts create mode 100644 packages/boot/test/unit/booters/datasource.booter.unit.ts diff --git a/packages/boot/README.md b/packages/boot/README.md index 17b2f7c69d2b..9b2f35255f4d 100644 --- a/packages/boot/README.md +++ b/packages/boot/README.md @@ -47,6 +47,7 @@ List of Options available on BootOptions Object. | -------------- | ----------------- | ----------------------------------- | | `controllers` | `ArtifactOptions` | ControllerBooter convention options | | `repositories` | `ArtifactOptions` | RepositoryBooter convention options | +| `datasources` | `ArtifactOptions` | DataSourceBooter convention options | ### ArtifactOptions @@ -98,12 +99,12 @@ Discovers and binds Controller Classes using `app.controller()`. #### Options -The Options for this can be passed via `BootOptions` when calling -`app.boot(options:BootOptions)`. +The options for this can be passed via `BootOptions` when calling +`app.boot(options: BootOptions)`. The options for this are passed in a `controllers` object on `BootOptions`. -Available Options on the `controllers` object on `BootOptions` are as follows: +Available options on the `controllers` object on `BootOptions` are as follows: | Options | Type | Default | Description | | ------------ | -------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------- | @@ -121,12 +122,12 @@ must use `RepositoryMixin` from `@loopback/repository`). #### Options -The Options for this can be passed via `BootOptions` when calling -`app.boot(options:BootOptions)`. +The options for this can be passed via `BootOptions` when calling +`app.boot(options: BootOptions)`. The options for this are passed in a `repositories` object on `BootOptions`. -Available Options on the `repositories` object on `BootOptions` are as follows: +Available options on the `repositories` object on `BootOptions` are as follows: | Options | Type | Default | Description | | ------------ | -------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------ | @@ -135,6 +136,29 @@ Available Options on the `repositories` object on `BootOptions` are as follows: | `nested` | `boolean` | `true` | Look in nested directories in `dirs` for Repository artifacts | | `glob` | `string` | | A `glob` pattern string. This takes precedence over above 3 options (which are used to make a glob pattern). | +### DataSourceBooter + +#### Description + +Discovers and binds DataSource Classes using `app.dataSource()` (Application +must use `RepositoryMixin` from `@loopback/repository`). + +#### Options + +The options for this can be passed via `BootOptions` when calling +`app.boot(options: BootOptions)`. + +The options for this are passed in a `datasources` object on `BootOptions`. + +Available options on the `datasources` object on `BootOptions` are as follows: + +| Options | Type | Default | Description | +| ------------ | -------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------ | +| `dirs` | `string \| string[]` | `['datasources']` | Paths relative to projectRoot to look in for DataSource artifacts | +| `extensions` | `string \| string[]` | `['.datasource.js']` | File extensions to match for DataSource artifacts | +| `nested` | `boolean` | `true` | Look in nested directories in `dirs` for DataSource artifacts | +| `glob` | `string` | | A `glob` pattern string. This takes precedence over above 3 options (which are used to make a glob pattern). | + ## Contributions - [Guidelines](https://github.com/strongloop/loopback-next/blob/master/docs/CONTRIBUTING.md) diff --git a/packages/boot/docs.json b/packages/boot/docs.json index 37fecc48bbae..c2180c24f099 100644 --- a/packages/boot/docs.json +++ b/packages/boot/docs.json @@ -4,9 +4,11 @@ "src/booters/base-artifact.booter.ts", "src/booters/booter-utils.ts", "src/booters/controller.booter.ts", + "src/booters/datasource.booter.ts", "src/booters/repository.booter.ts", "src/booters/index.ts", "src/mixins/boot.mixin.ts", + "src/mixins/index.ts", "src/boot.component.ts", "src/bootstrapper.ts", "src/index.ts", diff --git a/packages/boot/src/boot.component.ts b/packages/boot/src/boot.component.ts index bafc0a6fca25..e13430864d3b 100644 --- a/packages/boot/src/boot.component.ts +++ b/packages/boot/src/boot.component.ts @@ -6,7 +6,7 @@ import {Bootstrapper} from './bootstrapper'; import {Component, Application, CoreBindings} from '@loopback/core'; import {inject, BindingScope} from '@loopback/context'; -import {ControllerBooter, RepositoryBooter} from './booters'; +import {ControllerBooter, RepositoryBooter, DataSourceBooter} from './booters'; import {BootBindings} from './keys'; /** @@ -17,7 +17,7 @@ import {BootBindings} from './keys'; export class BootComponent implements Component { // Export a list of default booters in the component so they get bound // automatically when this component is mounted. - booters = [ControllerBooter, RepositoryBooter]; + booters = [ControllerBooter, RepositoryBooter, DataSourceBooter]; /** * diff --git a/packages/boot/src/booters/datasource.booter.ts b/packages/boot/src/booters/datasource.booter.ts new file mode 100644 index 000000000000..556f64ca2c4d --- /dev/null +++ b/packages/boot/src/booters/datasource.booter.ts @@ -0,0 +1,70 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/boot +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {CoreBindings} from '@loopback/core'; +import {AppWithRepository, juggler, Class} from '@loopback/repository'; +import {inject} from '@loopback/context'; +import {ArtifactOptions} from '../interfaces'; +import {BaseArtifactBooter} from './base-artifact.booter'; +import {BootBindings} from '../keys'; + +/** + * A class that extends BaseArtifactBooter to boot the 'DataSource' artifact type. + * Discovered DataSources are bound using `app.controller()`. + * + * Supported phases: configure, discover, load + * + * @param app Application instance + * @param projectRoot Root of User Project relative to which all paths are resolved + * @param [bootConfig] DataSource Artifact Options Object + */ +export class DataSourceBooter extends BaseArtifactBooter { + constructor( + @inject(CoreBindings.APPLICATION_INSTANCE) public app: AppWithRepository, + @inject(BootBindings.PROJECT_ROOT) public projectRoot: string, + @inject(`${BootBindings.BOOT_OPTIONS}#datasources`) + public datasourceConfig: ArtifactOptions = {}, + ) { + super(); + // Set DataSource Booter Options if passed in via bootConfig + this.options = Object.assign({}, DataSourceDefaults, datasourceConfig); + } + + /** + * Uses super method to get a list of Artifact classes. Boot each file by + * creating a DataSourceConstructor and binding it to the application class. + */ + async load() { + await super.load(); + + /** + * If Repository Classes were discovered, we need to make sure RepositoryMixin + * was used (so we have `app.repository()`) to perform the binding of a + * Repository Class. + */ + if (this.classes.length > 0) { + if (!this.app.dataSource) { + console.warn( + 'app.dataSource() function is needed for DataSourceBooter. You can add ' + + 'it to your Application using RepositoryMixin from @loopback/repository.', + ); + } else { + this.classes.forEach(cls => { + // tslint:disable-next-line:no-any + this.app.dataSource(cls as Class); + }); + } + } + } +} + +/** + * Default ArtifactOptions for DataSourceBooter. + */ +export const DataSourceDefaults: ArtifactOptions = { + dirs: ['datasources'], + extensions: ['.datasource.js'], + nested: true, +}; diff --git a/packages/boot/src/booters/index.ts b/packages/boot/src/booters/index.ts index 6b87feae0e1c..f31c44a32175 100644 --- a/packages/boot/src/booters/index.ts +++ b/packages/boot/src/booters/index.ts @@ -7,3 +7,4 @@ export * from './base-artifact.booter'; export * from './booter-utils'; export * from './controller.booter'; export * from './repository.booter'; +export * from './datasource.booter'; diff --git a/packages/boot/test/fixtures/datasource.artifact.ts b/packages/boot/test/fixtures/datasource.artifact.ts new file mode 100644 index 000000000000..d1052925d6bc --- /dev/null +++ b/packages/boot/test/fixtures/datasource.artifact.ts @@ -0,0 +1,14 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/boot +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {juggler} from '@loopback/repository'; + +export class DbDataSource extends juggler.DataSource { + static dataSourceName = 'db'; + + constructor() { + super({name: 'db'}); + } +} diff --git a/packages/boot/test/integration/datasource.booter.integration.ts b/packages/boot/test/integration/datasource.booter.integration.ts new file mode 100644 index 000000000000..caeb42331f8f --- /dev/null +++ b/packages/boot/test/integration/datasource.booter.integration.ts @@ -0,0 +1,41 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/boot +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect, TestSandbox} from '@loopback/testlab'; +import {resolve} from 'path'; +import {BooterApp} from '../fixtures/application'; + +describe('datasource booter integration tests', () => { + const SANDBOX_PATH = resolve(__dirname, '../../.sandbox'); + const sandbox = new TestSandbox(SANDBOX_PATH); + + const DATASOURCES_PREFIX = 'datasources'; + const DATASOURCES_TAG = 'datasource'; + + let app: BooterApp; + + beforeEach('reset sandbox', () => sandbox.reset()); + beforeEach(getApp); + + it('boots datasources when app.boot() is called', async () => { + const expectedBindings = [`${DATASOURCES_PREFIX}.db`]; + + await app.boot(); + + const bindings = app.findByTag(DATASOURCES_TAG).map(b => b.key); + expect(bindings.sort()).to.eql(expectedBindings.sort()); + }); + + async function getApp() { + await sandbox.copyFile(resolve(__dirname, '../fixtures/application.js')); + await sandbox.copyFile( + resolve(__dirname, '../fixtures/datasource.artifact.js'), + 'datasources/db.datasource.js', + ); + + const MyApp = require(resolve(SANDBOX_PATH, 'application.js')).BooterApp; + app = new MyApp(); + } +}); diff --git a/packages/boot/test/unit/boot.component.unit.ts b/packages/boot/test/unit/boot.component.unit.ts index bf79b780e35d..ae24503c5751 100644 --- a/packages/boot/test/unit/boot.component.unit.ts +++ b/packages/boot/test/unit/boot.component.unit.ts @@ -5,7 +5,14 @@ import {expect} from '@loopback/testlab'; import {Application} from '@loopback/core'; -import {BootBindings, Bootstrapper, ControllerBooter, BootMixin} from '../../'; +import { + BootBindings, + Bootstrapper, + ControllerBooter, + BootMixin, + RepositoryBooter, + DataSourceBooter, +} from '../../'; describe('boot.component unit tests', () => { class BootableApp extends BootMixin(Application) {} @@ -20,10 +27,24 @@ describe('boot.component unit tests', () => { }); it('ControllerBooter is bound as a booter by default', async () => { - const ctrlBooter = await app.get( + const booterInst = await app.get( `${BootBindings.BOOTER_PREFIX}.ControllerBooter`, ); - expect(ctrlBooter).to.be.an.instanceOf(ControllerBooter); + expect(booterInst).to.be.an.instanceOf(ControllerBooter); + }); + + it('RepositoryBooter is bound as a booter by default', async () => { + const booterInst = await app.get( + `${BootBindings.BOOTER_PREFIX}.RepositoryBooter`, + ); + expect(booterInst).to.be.an.instanceOf(RepositoryBooter); + }); + + it('DataSourceBooter is bound as a booter by default', async () => { + const booterInst = await app.get( + `${BootBindings.BOOTER_PREFIX}.DataSourceBooter`, + ); + expect(booterInst).to.be.an.instanceOf(DataSourceBooter); }); function getApp() { diff --git a/packages/boot/test/unit/booters/datasource.booter.unit.ts b/packages/boot/test/unit/booters/datasource.booter.unit.ts new file mode 100644 index 000000000000..4f0048dbfde9 --- /dev/null +++ b/packages/boot/test/unit/booters/datasource.booter.unit.ts @@ -0,0 +1,97 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/boot +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect, TestSandbox, sinon} from '@loopback/testlab'; +import {resolve} from 'path'; +import {AppWithRepository, RepositoryMixin} from '@loopback/repository'; +import {DataSourceBooter, DataSourceDefaults} from '../../../src'; +import {Application} from '@loopback/core'; + +describe('datasource booter unit tests', () => { + const SANDBOX_PATH = resolve(__dirname, '../../../.sandbox'); + const sandbox = new TestSandbox(SANDBOX_PATH); + + const DATASOURCES_PREFIX = 'datasources'; + const DATASOURCES_TAG = 'datasource'; + + class AppWithRepo extends RepositoryMixin(Application) {} + + let app: AppWithRepo; + let stub: sinon.SinonStub; + + beforeEach('reset sandbox', () => sandbox.reset()); + beforeEach(getApp); + beforeEach(createStub); + afterEach(restoreStub); + + it('gives a wanring if called on an app without RepositoryMixin', async () => { + const normalApp = new Application(); + await sandbox.copyFile( + resolve(__dirname, '../../fixtures/datasource.artifact.js'), + ); + + const booterInst = new DataSourceBooter( + normalApp as AppWithRepository, + SANDBOX_PATH, + ); + + booterInst.discovered = [resolve(SANDBOX_PATH, 'datasource.artifact.js')]; + await booterInst.load(); + + sinon.assert.calledOnce(stub); + sinon.assert.calledWith( + stub, + 'app.dataSource() function is needed for DataSourceBooter. You can add ' + + 'it to your Application using RepositoryMixin from @loopback/repository.', + ); + }); + + it(`uses DataSourceDefaults for 'options' if none are given`, () => { + const booterInst = new DataSourceBooter(app, SANDBOX_PATH); + expect(booterInst.options).to.deepEqual(DataSourceDefaults); + }); + + it('overrides defaults with provided options and uses defaults for the rest', () => { + const options = { + dirs: ['test'], + extensions: ['.ext1'], + }; + const expected = Object.assign({}, options, { + nested: DataSourceDefaults.nested, + }); + + const booterInst = new DataSourceBooter(app, SANDBOX_PATH, options); + expect(booterInst.options).to.deepEqual(expected); + }); + + it('binds datasources during the load phase', async () => { + const expected = [`${DATASOURCES_PREFIX}.db`]; + await sandbox.copyFile( + resolve(__dirname, '../../fixtures/datasource.artifact.js'), + ); + const booterInst = new DataSourceBooter(app, SANDBOX_PATH); + const NUM_CLASSES = 1; // 1 class in above file. + + booterInst.discovered = [resolve(SANDBOX_PATH, 'datasource.artifact.js')]; + await booterInst.load(); + + const datasources = app.findByTag(DATASOURCES_TAG); + const keys = datasources.map(binding => binding.key); + expect(keys).to.have.lengthOf(NUM_CLASSES); + expect(keys.sort()).to.eql(expected.sort()); + }); + + function getApp() { + app = new AppWithRepo(); + } + + function restoreStub() { + stub.restore(); + } + + function createStub() { + stub = sinon.stub(console, 'warn'); + } +}); From 3eba183fb523272485f0bc9bde7063d7d4230dc6 Mon Sep 17 00:00:00 2001 From: Taranveer Virk Date: Wed, 13 Jun 2018 19:16:56 -0400 Subject: [PATCH 4/5] refactor(example-todo): use datasource cli generated files and datasource booters --- examples/todo/src/application.ts | 13 --------- .../datasources/db.datasource.json} | 1 + .../todo/src/datasources/db.datasource.ts | 29 ++++++++----------- examples/todo/src/datasources/index.ts | 6 ++++ .../test/acceptance/application.acceptance.ts | 18 +++++++++--- 5 files changed, 33 insertions(+), 34 deletions(-) rename examples/todo/{config/datasources.json => src/datasources/db.datasource.json} (76%) create mode 100644 examples/todo/src/datasources/index.ts diff --git a/examples/todo/src/application.ts b/examples/todo/src/application.ts index 601a1ebb86d0..f7de4dd1192f 100644 --- a/examples/todo/src/application.ts +++ b/examples/todo/src/application.ts @@ -6,7 +6,6 @@ import {ApplicationConfig} from '@loopback/core'; import {RestApplication} from '@loopback/rest'; import {MySequence} from './sequence'; -import {db} from './datasources/db.datasource'; /* tslint:disable:no-unused-variable */ // Binding and Booter imports are required to infer types for BootMixin! @@ -40,17 +39,5 @@ export class TodoListApplication extends BootMixin( nested: true, }, }; - - this.setupDatasources(); - } - - setupDatasources() { - // This will allow you to test your application without needing to - // use a "real" datasource! - const datasource = - this.options && this.options.datasource - ? new juggler.DataSource(this.options.datasource) - : db; - this.dataSource(datasource); } } diff --git a/examples/todo/config/datasources.json b/examples/todo/src/datasources/db.datasource.json similarity index 76% rename from examples/todo/config/datasources.json rename to examples/todo/src/datasources/db.datasource.json index 1c62a6d15e0f..a68f220be986 100644 --- a/examples/todo/config/datasources.json +++ b/examples/todo/src/datasources/db.datasource.json @@ -1,5 +1,6 @@ { "name": "db", "connector": "memory", + "localStorage": "", "file": "./data/db.json" } diff --git a/examples/todo/src/datasources/db.datasource.ts b/examples/todo/src/datasources/db.datasource.ts index 85034f7b6f84..2e5d07a46140 100644 --- a/examples/todo/src/datasources/db.datasource.ts +++ b/examples/todo/src/datasources/db.datasource.ts @@ -3,22 +3,17 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import * as path from 'path'; -// The juggler reference must exist for consuming code to correctly infer -// type info used in the "db" export (contained in juggler.DataSource). -// tslint:disable-next-line:no-unused-variable -import {juggler} from '@loopback/repository'; +import {inject} from '@loopback/core'; +import {juggler, DataSource} from '@loopback/repository'; +const config = require('./db.datasource.json'); -const dsConfigPath = path.resolve( - __dirname, - '../../../config/datasources.json', -); -const config = require(dsConfigPath); +export class DbDataSource extends juggler.DataSource { + static dataSourceName = 'db'; -// TODO(bajtos) Ideally, datasources should be created by @loopback/boot -// and registered with the app for dependency injection. -// However, we need to investigate how to access these datasources from -// integration tests where we don't have access to the full app object. -// For example, @loopback/boot can provide a helper function for -// performing a partial boot that creates datasources only. -export const db = new juggler.DataSource(config); + constructor( + @inject('datasources.config.db', {optional: true}) + dsConfig: DataSource = config, + ) { + super(dsConfig); + } +} diff --git a/examples/todo/src/datasources/index.ts b/examples/todo/src/datasources/index.ts new file mode 100644 index 000000000000..425f38ab0545 --- /dev/null +++ b/examples/todo/src/datasources/index.ts @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/example-todo +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export * from './db.datasource'; diff --git a/examples/todo/test/acceptance/application.acceptance.ts b/examples/todo/test/acceptance/application.acceptance.ts index 0082b3682765..0757b020250f 100644 --- a/examples/todo/test/acceptance/application.acceptance.ts +++ b/examples/todo/test/acceptance/application.acceptance.ts @@ -19,6 +19,20 @@ describe('Application', () => { before(givenAnApplication); before(async () => { await app.boot(); + + /** + * Override DataSource to not write to file for testing. Since we aren't + * persisting data to file and each injection normally instatiates a new + * instance, we must change the BindingScope to a singleton so only one + * instance is created and used for all injections (preserving access to + * the same memory space). + */ + app.bind('datasources.config.db').to({ + name: 'db', + connector: 'memory', + }); + + // Start Application await app.start(); }); before(givenARestServer); @@ -109,10 +123,6 @@ describe('Application', () => { rest: { port: 0, }, - datasource: { - name: 'db', - connector: 'memory', - }, }); } From dc70c6c40b3ff0cbe575a3018ebe8224b70f64dc Mon Sep 17 00:00:00 2001 From: Taranveer Virk Date: Wed, 13 Jun 2018 19:17:30 -0400 Subject: [PATCH 5/5] docs(docs): update docs with datasource cli and booter content --- docs/site/Booting-an-Application.md | 18 +++++++ docs/site/Concepts.md | 3 ++ docs/site/Controller-generator.md | 6 +-- docs/site/DataSource-generator.md | 60 +++++++++++++++++++++ docs/site/DataSources.md | 46 ++++++++++++++++ docs/site/Glossary.md | 3 +- docs/site/sidebars/lb4_sidebar.yml | 8 +++ docs/site/tables/lb4-artifact-commands.html | 17 +++--- docs/site/todo-tutorial-datasource.md | 48 ++++++----------- 9 files changed, 165 insertions(+), 44 deletions(-) create mode 100644 docs/site/DataSource-generator.md create mode 100644 docs/site/DataSources.md diff --git a/docs/site/Booting-an-Application.md b/docs/site/Booting-an-Application.md index 549d5eee8e97..b7cbccc8057a 100644 --- a/docs/site/Booting-an-Application.md +++ b/docs/site/Booting-an-Application.md @@ -226,6 +226,24 @@ The `repositories` object supports the following options: | `nested` | `boolean` | `true` | Look in nested directories in `dirs` for Repository artifacts | | `glob` | `string` | | A `glob` pattern string. This takes precendence over above 3 options (which are used to make a glob pattern). | +### DataSource Booter + +This Booter's purpose is to discover [DataSource](DataSource.md) type Artifacts +and to bind them to the Application's Context. The use of this Booter requires +`RepositoryMixin` from `@loopback/repository` to be mixed into your Application +class. + +You can configure the conventions used in your project for a DataSource by +passing a `datasources` object on `BootOptions` property of your Application. +The `datasources` object support the following options: + +| Options | Type | Default | Description | +| ------------ | -------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------- | +| `dirs` | `string \| string[]` | `['datasources']` | Paths relative to projectRoot to look in for DataSource artifacts | +| `extensions` | `string \| string[]` | `['.datasource.js']` | File extensions to match for DataSource artifacts | +| `nested` | `boolean` | `true` | Look in nested directories in `dirs` for DataSource artifacts | +| `glob` | `string` | | A `glob` pattern string. This takes precendence over above 3 options (which are used to make a glob pattern). | + ### Custom Booters A custom Booter can be written as a Class that implements the `Booter` diff --git a/docs/site/Concepts.md b/docs/site/Concepts.md index 02ec5406b021..aca6ae516588 100644 --- a/docs/site/Concepts.md +++ b/docs/site/Concepts.md @@ -49,6 +49,9 @@ LoopBack 4 introduces some new concepts that are important to understand: `@loopback/repository-json-schema` module uses the decorators' metadata to build a matching JSON Schema. +- [**DataSources**](DataSources.md): A named configuration for a Connector + instance that represents data in an external system. + - [**Repository**](Repositories.md): A type of service that represents a collection of data within a DataSource. diff --git a/docs/site/Controller-generator.md b/docs/site/Controller-generator.md index 9f102fb4153d..689e2f1704bd 100644 --- a/docs/site/Controller-generator.md +++ b/docs/site/Controller-generator.md @@ -37,10 +37,10 @@ the name. The tool will prompt you for: -- Name of the controller. If the name had been supplied from the command line, - the prompt is skipped and the controller is built with the name from the +- **Name of the controller.** If the name had been supplied from the command + line, the prompt is skipped and the controller is built with the name from the command-line argument. -- Type of the controller. You can select from the following types: +- **Type of the controller.** You can select from the following types: - **Empty Controller** - An empty controller definition - **REST Controller with CRUD Methods** - A controller wired up to a model and repository definition, with pre-defined CRUD methods. diff --git a/docs/site/DataSource-generator.md b/docs/site/DataSource-generator.md new file mode 100644 index 000000000000..5c814864c5b5 --- /dev/null +++ b/docs/site/DataSource-generator.md @@ -0,0 +1,60 @@ +--- +lang: en +title: 'DataSource generator' +keywords: LoopBack 4.0, LoopBack 4 +tags: +sidebar: lb4_sidebar +permalink: /doc/en/lb4/DataSource-generator.html +summary: +--- + +{% include content/generator-create-app.html lang=page.lang %} + +### Synopsis + +Adds new [DataSource](Datasources.md) class and config files to a LoopBack +application. + +```sh +lb4 datasource [options] [] +``` + +### Options + +`--connector` : Name of datasource connector + +This can be a connector supported by LoopBack / Community / Custom. + +{% include_relative includes/CLI-std-options.md %} + +### Arguments + +`` - Required name of the datasource to create as an argiment to the +command. If provided, the tool will use that as the default when it prompts for +the name. + +### Interactive Prompts + +The tool will prompt you for: + +- **Name of the datasource.** _(dataSourceName)_ If the name had been supplied + from the command line, the prompt is skipped and the datasource is built with + the name from the command-line argument. +- **Name of connector.** If not supplied via command line, you will be presented + with a list of connector to select from (including an `other` option for + custom connector). +- **Connector configuration.** If the connector is not a custom connector, the + CLI will prompt for the connector configuration information. + +### Output + +Once all the prompts have been answered, the CLI will do the following: + +- Install `@loopback/repository` and the connector package (if it's not a custom + connector). +- Create a file with the connector configuration as follows: + `/datasources/${dataSource.dataSourceName}.datasource.json` +- Create a DataSource class which recieves the connector config using + [Dependency Injection](Dependency-injection.md) as follows: + `/datasources/${dataSource.dataSourceName}.datasource.ts` +- Update `index.ts` to export the newly created DataSource class. diff --git a/docs/site/DataSources.md b/docs/site/DataSources.md new file mode 100644 index 000000000000..ca6f6f21c885 --- /dev/null +++ b/docs/site/DataSources.md @@ -0,0 +1,46 @@ +--- +lang: en +title: 'DataSources' +keywords: LoopBack 4.0, LoopBack 4 +tags: +sidebar: lb4_sidebar +permalink: /doc/en/lb4/DataSources.html +summary: +--- + +## Overview + +A `DataSource` in LoopBack 4 is a named configuration for a Connector instance +that represents data in an external system. The Connector is used by +`legacy-juggler-bridge` to power LoopBack 4 Repositories for Data operations. + +### Creating a DataSource + +It is recommended to use the [`lb4 datasource` command](DataSource-generator.md) +provided by the CLI to generate a DataSource. The CLI will prompt for all +necessary connector information and create the following files: + +- `${dataSource.dataSourceName}.datasource.json` containing the connector + configuration +- `${dataSource.dataSourceName}.datasource.ts` containing a class extending + `juggler.DataSource`. This class can be used to override the default + DataSource behaviour programaticaly. Note: The connector configuration stored + in the `.json` file is injected into this class using + [Dependency Injection](Dependency-injection.md). + +Both the above files are generated in `src/datasources/` directory by the CLI. +It will also update `src/datasources/index.ts` to export the new DataSource +class. + +Example DataSource Class: + +```ts +import {inject} from '@loopback/core'; +import {juggler, DataSource} from '@loopback/repository'; + +export class DbDataSource extends juggler.DataSource { + constructor(@inject('datasources.config.db') dsConfig: DataSource) { + super(dsConfig); + } +} +``` diff --git a/docs/site/Glossary.md b/docs/site/Glossary.md index 94ad6439ba2c..60069e394b44 100644 --- a/docs/site/Glossary.md +++ b/docs/site/Glossary.md @@ -23,7 +23,8 @@ values for writing web applications and APIs. For more information, see **Controller**: The implementation of API endpoints. **DataSource**: A named configuration for a Connector instance that represents -data in an external system. +data in an external system. For more information, see +[DataSource](DataSource.md). **Element:** The building blocks of a Sequence, such as route, params, and result. For more information, see [Sequence](Sequence.md#elements). diff --git a/docs/site/sidebars/lb4_sidebar.yml b/docs/site/sidebars/lb4_sidebar.yml index d8a9aa9920df..3d5c2ee827b6 100644 --- a/docs/site/sidebars/lb4_sidebar.yml +++ b/docs/site/sidebars/lb4_sidebar.yml @@ -78,6 +78,10 @@ children: url: Controllers.html output: 'web, pdf' + - title: 'DataSources' + url: DataSources.html + output: 'web, pdf' + - title: 'Routes' url: Routes.html output: 'web, pdf' @@ -127,6 +131,10 @@ children: url: Controller-generator.html output: 'web, pdf' + - title: 'DataSource generator' + url: DataSource-generator.html + output: 'web, pdf' + - title: 'Extension generator' url: Extension-generator.html output: 'web, pdf' diff --git a/docs/site/tables/lb4-artifact-commands.html b/docs/site/tables/lb4-artifact-commands.html index 69c1b351a637..b1a7a03edb7a 100644 --- a/docs/site/tables/lb4-artifact-commands.html +++ b/docs/site/tables/lb4-artifact-commands.html @@ -12,15 +12,16 @@ - - lb4 controller - - Add a new controller to a LoopBack 4 application - - - Controller generator - + lb4 controller + Add a new controller to a LoopBack 4 application + Controller generator + + lb4 datasource + Add a new datasource to a LoopBack 4 application + DataSource generator + + diff --git a/docs/site/todo-tutorial-datasource.md b/docs/site/todo-tutorial-datasource.md index 595ac49fd3b8..728ee81e8951 100644 --- a/docs/site/todo-tutorial-datasource.md +++ b/docs/site/todo-tutorial-datasource.md @@ -22,38 +22,22 @@ create a datasource definition to make this possible. ### Building a Datasource -Create a new folder in the root directory of the project called `config`, and -then inside that folder, create a `datasources.json` file. For the purposes of -this tutorial, we'll be using the memory connector provided with the Juggler. - -#### config/datasources.json - -```json -{ - "name": "db", - "connector": "memory", - "file": "./data/db.json" -} -``` - -Inside the `src/datasources` directory create a new file called -`db.datasource.ts`. This file will create a strongly-typed export of our -datasource using the `juggler.DataSource`, which we can consume in our -application via injection. - -#### src/datasources/db.datasource.ts - -```ts -import * as path from 'path'; -import {juggler} from '@loopback/repository'; - -const dsConfigPath = path.resolve( - __dirname, - '../../../config/datasources.json', -); -const config = require(dsConfigPath); - -export const db = new juggler.DataSource(config); +From inside the project folder, we'll run the `lb4 datasource` command to create +a DataSource. For the purposes of this tutorial, we'll be using the memory +connector provided with the Juggler. + +```sh +lb4 datasource +? Datasource name: db +? Select the connector for db: In-memory db (supported by StrongLoop) +? window.localStorage key to use for persistence (browser only): +? Full path to file for persistence (server only): ./data/db.json + + create src/datasources/db.datasource.json + create src/datasources/db.datasource.ts + update src/datasources/index.ts + +Datasource db is now created in src/datasources/ ``` Create a `data` folder in the applications root and add a new file called