diff --git a/examples/todo/package-lock.json b/examples/todo/package-lock.json index 3e37e6d74d96..5c55429c1840 100644 --- a/examples/todo/package-lock.json +++ b/examples/todo/package-lock.json @@ -190,6 +190,14 @@ "integrity": "sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg==", "dev": true }, + "async": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.3.tgz", + "integrity": "sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==", + "requires": { + "lodash": "^4.17.14" + } + }, "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -223,6 +231,25 @@ "tweetnacl": "^0.14.3" } }, + "bignumber.js": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-7.2.1.tgz", + "integrity": "sha512-S4XzBk5sMB+Rcb/LNcpzXr57VRTxgAvaAEDAl1AwRx27j00hT84O6OkteE7u8UB3NuaaygCRrEpqox4uDOrbdQ==" + }, + "bl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-2.2.0.tgz", + "integrity": "sha512-wbgvOpqopSr7uq6fJrLH8EsvYMJf9gzfo2jCsL2eTy75qXPukA4pCgHamOQkZtY5vmfVtjB+P3LNlMHW5CEZXA==", + "requires": { + "readable-stream": "^2.3.5", + "safe-buffer": "^5.1.1" + } + }, + "bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==" + }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -871,6 +898,11 @@ "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + }, "isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -968,6 +1000,97 @@ "integrity": "sha1-vyJJiGzlFM2hEvrpIYzcBlIR/Jw=", "dev": true }, + "loopback-connector": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/loopback-connector/-/loopback-connector-4.9.0.tgz", + "integrity": "sha512-WtHPxItqNLOxuonsOthuOIZrkd9kW+t0HtMweU0pjtEquCvKJAV8s9xqaNs8q8kmbv6rgmYEtmzxkRUsRcV+xQ==", + "requires": { + "async": "^3.1.0", + "bluebird": "^3.4.6", + "debug": "^4.1.1", + "msgpack5": "^4.2.0", + "strong-globalize": "^5.0.0", + "uuid": "^3.0.1" + }, + "dependencies": { + "async": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/async/-/async-3.1.0.tgz", + "integrity": "sha512-4vx/aaY6j/j3Lw3fbCHNWP0pPaTCew3F6F3hYyl/tHs/ndmV1q7NW9T5yuJ2XAGwdQrP+6Wu20x06U4APo/iQQ==" + }, + "invert-kv": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-3.0.0.tgz", + "integrity": "sha512-JzF8q2BeZA1ZkE3XROwRpoMQ9ObMgTtp0JH8EXewlbkikuOj2GPLIpUipdO+VL8QsTr2teAJD02EFGGL5cO7uw==" + }, + "lcid": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lcid/-/lcid-3.1.1.tgz", + "integrity": "sha512-M6T051+5QCGLBQb8id3hdvIW8+zeFV2FyBGFS9IEK5H9Wt4MueD4bW1eWikpHgZp+5xR3l5c8pZUkQsIA0BFZg==", + "requires": { + "invert-kv": "^3.0.0" + } + }, + "mem": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/mem/-/mem-5.1.1.tgz", + "integrity": "sha512-qvwipnozMohxLXG1pOqoLiZKNkC4r4qqRucSoDwXowsNGDSULiqFTRUF05vcZWnwJSG22qTsynQhxbaMtnX9gw==", + "requires": { + "map-age-cleaner": "^0.1.3", + "mimic-fn": "^2.1.0", + "p-is-promise": "^2.1.0" + } + }, + "os-locale": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-4.0.0.tgz", + "integrity": "sha512-HsSR1+2l6as4Wp2SGZxqLnuFHxVvh1Ir9pvZxyujsC13egZVe7P0YeBLN0ijQzM/twrO5To3ia3jzBXAvpMTEA==", + "requires": { + "execa": "^1.0.0", + "lcid": "^3.0.0", + "mem": "^5.0.0" + } + }, + "strong-globalize": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/strong-globalize/-/strong-globalize-5.0.2.tgz", + "integrity": "sha512-Y5iuJLEcjI5iUusOvPlen0Q6ej11jUFMzJF4er9gMGy1+yVv/i7v2TuFvD/8lhMAPrE++RRawNQZMpJYsdju/Q==", + "requires": { + "accept-language": "^3.0.18", + "debug": "^4.1.1", + "globalize": "^1.4.2", + "lodash": "^4.17.15", + "md5": "^2.2.1", + "mkdirp": "^0.5.1", + "os-locale": "^4.0.0", + "yamljs": "^0.3.0" + } + } + } + }, + "loopback-connector-mysql": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/loopback-connector-mysql/-/loopback-connector-mysql-5.4.2.tgz", + "integrity": "sha512-f5iIIcJdfUuBUkScGcK7m4dLZnpjFjl1iFG5OHTk8pFwDq7+Xap/0H99ulueRp2ljfqbULTUvt3Rg1y/W5smtw==", + "requires": { + "async": "^2.6.1", + "debug": "^3.1.0", + "lodash": "^4.17.11", + "loopback-connector": "^4.0.0", + "mysql": "^2.11.1", + "strong-globalize": "^4.1.1" + }, + "dependencies": { + "debug": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", + "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", + "requires": { + "ms": "^2.1.1" + } + } + } + }, "loopback-connector-rest": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/loopback-connector-rest/-/loopback-connector-rest-3.6.0.tgz", @@ -1066,12 +1189,41 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, + "msgpack5": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/msgpack5/-/msgpack5-4.2.1.tgz", + "integrity": "sha512-Xo7nE9ZfBVonQi1rSopNAqPdts/QHyuSEUwIEzAkB+V2FtmkkLUbP6MyVqVVQxsZYI65FpvW3Bb8Z9ZWEjbgHQ==", + "requires": { + "bl": "^2.0.1", + "inherits": "^2.0.3", + "readable-stream": "^2.3.6", + "safe-buffer": "^5.1.2" + } + }, "mute-stream": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", "dev": true }, + "mysql": { + "version": "2.17.1", + "resolved": "https://registry.npmjs.org/mysql/-/mysql-2.17.1.tgz", + "integrity": "sha512-7vMqHQ673SAk5C8fOzTG2LpPcf3bNt0oL3sFpxPEEFp1mdlDcrLK0On7z8ZYKaaHrHwNcQ/MTUz7/oobZ2OyyA==", + "requires": { + "bignumber.js": "7.2.1", + "readable-stream": "2.3.6", + "safe-buffer": "5.1.2", + "sqlstring": "2.3.1" + }, + "dependencies": { + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + } + } + }, "natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -1188,6 +1340,11 @@ "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=", "dev": true }, + "process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, "progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -1224,6 +1381,27 @@ "integrity": "sha512-hLWjpy7EnsDBb0p+Z3B7rPi3GDeRG5ZtiI33kJhTt+ORCd38AbAIjB/9zRIUoeTbE/AVX5ZkU7m6bznsvrf8eQ==", "dev": true }, + "readable-stream": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + }, + "dependencies": { + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + } + } + }, "regexpp": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.0.0.tgz", @@ -1364,6 +1542,11 @@ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=" }, + "sqlstring": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.1.tgz", + "integrity": "sha1-R1OT/56RR5rqYtyvDKPRSYOn+0A=" + }, "sshpk": { "version": "1.16.1", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz", @@ -1407,6 +1590,21 @@ } } }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "requires": { + "safe-buffer": "~5.1.0" + }, + "dependencies": { + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + } + } + }, "strip-ansi": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", @@ -1595,6 +1793,11 @@ "punycode": "^2.1.0" } }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" + }, "uuid": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.3.tgz", diff --git a/examples/todo/package.json b/examples/todo/package.json index cb5150dea6f1..47fc3d692f4b 100644 --- a/examples/todo/package.json +++ b/examples/todo/package.json @@ -42,8 +42,10 @@ "@loopback/openapi-v3": "^1.10.2", "@loopback/repository": "^1.15.5", "@loopback/rest": "^1.24.0", + "@loopback/rest-crud": "^0.6.1", "@loopback/rest-explorer": "^1.4.5", "@loopback/service-proxy": "^1.3.12", + "loopback-connector-mysql": "^5.4.2", "loopback-connector-rest": "^3.6.0" }, "devDependencies": { diff --git a/examples/todo/src/controllers/index.ts b/examples/todo/src/controllers/index.ts index 72f6ff506fbc..d25d9c712643 100644 --- a/examples/todo/src/controllers/index.ts +++ b/examples/todo/src/controllers/index.ts @@ -4,3 +4,4 @@ // License text available at https://opensource.org/licenses/MIT export * from './todo.controller'; +export * from './model-admin.controller'; diff --git a/examples/todo/src/controllers/model-admin.controller.ts b/examples/todo/src/controllers/model-admin.controller.ts new file mode 100644 index 000000000000..21b6fbf82c2a --- /dev/null +++ b/examples/todo/src/controllers/model-admin.controller.ts @@ -0,0 +1,221 @@ +// Copyright IBM Corp. 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 + +import {CoreBindings, inject} from '@loopback/core'; +import { + defineModelClass, + Entity, + juggler, + model, + Model, + ModelDefinition, + property, +} from '@loopback/repository'; +import {getModelSchemaRef, post, requestBody} from '@loopback/rest'; +import { + defineCrudRepositoryClass, + defineCrudRestController, +} from '@loopback/rest-crud'; +import * as assert from 'assert'; +import {TodoListApplication} from '..'; + +@model() +class DiscoverRequest extends Model { + @property({ + required: true, + description: 'Database connection string (url), only MySQL is supported.', + }) + connectionString: string; + + @property({ + type: 'array', + itemType: 'string', + description: 'List of table models to discover & load', + }) + tableNames: string[]; + + constructor(data?: Partial) { + super(data); + } +} + +@model() +class DiscoverResponse extends Model { + @property({ + type: 'object', + required: true, + description: + 'A map from table names to endpoint paths, e.g. PRODUCT -> /products', + }) + endpoints: { + [tableName: string]: string; + }; + + constructor(data?: Partial) { + super(data); + if (!this.endpoints) this.endpoints = {}; + } +} + +export class ModelAdminController { + constructor( + @inject(CoreBindings.APPLICATION_INSTANCE) + private app: TodoListApplication, + ) {} + + @post('/discover', { + responses: { + 200: { + description: 'Information about discovered models', + content: { + 'application/json': {schema: getModelSchemaRef(DiscoverResponse)}, + }, + }, + }, + }) + async discoverAndPublishModels( + @requestBody() {connectionString, tableNames}: DiscoverRequest, + ): Promise { + const result = new DiscoverResponse(); + + const ds = await getDataSourceForConnectionString( + this.app, + connectionString, + ); + + for (const table of tableNames) { + const basePath = await this.discoverAndPublish(ds, table); + result.endpoints[table] = basePath; + } + + return result; + } + + async discoverAndPublish(ds: juggler.DataSource, table: string) { + console.log('Discovering table %j', table); + + // Step 1: discover model definition from the database schema + const modelDef = await discoverModelDefinition(ds, table); + + // Step 2: define a model class using the discovered definition + const ModelClass = defineModelClass(Entity, modelDef); + console.log('Defined model %s', ModelClass.name); + + // Step 3: define a repository class adding CRUD behavior to our model + const RepositoryClass = defineCrudRepositoryClass(ModelClass); + inject(`datasources.${ds.name}`)(RepositoryClass, undefined, 0); + const repoBinding = this.app.repository(RepositoryClass); + console.log('Defined repository %s', RepositoryClass.name); + + // Step 4: Optionally, expose the new model via REST API + const basePath = '/' + modelDef.name; + const ControllerClass = defineCrudRestController(ModelClass, {basePath}); + inject(repoBinding.key)(ControllerClass, undefined, 0); + this.app.controller(ControllerClass); + console.log('Defined controller %s', ControllerClass.name); + + return basePath; + } +} + +let dbCounter = 0; + +async function getDataSourceForConnectionString( + app: TodoListApplication, + connectionString: string, +) { + // To keep our proof-of-concept simple, we create a new datasource instance + // for each new discovery request. + // In a real application, we should probably re-use datasource instances + // sharing the same connection string. + const dsName = `db-${++dbCounter}`; + console.log('Connecting to %j - datasource %j', connectionString, dsName); + const ds = new juggler.DataSource({ + name: dsName, + connector: require('loopback-connector-mysql'), + url: connectionString, + }); + await ds.connect(); + app.dataSource(ds, dsName); + assert.equal(ds.name, dsName); + return ds; +} + +// Ideally, `@loopback/repository` should provide a function to discover +// model definition in LB4 format. For example, it could be responsibility +// of a DataSource, in which case we need to implement custom DataSource +// class backed by juggler DataSource but performing any necessary conversions. + +async function discoverModelDefinition( + ds: juggler.DataSource, + table: string, +): Promise { + const jugglerDef = await ds.discoverSchema(table); + /* Example output from MySQL: + { + name: 'Coffeeshop', + options: { + idInjection: false, + mysql: {schema: 'test', table: 'CoffeeShop'}, + }, + properties: { + id: { + type: 'Number', + required: true, + length: null, + precision: 10, + scale: 0, + id: 1, + mysql: { + columnName: 'id', + dataType: 'int', + dataLength: null, + dataPrecision: 10, + dataScale: 0, + nullable: 'N', + }, + }, + name: { + type: 'String', + required: false, + length: 512, + precision: null, + scale: null, + mysql: { + columnName: 'name', + dataType: 'varchar', + dataLength: 512, + dataPrecision: null, + dataScale: null, + nullable: 'Y', + }, + }, + city: { + type: 'String', + required: false, + length: 512, + precision: null, + scale: null, + mysql: { + columnName: 'city', + dataType: 'varchar', + dataLength: 512, + dataPrecision: null, + dataScale: null, + nullable: 'Y', + }, + }, + }} + */ + + return new ModelDefinition({ + name: jugglerDef.name, + // TODO: convert from juggler/LB3 style to LB4 + // For example, we need to transform array-type definitions from + // {type: ['string']} to {type: 'array', itemType: 'string'} + properties: jugglerDef.properties, + settings: jugglerDef.options, + }); +} diff --git a/packages/rest/src/rest.application.ts b/packages/rest/src/rest.application.ts index 0445295cc961..261598e7200c 100644 --- a/packages/rest/src/rest.application.ts +++ b/packages/rest/src/rest.application.ts @@ -133,6 +133,11 @@ export class RestApplication extends Application implements HttpServerLike { this.restServer.basePath(path); } + controller(controllerCtor: ControllerClass, name?: string): Binding { + this.restServer.invalidateRoutingCache(); + return super.controller(controllerCtor, name); + } + /** * Register a new Controller-based route. * diff --git a/packages/rest/src/rest.server.ts b/packages/rest/src/rest.server.ts index bfb6f3d9409f..b5a19442e434 100644 --- a/packages/rest/src/rest.server.ts +++ b/packages/rest/src/rest.server.ts @@ -464,6 +464,11 @@ export class RestServer extends Context implements Server, HttpServerLike { response.redirect(302, fullUrl); } + // workaround for https://github.com/strongloop/loopback-next/issues/433 + invalidateRoutingCache(): void { + delete this._httpHandler; + } + /** * Register a controller class with this server. * @@ -482,6 +487,9 @@ export class RestServer extends Context implements Server, HttpServerLike { * */ controller(controllerCtor: ControllerClass): Binding { + this.invalidateRoutingCache(); + // FIXME(bajtos) This code is never used, a typical LB4 app is binding + // controller via `app.controller()` API return this.bind('controllers.' + controllerCtor.name).toClass( controllerCtor, ); @@ -564,6 +572,8 @@ export class RestServer extends Context implements Server, HttpServerLike { controllerFactory?: ControllerFactory, methodName?: string, ): Binding { + this.invalidateRoutingCache(); + if (typeof routeOrVerb === 'object') { const r = routeOrVerb; // Encode the path to escape special chars @@ -703,6 +713,9 @@ export class RestServer extends Context implements Server, HttpServerLike { let spec = this.getSync(RestBindings.API_SPEC); const defs = this.httpHandler.getApiDefinitions(); + // Apply shallow-clone to prevent modification of user-provided API_SPEC + spec = {...spec}; + // Apply deep clone to prevent getApiSpec() callers from // accidentally modifying our internal routing data spec.paths = cloneDeep(this.httpHandler.describeApiPaths());