diff --git a/SPIKE-NOTES.md b/SPIKE-NOTES.md
new file mode 100644
index 000000000000..544bc0e1ce3d
--- /dev/null
+++ b/SPIKE-NOTES.md
@@ -0,0 +1,30 @@
+## Open questions
+
+- How to test code migrated from strong-remoting and loopback (v3)? Do we want
+ to copy existing tests over? Migrate them to async/await style? Don't bother
+ with testing at all, use few acceptance-level tests only?
+
+- How to split 2k+ lines of new (migrated) code into smaller chunks that will be
+ easier to review?
+
+- Should we register LB3 Models for dependency injection into LB4 code? Register
+ them as repositories, models, services, or something else?
+
+- Should we implement booting datasources from `datasources.json`? I see two
+ obstacles:
+
+ 1. Datasources must be booted before models are attached.
+ 2. LB3 supports variable replacement in JSON config files using ENV vars but
+ also `app.get(name)`. This may add unwanted complexity to our compat layer.
+
+## By the way
+
+- TypeScript does not support static index signature, which make it difficult to
+ consume custom model methods. See
+ https://github.com/Microsoft/TypeScript/issues/6480.
+
+- I'd like us to extract the Booter contract into a standalone package so that
+ v3compat and other similar extensions don't have to inherit entire boot and
+ don't have to lock down a specific semver-major version of boot in extension
+ dependencies. Instead, they should depend on the Booter contract only, this
+ contract should not be changed so often (hopefully).
diff --git a/examples/lb3models/LICENSE b/examples/lb3models/LICENSE
new file mode 100644
index 000000000000..0cd7db433f9e
--- /dev/null
+++ b/examples/lb3models/LICENSE
@@ -0,0 +1,25 @@
+Copyright (c) IBM Corp. 2017,2018. All Rights Reserved.
+Node module: @loopback/lb3models
+This project is licensed under the MIT License, full text below.
+
+--------
+
+MIT license
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/examples/lb3models/README.md b/examples/lb3models/README.md
new file mode 100644
index 000000000000..5b8ac6b0a37b
--- /dev/null
+++ b/examples/lb3models/README.md
@@ -0,0 +1,82 @@
+# @loopback/example-lb3models
+
+This project demonstrates how to add LoopBack 3 models into a LoopBack 4
+application.
+
+## Instructions
+
+1. Create a new LB4 app using `lb4 app`. Add `@loopback/v3compat'` to project
+ dependencies.
+
+2. Copy your model files from `common/models` to `legacy/models` directory, for
+ example:
+
+ ```text
+ legacy/models/coffee-shop.js
+ legacy/models/coffee-shop.json
+ ```
+
+ IMPORTANT! These files must live outside your `src` directory, out of sight
+ of TypeScript compiler.
+
+3. Copy your `server/model-config` to `src/legacy/model-config.json`.
+
+ Remove references to LoopBack 3 built-in models like User and ACL. LoopBack 4
+ does not support local authentication yet.
+
+ Remove `_meta` section, LoopBack 4 does not support this config option.
+
+4. Modify your Application class and apply `CompatMixin`.
+
+ ```ts
+ import {CompatMixin} from '@loopback/v3compat';
+ // ...
+
+ export class TodoListApplication extends CompatMixin(
+ BootMixin(ServiceMixin(RepositoryMixin(RestApplication))),
+ ) {
+ // ...
+ }
+ ```
+
+5. Register your legacy datasources in Application's constructor.
+
+ ```ts
+ this.v3compat.dataSource('mysqlDs', {
+ name: 'mysqlDs',
+ connector: 'mysql',
+ host: 'demo.strongloop.com',
+ port: 3306,
+ database: 'getting_started',
+ username: 'demo',
+ password: 'L00pBack',
+ });
+ ```
+
+## Need help?
+
+Check out our [Gitter channel](https://gitter.im/strongloop/loopback) and ask
+for help with this tutorial.
+
+## Bugs/Feedback
+
+Open an issue in [loopback-next](https://github.com/strongloop/loopback-next)
+and we'll take a look.
+
+## Contributions
+
+- [Guidelines](https://github.com/strongloop/loopback-next/blob/master/docs/CONTRIBUTING.md)
+- [Join the team](https://github.com/strongloop/loopback-next/issues/110)
+
+## Tests
+
+Run `npm test` from the root folder.
+
+## Contributors
+
+See
+[all contributors](https://github.com/strongloop/loopback-next/graphs/contributors).
+
+## License
+
+MIT
diff --git a/examples/lb3models/index.d.ts b/examples/lb3models/index.d.ts
new file mode 100644
index 000000000000..f14ead76b32d
--- /dev/null
+++ b/examples/lb3models/index.d.ts
@@ -0,0 +1,6 @@
+// Copyright IBM Corp. 2017,2018. All Rights Reserved.
+// Node module: @loopback/example-lb3models
+// This file is licensed under the MIT License.
+// License text available at https://opensource.org/licenses/MIT
+
+export * from './dist';
diff --git a/examples/lb3models/index.js b/examples/lb3models/index.js
new file mode 100644
index 000000000000..962f5349b19f
--- /dev/null
+++ b/examples/lb3models/index.js
@@ -0,0 +1,26 @@
+// Copyright IBM Corp. 2017,2018. All Rights Reserved.
+// Node module: @loopback/example-lb3models
+// This file is licensed under the MIT License.
+// License text available at https://opensource.org/licenses/MIT
+
+const application = require('./dist');
+
+module.exports = application;
+
+if (require.main === module) {
+ // Run the application
+ const config = {
+ rest: {
+ port: +process.env.PORT || 3000,
+ host: process.env.HOST || 'localhost',
+ openApiSpec: {
+ // useful when used with OASGraph to locate your application
+ setServersFromRequest: true,
+ },
+ },
+ };
+ application.main(config).catch(err => {
+ console.error('Cannot start the application.', err);
+ process.exit(1);
+ });
+}
diff --git a/examples/lb3models/index.ts b/examples/lb3models/index.ts
new file mode 100644
index 000000000000..be33a4d7c1f0
--- /dev/null
+++ b/examples/lb3models/index.ts
@@ -0,0 +1,8 @@
+// Copyright IBM Corp. 2017,2018. All Rights Reserved.
+// Node module: @loopback/example-lb3models
+// This file is licensed under the MIT License.
+// License text available at https://opensource.org/licenses/MIT
+
+// DO NOT EDIT THIS FILE
+// Add any additional (re)exports to src/index.ts instead.
+export * from './src';
diff --git a/examples/lb3models/legacy/model-config.json b/examples/lb3models/legacy/model-config.json
new file mode 100644
index 000000000000..77d1515a53ee
--- /dev/null
+++ b/examples/lb3models/legacy/model-config.json
@@ -0,0 +1,6 @@
+{
+ "CoffeeShop": {
+ "dataSource": "mysqlDs",
+ "public": true
+ }
+}
diff --git a/examples/lb3models/legacy/models/coffee-shop.js b/examples/lb3models/legacy/models/coffee-shop.js
new file mode 100644
index 000000000000..a24631451b1a
--- /dev/null
+++ b/examples/lb3models/legacy/models/coffee-shop.js
@@ -0,0 +1,28 @@
+'use strict';
+
+module.exports = function(CoffeeShop) {
+ CoffeeShop.status = function(cb) {
+ var currentDate = new Date();
+ var currentHour = currentDate.getHours();
+ var OPEN_HOUR = 6;
+ var CLOSE_HOUR = 20;
+ console.log('Current hour is %d', currentHour);
+ var response;
+ if (currentHour > OPEN_HOUR && currentHour < CLOSE_HOUR) {
+ response = 'We are open for business.';
+ } else {
+ response = 'Sorry, we are closed. Open daily from 6am to 8pm.';
+ }
+ cb(null, response);
+ };
+ CoffeeShop.remoteMethod('status', {
+ http: {
+ path: '/status',
+ verb: 'get',
+ },
+ returns: {
+ arg: 'status',
+ type: 'string',
+ },
+ });
+};
diff --git a/examples/lb3models/legacy/models/coffee-shop.json b/examples/lb3models/legacy/models/coffee-shop.json
new file mode 100644
index 000000000000..e17943ea2f12
--- /dev/null
+++ b/examples/lb3models/legacy/models/coffee-shop.json
@@ -0,0 +1,22 @@
+{
+ "name": "CoffeeShop",
+ "base": "PersistedModel",
+ "idInjection": true,
+ "forceId": false,
+ "options": {
+ "validateUpsert": true
+ },
+ "properties": {
+ "name": {
+ "type": "string",
+ "required": true
+ },
+ "city": {
+ "type": "string"
+ }
+ },
+ "validations": [],
+ "relations": {},
+ "acls": [],
+ "methods": {}
+}
diff --git a/examples/lb3models/package.json b/examples/lb3models/package.json
new file mode 100644
index 000000000000..46a34b736fa3
--- /dev/null
+++ b/examples/lb3models/package.json
@@ -0,0 +1,65 @@
+{
+ "name": "@loopback/example-lb3models",
+ "version": "0.1.0",
+ "description": "An example on how to add LoopBack 3 models into a LoopBack 4 application.",
+ "main": "index.js",
+ "engines": {
+ "node": ">=8.9"
+ },
+ "scripts": {
+ "build:apidocs": "lb-apidocs",
+ "build": "lb-tsc es2017 --outDir dist",
+ "build:watch": "lb-tsc es2017 --outDir dist --watch",
+ "clean": "lb-clean *example-lb3models*.tgz dist package api-docs",
+ "lint": "npm run prettier:check && npm run tslint",
+ "lint:fix": "npm run tslint:fix && npm run prettier:fix",
+ "prettier:cli": "lb-prettier \"**/*.ts\"",
+ "prettier:check": "npm run prettier:cli -- -l",
+ "prettier:fix": "npm run prettier:cli -- --write",
+ "tslint": "lb-tslint",
+ "tslint:fix": "npm run tslint -- --fix",
+ "pretest": "npm run build",
+ "test": "lb-mocha \"dist/test/*/**/*.js\"",
+ "test:dev": "lb-mocha --allow-console-logs dist/test/**/*.js && npm run posttest",
+ "verify": "npm pack && tar xf loopback-todo*.tgz && tree package && npm run clean",
+ "migrate": "node ./dist/src/migrate",
+ "prestart": "npm run build",
+ "start": "node ."
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/strongloop/loopback-next.git"
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "license": "MIT",
+ "dependencies": {
+ "@loopback/boot": "^1.0.10",
+ "@loopback/context": "^1.4.1",
+ "@loopback/core": "^1.1.4",
+ "@loopback/rest": "^1.5.3",
+ "@loopback/rest-explorer": "^1.1.6",
+ "@loopback/v3compat": "^0.1.0",
+ "loopback-connector-mysql": "^5.3.1"
+ },
+ "devDependencies": {
+ "@loopback/build": "^1.2.0",
+ "@loopback/testlab": "^1.0.4",
+ "@loopback/tslint-config": "^2.0.0",
+ "@types/lodash": "^4.14.109",
+ "@types/node": "^10.11.2",
+ "lodash": "^4.17.10",
+ "tslint": "^5.12.0",
+ "typescript": "^3.2.2"
+ },
+ "keywords": [
+ "loopback",
+ "LoopBack",
+ "example",
+ "tutorial",
+ "CRUD",
+ "models",
+ "todo"
+ ]
+}
diff --git a/examples/lb3models/public/index.html b/examples/lb3models/public/index.html
new file mode 100644
index 000000000000..2764c49c7185
--- /dev/null
+++ b/examples/lb3models/public/index.html
@@ -0,0 +1,73 @@
+
+
+
+
+ @loopback/example-lb3models
+
+
+
+
+
+
+
+
+
+
+
+
@loopback/example-lb3models
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/lb3models/src/application.ts b/examples/lb3models/src/application.ts
new file mode 100644
index 000000000000..c973895c7e2e
--- /dev/null
+++ b/examples/lb3models/src/application.ts
@@ -0,0 +1,49 @@
+// Copyright IBM Corp. 2017,2018. All Rights Reserved.
+// Node module: @loopback/example-lb3models
+// This file is licensed under the MIT License.
+// License text available at https://opensource.org/licenses/MIT
+
+import {BootMixin} from '@loopback/boot';
+import {ApplicationConfig} from '@loopback/core';
+import {RestApplication} from '@loopback/rest';
+import {RestExplorerComponent} from '@loopback/rest-explorer';
+import {CompatMixin} from '@loopback/v3compat';
+import * as path from 'path';
+import {MySequence} from './sequence';
+
+export class TodoListApplication extends CompatMixin(
+ BootMixin(RestApplication),
+) {
+ constructor(options: ApplicationConfig = {}) {
+ super(options);
+
+ // Set up the custom sequence
+ this.sequence(MySequence);
+
+ // Set up default home page
+ this.static('/', path.join(__dirname, '../../public'));
+
+ this.component(RestExplorerComponent);
+
+ this.projectRoot = __dirname;
+ // Customize @loopback/boot Booter Conventions here
+ this.bootOptions = {
+ controllers: {
+ // Customize ControllerBooter Conventions here
+ dirs: ['controllers'],
+ extensions: ['.controller.js'],
+ nested: true,
+ },
+ };
+
+ this.v3compat.dataSource('mysqlDs', {
+ name: 'mysqlDs',
+ connector: require('loopback-connector-mysql'),
+ host: 'demo.strongloop.com',
+ port: 3306,
+ database: 'getting_started',
+ username: 'demo',
+ password: 'L00pBack',
+ });
+ }
+}
diff --git a/examples/lb3models/src/index.ts b/examples/lb3models/src/index.ts
new file mode 100644
index 000000000000..22cea32ada53
--- /dev/null
+++ b/examples/lb3models/src/index.ts
@@ -0,0 +1,17 @@
+// Copyright IBM Corp. 2017,2018. All Rights Reserved.
+// Node module: @loopback/example-lb3models
+// This file is licensed under the MIT License.
+// License text available at https://opensource.org/licenses/MIT
+
+import {TodoListApplication} from './application';
+import {ApplicationConfig} from '@loopback/core';
+
+export async function main(options: ApplicationConfig = {}) {
+ const app = new TodoListApplication(options);
+ await app.boot();
+ await app.start();
+
+ const url = app.restServer.url;
+ console.log(`Server is running at ${url}`);
+ return app;
+}
diff --git a/examples/lb3models/src/migrate.ts b/examples/lb3models/src/migrate.ts
new file mode 100644
index 000000000000..a65a3bbd91d6
--- /dev/null
+++ b/examples/lb3models/src/migrate.ts
@@ -0,0 +1,25 @@
+// Copyright IBM Corp. 2017,2018. All Rights Reserved.
+// Node module: @loopback/example-lb3models
+// This file is licensed under the MIT License.
+// License text available at https://opensource.org/licenses/MIT
+
+import {TodoListApplication} from './application';
+
+export async function migrate(args: string[]) {
+ const existingSchema = args.includes('--rebuild') ? 'drop' : 'alter';
+ console.log('Migrating schemas (%s existing schema)', existingSchema);
+
+ const app = new TodoListApplication();
+ await app.boot();
+ await app.migrateSchema({existingSchema});
+
+ // Connectors usually keep a pool of opened connections,
+ // this keeps the process running even after all work is done.
+ // We need to exit explicitly.
+ process.exit(0);
+}
+
+migrate(process.argv).catch(err => {
+ console.error('Cannot migrate database schema', err);
+ process.exit(1);
+});
diff --git a/examples/lb3models/src/sequence.ts b/examples/lb3models/src/sequence.ts
new file mode 100644
index 000000000000..304bd70de5eb
--- /dev/null
+++ b/examples/lb3models/src/sequence.ts
@@ -0,0 +1,41 @@
+// Copyright IBM Corp. 2017,2018. All Rights Reserved.
+// Node module: @loopback/example-lb3models
+// This file is licensed under the MIT License.
+// License text available at https://opensource.org/licenses/MIT
+
+import {Context, inject} from '@loopback/context';
+import {
+ FindRoute,
+ InvokeMethod,
+ ParseParams,
+ Reject,
+ RequestContext,
+ RestBindings,
+ Send,
+ SequenceHandler,
+} from '@loopback/rest';
+
+const SequenceActions = RestBindings.SequenceActions;
+
+export class MySequence implements SequenceHandler {
+ constructor(
+ @inject(RestBindings.Http.CONTEXT) public ctx: Context,
+ @inject(SequenceActions.FIND_ROUTE) protected findRoute: FindRoute,
+ @inject(SequenceActions.PARSE_PARAMS) protected parseParams: ParseParams,
+ @inject(SequenceActions.INVOKE_METHOD) protected invoke: InvokeMethod,
+ @inject(SequenceActions.SEND) public send: Send,
+ @inject(SequenceActions.REJECT) public reject: Reject,
+ ) {}
+
+ async handle(context: RequestContext) {
+ try {
+ const {request, response} = context;
+ const route = this.findRoute(request);
+ const args = await this.parseParams(request, route);
+ const result = await this.invoke(route, args);
+ this.send(response, result);
+ } catch (error) {
+ this.reject(context, error);
+ }
+ }
+}
diff --git a/examples/lb3models/tsconfig.build.json b/examples/lb3models/tsconfig.build.json
new file mode 100644
index 000000000000..f8bd0f50ef8f
--- /dev/null
+++ b/examples/lb3models/tsconfig.build.json
@@ -0,0 +1,8 @@
+{
+ "$schema": "http://json.schemastore.org/tsconfig",
+ "extends": "@loopback/build/config/tsconfig.common.json",
+ "compilerOptions": {
+ "rootDir": "."
+ },
+ "include": ["index.ts", "src", "test"]
+}
diff --git a/examples/lb3models/tslint.build.json b/examples/lb3models/tslint.build.json
new file mode 100644
index 000000000000..121b8adb21a3
--- /dev/null
+++ b/examples/lb3models/tslint.build.json
@@ -0,0 +1,4 @@
+{
+ "$schema": "http://json.schemastore.org/tslint",
+ "extends": ["@loopback/tslint-config/tslint.build.json"]
+}
diff --git a/examples/lb3models/tslint.json b/examples/lb3models/tslint.json
new file mode 100644
index 000000000000..2bb931e66a64
--- /dev/null
+++ b/examples/lb3models/tslint.json
@@ -0,0 +1,4 @@
+{
+ "$schema": "http://json.schemastore.org/tslint",
+ "extends": ["@loopback/tslint-config/tslint.common.json"]
+}
diff --git a/packages/context/src/binding-decorator.ts b/packages/context/src/binding-decorator.ts
index 3c2cf907f0e9..07c3ee350c3f 100644
--- a/packages/context/src/binding-decorator.ts
+++ b/packages/context/src/binding-decorator.ts
@@ -89,7 +89,7 @@ export namespace bind {
*/
export function provider(
...specs: BindingSpec[]
- ): ((target: Constructor>) => void) {
+ ): (target: Constructor>) => void {
return (target: Constructor>) => {
if (!isProviderClass(target)) {
throw new Error(`Target ${target} is not a Provider`);
diff --git a/packages/openapi-v3/src/controller-spec.ts b/packages/openapi-v3/src/controller-spec.ts
index f30195172bc4..f8eeb340df9f 100644
--- a/packages/openapi-v3/src/controller-spec.ts
+++ b/packages/openapi-v3/src/controller-spec.ts
@@ -204,7 +204,9 @@ function resolveControllerSpec(constructor: Function): ControllerSpec {
constructor.prototype,
op,
);
- const paramTypes = opMetadata.parameterTypes;
+
+ // TODO(bajtos) Add a unit-test for this fix
+ const paramTypes = opMetadata.parameterTypes || [];
const isComplexType = (ctor: Function) =>
!_.includes([String, Number, Boolean, Array, Object], ctor);
diff --git a/packages/v3compat/.npmrc b/packages/v3compat/.npmrc
new file mode 100644
index 000000000000..43c97e719a5a
--- /dev/null
+++ b/packages/v3compat/.npmrc
@@ -0,0 +1 @@
+package-lock=false
diff --git a/packages/v3compat/LICENSE b/packages/v3compat/LICENSE
new file mode 100644
index 000000000000..7ce53fa5b434
--- /dev/null
+++ b/packages/v3compat/LICENSE
@@ -0,0 +1,25 @@
+Copyright (c) IBM Corp. 2018. All Rights Reserved.
+Node module: @loopback/v3compat
+This project is licensed under the MIT License, full text below.
+
+--------
+
+MIT license
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/packages/v3compat/README.md b/packages/v3compat/README.md
new file mode 100644
index 000000000000..024aefd889f1
--- /dev/null
+++ b/packages/v3compat/README.md
@@ -0,0 +1,287 @@
+# @loopback/v3compat
+
+A compatibility layer simplifying migration of LoopBack 3 models to LoopBack 4+.
+
+## Installation
+
+```sh
+npm install --save @loopback/v3compat
+```
+
+## Basic use
+
+1. Modify your Application class and apply `CompatMixin`.
+
+ ```ts
+ import {CompatMixin} from '@loopback/v3compat';
+ // ...
+
+ export class MyApplication extends CompatMixin(
+ BootMixin(ServiceMixin(RepositoryMixin(RestApplication))),
+ ) {
+ // ...
+ }
+ ```
+
+2. Register your legacy datasources in Application's constructor.
+
+ ```ts
+ this.v3compat.dataSource('db', {
+ name: 'db',
+ connector: 'memory',
+ });
+ ```
+
+3. Copy your LoopBack 3 model files from `common/models` to `legacy/models`
+ directory, for example:
+
+ ```text
+ legacy/models/coffee-shop.js
+ legacy/models/coffee-shop.json
+ ```
+
+ IMPORTANT! These files must live outside your `src` directory, out of sight
+ of TypeScript compiler.
+
+4. Copy your `server/model-config` to `src/legacy/model-config.json`.
+
+ Remove references to LoopBack 3 built-in models like User and ACL. LoopBack 4
+ does not support local authentication yet.
+
+ Remove `_meta` section, LoopBack 4 does not support this config option.
+
+## Developer documentation
+
+See [internal-design.md](./docs/internal-design.md) for a detailed documentation
+on how the compatibility layer works under the hood.
+
+## Implementation status
+
+### Supported features
+
+These features are already implemented in this PoC and work out of the box.
+
+#### LoopBack application
+
+- Create a new dataSource: `app.v3compat.dataSource()`
+- Create a new model: `app.v3compat.registry.createModel()`)
+- Configure a model and expose it via REST API: `app.v3compat.model()`
+
+#### Persistence
+
+- All loopback-datasource-juggler features known from LoopBack 3.x are fully
+ supported. The compatibility layer is using loopback-datasource-juggler v4
+ directly.
+
+#### Remoting
+
+- Models can expose custom remote methods via REST API using
+ `MyModel.remoteMethod(name, options)` API.
+
+- The remoting layer supports most remoting features: HTTP verb and path
+ configuration of a remote method, `accepts`/`returns` parameters, etc.
+
+- Few DataAccessObject APIs like `create` and `find` are exposed via the REST
+ API. The rest of these methods should be exposed soon.
+
+### Breaking changes
+
+- The implementation has switched from `bluebird` to native promises. Promises
+ returned by LoopBack's APIs do not provide Bluebird's sugar APIs anymore.
+
+- "accepts" parameter with type `any` are treated as `string`, the value is
+ never coerced.
+
+- Coercion at REST API layer works differently, the implementation switched from
+ our own coercion (implemented by strong-remoting) to coercion provided by AJV
+ library.
+
+- The bootstrapper has been greatly simplified. It always defines all LB3 models
+ found, regardless of whether they are configured in `model-config-json`. Of
+ course, only models listed in `model-config.json` are attached to a datasource
+ and exposed via REST API(if configured as `public`).
+
+- Deprecated methods like `SharedClass.prototype.disableRemoteMethod` were
+ removed.
+
+### Missing features needed for 1.0 release
+
+_TODO(bajtos): process TODO comments in the source code and add the missing
+features to one of the two lists below._
+
+- Expose all DataAccessObject (PersistedModel) methods via REST API
+
+- Expose relation and scope endpoints via REST API. Migrate the following
+ `Model` methods:
+
+ - `belongsToRemoting`
+ - `hasOneRemoting`
+ - `hasManyRemoting`
+ - `scopeRemoting`
+ - `nestRemoting`
+
+- Remoting hooks: `MyModel.beforeRemote`, `afterRemote`, `afterRemoteError` This
+ will require `HttpContext` with the following properties:
+
+ - `method`
+ - `req`
+ - `res`
+ - `options`
+ - `args`
+ - `methodString`
+ - `result`
+
+- Convert `null` to 404 Not Found response:
+ `{rest: {after: convertNullToNotFoundError}}`
+
+- hasUpdateOnlyProps: the "create" method should have a different request body
+ schema for "create" when "forceID" is enabled, because "create" requests must
+ not include the "id" property.
+
+### Features to implement later (if ever)
+
+These features are not implemented yet. We may implement some of them in the
+future, but many will never make it to our backlog. We are strongly encouraging
+LoopBack 3 users to contribute the features they are interested in using.
+
+- `MyModel.disableRemoteMethodByName`
+
+- Pick up models and methods defined after the app has started.
+
+- Allow remote methods to respond with a Buffer or a ReadableStream
+
+- Allow remote methods to set a custom response status code and HTTP headers via
+ "returns" parameters
+
+- Mixins. The runtime bits provided by juggler are present, we need to add
+ support for related infrastructure like a booter to load mixin definitions
+ from JS files and register them with juggler.
+
+- Current context set from HTTP context (`http: 'optionsFromRequest'`) and
+ `Model.createOptionsFromRemotingContext`, see
+ [Using current context](https://loopback.io/doc/en/lb3/Using-current-context.html)
+
+- Case-insensitive URL paths. A todo model should be available at `/Todos`,
+ `/todos`, etc.
+
+- `_meta` configuration in `model-config.json`: allow the user to provide
+ additional directories where to look for models and mixins. This can be
+ implemented differently in LB4, for example via booter configuration.
+
+- White-list/black-list of model methods to expose via REST API (`methods`
+ property in `model-config.json` model entry).
+
+- `SharedClass` APIs
+
+ - `isMethodEnabled(sharedMethod)`
+ - `resolve(resolver)`
+ - `findMethodByName`
+ - `disableMethodByName`
+
+- Registry of connectors
+
+ - `app.connectors.{name}`
+ - `app.connector(name, connector)`
+
+- KeyValue model and its REST API
+
+ - `get(key, options, cb)`
+ - `set(key, value, options, cb)`
+ - `expire(key, options, cb)`
+ - `ttl(key, options, cb)`
+ - `keys(filter, options, cb)`
+ - `iterateKeys(filter, options)`
+
+### Out of scope
+
+- `app.remotes()` and `RemoteObjects` API
+
+- `PersistedModel.createChangeStream()`
+
+- Allow LB3 models to be attached to LB4 dataSources. This won't work because
+ LB3 requires all datasources to share the same `ModelBuilder` instance.
+
+- CLS-based context (`loopback-context`)
+
+- Global model registry: `loopback.createModel`, `loopback.findModel`, etc.
+
+- REST API for creating multiple model instances in one HTTP request, using the
+ same endpoint as for creating a single model instance (strong-remoting
+ parameter options `allowArray: true`).
+
+- Change replication, `Change` and `Checkpoint` models and the following
+ `PersistedModel` methods:
+
+ - `diff`
+ - `changes`
+ - `checkpoint`
+ - `currentCheckpoint`
+ - `replicate`
+ - `createUpdates`
+ - `bulkUpdate`
+ - `getChangeModel`
+ - `getSourceId`
+ - `enableChangeTracking`
+ - `rectifyAllChanges`
+ - `handleChangeError`
+ - `rectifyChange`
+ - `updateLastChange`
+ - `createChangeFilter`
+ - `fillCustomChangeProperties`
+
+- The built-in `Email` model and connector.
+
+- The built-in `Application` model
+
+- Authorization & authentication:
+
+ - `app.enableAuth()`
+ - `Model.checkAccess(token, modelId, sharedMethod, ctx, cb)`
+ - `AccessToken` model
+ - `Acl` model
+ - `RoleMapping` model
+ - `Role` model
+ - `Scope` model
+ - `User` model
+
+- `SharedMethod.prototype.invoke(scope, args, remotingOptions, ctx, cb)`
+
+- Remoting `HttpContext` properties and methods:
+
+ - `typeRegistry`
+ - `supportedTypes`
+ - `invoke`
+ - `setReturnArgByName`
+ - `getArgByName`
+ - `buildArgs`
+ - `createStream`
+ - `respondWithEventStream`
+ - `resolveReponseOperation`
+ - `done`
+ - (etc.)
+
+- Support for XML parsing (request bodies) and serialization (response bodies).
+
+- Extension points enabling JSON API.
+
+- Piping a return value of a remote function into HTTP response body. Remote
+ methods should pass a `ReadableStream` instance in one of the "returns"
+ arguments instead.
+
+## Contributions
+
+- [Guidelines](https://github.com/strongloop/loopback-next/blob/master/docs/CONTRIBUTING.md)
+- [Join the team](https://github.com/strongloop/loopback-next/issues/110)
+
+## Tests
+
+Run `npm test` from the root folder.
+
+## Contributors
+
+See
+[all contributors](https://github.com/strongloop/loopback-next/graphs/contributors).
+
+## License
+
+MIT
diff --git a/packages/v3compat/docs.json b/packages/v3compat/docs.json
new file mode 100644
index 000000000000..9da9d2c34a05
--- /dev/null
+++ b/packages/v3compat/docs.json
@@ -0,0 +1,9 @@
+{
+ "content": [
+ "index.ts",
+ "src/rest-explorer.component.ts",
+ "src/rest-explorer.keys.ts",
+ "src/rest-explorer.types.ts"
+ ],
+ "codeSectionDepth": 4
+}
diff --git a/packages/v3compat/docs/internal-design.md b/packages/v3compat/docs/internal-design.md
new file mode 100644
index 000000000000..465f09ed3911
--- /dev/null
+++ b/packages/v3compat/docs/internal-design.md
@@ -0,0 +1,142 @@
+# Internal Design
+
+This document outlines how v3compat works under the hood.
+
+## Differences between LoopBack version 3 and version 4
+
+### Models and repositories
+
+In LoopBack 4, a Model (Entity) describes the data schema and a Repository (or a
+(e.g. a connection pool). Models, Repositories, Services and DataSources are
+register with IoC container (`Context`) and later injected into controllers via
+Dependency Injection.
+
+In LoopBack 3, a Model class not only describes the data schema, but also
+implements the related behavior using a DataSource as the backing
+implementation. There are multiple registries holding references to different
+classes and objects: a registry of data-sources at application level, a registry
+of models attached to an application, etc.
+
+To support LB3 models in LB4 applications, we need to recreate LB3-like model
+registry and application object.
+
+### REST API
+
+When an incoming HTTP request arrives to a LoopBack 4 application, it is
+processed by the following layers:
+
+1. The REST layer finds the controller method to handle the requested verb and
+ path.
+2. The REST layer parses invocation arguments from the request, using metadata
+ from OpenAPI `OperationObject`.
+3. The controller method is invoked.
+4. The controller method invokes a Repository or a Service method to access the
+ backing database/web-service.
+5. The repository/service returns data.
+6. The controller method converts data from repository/service format to a form
+ suitable for HTTP transport.
+7. The REST layer converts the value returned by the controller method into a
+ real HTTP response, using metadata from OpenAPI `OperationObject`.
+
+For comparison, LoopBack 3 handles incoming requests as follows:
+
+1. The REST layer finds _the remote method_ to handle the requested verb and
+ path.
+2. The REST layer parses invocation arguments from the request, using
+ _strong-remoting metadata supplied by the remote method._
+3. The remote method is invoked.
+4. The remote method is typically _a model method contributed by
+ `DataAccessObject`_, `KeyValueAccessObject` or a custom access-object
+ provided by a connector for web-services.
+5. The model method returns data from the database/web-service.
+6. _(There is no conversion from DAO to REST format.)_
+7. The REST layer converts the value returned by the remote method into a real
+ HTTP response, using metadata from _strong-remoting metadata supplied by the
+ remote method._
+
+In order to expose LB3 models via LB4 REST API, we need to convert remoting
+metadata into OpenAPI Spec and build controllers (or handler routes) to expose
+remote methods in a form that LB4 can invoke.
+
+## Implementation overview
+
+To allow LoopBack 3 models to function inside a LoopBack 4 application, we need
+to implement several components.
+
+### Model and DataSource registration
+
+- [`Lb3Application`](../src/core/lb3-application.ts) implements API provided by
+ LoopBack 3 application object (`app`), it approximately corresponds to
+ [lib/application.js](https://github.com/strongloop/loopback/blob/master/lib/application.js)
+ with few bits from
+ [lib/loopback.js](https://github.com/strongloop/loopback/blob/master/lib/loopback.js)
+
+- [`Lb3Registry`](../src/core/lb3-registry.ts) implements a registry of models
+ with methods for creating, configuring and removing models. It's mostly the
+ same code as in
+ [lib/registry.js](https://github.com/strongloop/loopback/blob/master/lib/registry.js).
+
+## Base models
+
+- [`Model`](../src/core/lb3-model.ts) builds on top of
+ loopback-datasource-juggler model class and adds loopback/strong-remoting
+ specific additions like `Model.remoteMethod` API. It does not expose any REST
+ API endpoints, therefore it's suitable for using with Service connectors
+ (SOAP, REST). The v3compat implementation is mostly copying the code from
+ [lib/model.js](https://github.com/strongloop/loopback/blob/master/lib/model.js)
+
+- [`PersistedModel`](../src/core/lb3-persisted-model.ts) builds on top of
+ `Model` and exposes `DataAccessObject` (`CRUD`) endpoints via the REST API.
+ The v3compat implementation is mostly copying the code from
+ [lib/persisted-model.js](https://github.com/strongloop/loopback/blob/master/lib/persisted-model.js)
+
+## Remoting metadata
+
+In order to expose shared methods via LB4 REST API, we need to provide
+infrastructure for specifying remoting metadata first.
+
+- [`SharedClass`](../src/remoting/shared-class.ts) represents a resource
+ grouping multiple operations, it is a direct counter-part of strong-remoting's
+ [lib/shared-class.js](https://github.com/strongloop/strong-remoting/blob/master/lib/shared-class.js)
+
+- [`SharedMethod`](../src/remoting/shared-method.ts) represents a single REST
+ operation (possibly exposed at multiple endpoints), the implementation is
+ mostly copied from strong-remoting's
+ [lib/shared-method.js](https://github.com/strongloop/strong-remoting/blob/master/lib/shared-method.js)
+
+## REST layer
+
+With remote methods defined and described with necessary metadata, it's time to
+build a REST layer exposing these methods as REST endpoints.
+
+- [`specgen`](../src/specgen) provides conversion utilities for building OpenAPI
+ spec from strong-remoting metadata. The code is loosely based on
+ loopback-swagger's
+ [specgen](https://github.com/strongloop/loopback-swagger/tree/master/lib/specgen).
+
+- [`Lb3ModelController`](../src/remoting/lb3-model-controller.ts) is as a base
+ controller class acting as a bridge to convert LB4 controller method
+ invocations into an invocation of an LB3 shared method. It accepts arguments
+ parsed by `@loopback/rest` from the incoming request and converts them into a
+ list of argument expected by the remote method. When the remote method
+ returns, the controller converts the result back to the format expected by
+ LB4.
+
+- [`RestAdapter`](../src/remoting/rest-adapter.ts) puts it all together. For
+ each LB3 model registered in the application, it builds a LB4 controller class
+ and defines a new controller method for each remote method provided by the LB3
+ model. It uses `specgen` to convert LB3 remoting metadata into OpenAPI spec
+ expected by LB4, and calls `Lb3ModelController` method to invoke the remote
+ method when a request arrives.
+
+## Bootstrapper
+
+[`Lb3ModelBooter`](../src/boot/lb3-model-booter) is a LB4 Booter class that
+performs the following tasks:
+
+- It loads individual model definitions from `models/{name}.json` files, defines
+ new models using `Lb3Registry` API and customizes them by running the
+ appropriate `models/{name}.js` script.
+
+- Then it processes `model-config.json` file and configures & attaches models to
+ the application.
diff --git a/packages/v3compat/fixtures/model-config.json b/packages/v3compat/fixtures/model-config.json
new file mode 100644
index 000000000000..275f6d85a7ad
--- /dev/null
+++ b/packages/v3compat/fixtures/model-config.json
@@ -0,0 +1,6 @@
+{
+ "Todo": {
+ "dataSource": "db",
+ "public": true
+ }
+}
diff --git a/packages/v3compat/fixtures/models/todo.js b/packages/v3compat/fixtures/models/todo.js
new file mode 100644
index 000000000000..b64ab2fd3c68
--- /dev/null
+++ b/packages/v3compat/fixtures/models/todo.js
@@ -0,0 +1,11 @@
+// Copyright IBM Corp. 2018. All Rights Reserved.
+// Node module: @loopback/v3compat
+// This file is licensed under the MIT License.
+// License text available at https://opensource.org/licenses/MIT
+
+module.exports = function(Todo) {
+ // A dummy custom method to verify that model JS file was correctly processed
+ Todo.findByTitle = function(title) {
+ return this.find({where: {title}});
+ };
+};
diff --git a/packages/v3compat/fixtures/models/todo.json b/packages/v3compat/fixtures/models/todo.json
new file mode 100644
index 000000000000..5314e4f23b8f
--- /dev/null
+++ b/packages/v3compat/fixtures/models/todo.json
@@ -0,0 +1,11 @@
+{
+ "name": "Todo",
+ "base": "PersistedModel",
+ "strict": false,
+ "properties": {
+ "title": {
+ "type": "string",
+ "required": true
+ }
+ }
+}
diff --git a/packages/v3compat/index.d.ts b/packages/v3compat/index.d.ts
new file mode 100644
index 000000000000..e419eb051989
--- /dev/null
+++ b/packages/v3compat/index.d.ts
@@ -0,0 +1,6 @@
+// Copyright IBM Corp. 2018. All Rights Reserved.
+// Node module: @loopback/v3compat
+// This file is licensed under the MIT License.
+// License text available at https://opensource.org/licenses/MIT
+
+export * from './dist';
diff --git a/packages/v3compat/index.js b/packages/v3compat/index.js
new file mode 100644
index 000000000000..85a6e7ef098a
--- /dev/null
+++ b/packages/v3compat/index.js
@@ -0,0 +1,6 @@
+// Copyright IBM Corp. 2018. All Rights Reserved.
+// Node module: @loopback/v3compat
+// This file is licensed under the MIT License.
+// License text available at https://opensource.org/licenses/MIT
+
+module.exports = require('./dist');
diff --git a/packages/v3compat/index.ts b/packages/v3compat/index.ts
new file mode 100644
index 000000000000..fb3e2364637e
--- /dev/null
+++ b/packages/v3compat/index.ts
@@ -0,0 +1,8 @@
+// Copyright IBM Corp. 2018. All Rights Reserved.
+// Node module: @loopback/v3compat
+// This file is licensed under the MIT License.
+// License text available at https://opensource.org/licenses/MIT
+
+// DO NOT EDIT THIS FILE
+// Add any additional (re)exports to src/index.ts instead.
+export * from './src';
diff --git a/packages/v3compat/package.json b/packages/v3compat/package.json
new file mode 100644
index 000000000000..464c3204a188
--- /dev/null
+++ b/packages/v3compat/package.json
@@ -0,0 +1,54 @@
+{
+ "name": "@loopback/v3compat",
+ "description": "A compatibility layer allowing LoopBack 3 projects to carry over their models to LoopBack 4+.",
+ "version": "0.1.0",
+ "keywords": [
+ "LoopBack",
+ "Compatibility",
+ "LB3"
+ ],
+ "publishConfig": {
+ "access": "public"
+ },
+ "engines": {
+ "node": ">=8.9"
+ },
+ "scripts": {
+ "build:apidocs": "lb-apidocs",
+ "build": "lb-tsc es2017 --outDir dist",
+ "clean": "lb-clean loopback-explorer*.tgz dist package api-docs",
+ "pretest": "npm run build",
+ "test": "lb-mocha \"dist/test/**/*.js\"",
+ "verify": "npm pack && tar xf loopback-explorer*.tgz && tree package && npm run clean"
+ },
+ "author": "IBM",
+ "copyright.owner": "IBM Corp.",
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/strongloop/loopback-next.git"
+ },
+ "dependencies": {
+ "@loopback/boot": "^1.0.10",
+ "@loopback/core": "^1.1.3",
+ "@loopback/rest": "^1.5.3",
+ "debug": "^4.1.1",
+ "loopback-datasource-juggler": "^4.5.0"
+ },
+ "devDependencies": {
+ "@loopback/build": "^1.1.0",
+ "@loopback/rest": "^1.5.1",
+ "@loopback/testlab": "^1.0.3",
+ "@loopback/tslint-config": "^1.0.0",
+ "@types/debug": "^0.0.31",
+ "@types/node": "^10.1.1"
+ },
+ "files": [
+ "README.md",
+ "index.js",
+ "index.d.ts",
+ "dist/src",
+ "dist/index*",
+ "src"
+ ]
+}
diff --git a/packages/v3compat/src/boot/README.md b/packages/v3compat/src/boot/README.md
new file mode 100644
index 000000000000..51bc5271ad6f
--- /dev/null
+++ b/packages/v3compat/src/boot/README.md
@@ -0,0 +1,4 @@
+# v3compat/boot
+
+Source code migrated from
+[loopback-boot](https://github.com/strongloop/loopback-boot).
diff --git a/packages/v3compat/src/boot/index.ts b/packages/v3compat/src/boot/index.ts
new file mode 100644
index 000000000000..fd1eef7c049f
--- /dev/null
+++ b/packages/v3compat/src/boot/index.ts
@@ -0,0 +1,6 @@
+// Copyright IBM Corp. 2018. All Rights Reserved.
+// Node module: @loopback/v3compat
+// This file is licensed under the MIT License.
+// License text available at https://opensource.org/licenses/MIT
+
+export * from './lb3-model-booter';
diff --git a/packages/v3compat/src/boot/lb3-model-booter.ts b/packages/v3compat/src/boot/lb3-model-booter.ts
new file mode 100644
index 000000000000..f5fac2a7426a
--- /dev/null
+++ b/packages/v3compat/src/boot/lb3-model-booter.ts
@@ -0,0 +1,122 @@
+// Copyright IBM Corp. 2018. All Rights Reserved.
+// Node module: @loopback/v3compat
+// This file is licensed under the MIT License.
+// License text available at https://opensource.org/licenses/MIT
+
+import {BootBindings, Booter, discoverFiles} from '@loopback/boot';
+import {Application, CoreBindings, inject} from '@loopback/core';
+import * as debugFactory from 'debug';
+import * as fs from 'fs';
+import * as path from 'path';
+import {promisify} from 'util';
+import {Lb3Application} from '../core';
+
+const fileExists = promisify(fs.exists);
+
+const debug = debugFactory('loopback:v3compat:model-booter');
+
+const DefaultOptions = {
+ // from "/dist/src/application.ts" to "/legacy"
+ root: '../../legacy',
+};
+
+export class Lb3ModelBooter implements Booter {
+ options: Lb3ModelBooterOptions;
+
+ root: string;
+ modelDefinitionsGlob: string;
+ modelConfigFile: string;
+
+ discoveredDefinitionFiles: string[];
+
+ constructor(
+ @inject(CoreBindings.APPLICATION_INSTANCE)
+ public app: Application & {v3compat: Lb3Application},
+ @inject(BootBindings.PROJECT_ROOT)
+ public projectRoot: string,
+ @inject(`${BootBindings.BOOT_OPTIONS}#v3compat`)
+ options: Partial = {},
+ ) {
+ this.options = Object.assign({}, DefaultOptions, options);
+ }
+
+ async configure?(): Promise {
+ this.root = path.join(this.projectRoot, this.options.root);
+ this.modelDefinitionsGlob = '/models/*.json';
+ this.modelConfigFile = 'model-config.json';
+ }
+
+ async discover?(): Promise {
+ debug(
+ 'Discovering LB3 model definitions in %j using glob %j',
+ this.root,
+ this.modelDefinitionsGlob,
+ );
+
+ const allFiles = await discoverFiles(this.modelDefinitionsGlob, this.root);
+ this.discoveredDefinitionFiles = allFiles.filter(
+ f => f[0] !== '_' && path.extname(f) === '.json',
+ );
+ debug(' -> %j', allFiles);
+ }
+
+ async load?(): Promise {
+ for (const f of this.discoveredDefinitionFiles) await this.loadModel(f);
+
+ await this.configureModels();
+ }
+
+ private async loadModel(jsonFile: string) {
+ const basename = path.basename(jsonFile, path.extname(jsonFile));
+ const sourceDir = path.dirname(jsonFile);
+ // TODO: support additional extensions like `.ts` and `.coffee`
+ const sourceFile = path.join(sourceDir, `${basename}.js`);
+ const sourceExists = await fileExists(sourceFile);
+
+ debug(
+ 'Loading model from %j (%j)',
+ path.relative(this.projectRoot, jsonFile),
+ sourceExists
+ ? path.relative(this.projectRoot, sourceFile)
+ : '',
+ );
+
+ const definition = require(jsonFile);
+ const script = sourceExists ? require(sourceFile) : undefined;
+
+ debug(' creating a new model %j', definition.name);
+ const modelCtor = this.app.v3compat.registry.createModel(definition);
+
+ if (typeof script === 'function') {
+ debug(
+ ' customizing model %j using %j',
+ definition.name,
+ path.relative(this.projectRoot, sourceFile),
+ );
+ script(modelCtor);
+ } else if (sourceExists) {
+ debug(
+ ' skipping model file %s - `module.exports` is not a function',
+ sourceFile,
+ );
+ }
+ }
+
+ private async configureModels() {
+ const configFile = path.join(this.root, this.modelConfigFile);
+ debug('Loading model-config from %j', configFile);
+
+ const config = require(configFile);
+ for (const modelName in config) {
+ if (modelName === '_meta') continue;
+ const modelConfig = config[modelName];
+ debug(' configuring %j with %j', modelName, modelConfig);
+ const modelCtor = this.app.v3compat.registry.getModel(modelName);
+ this.app.v3compat.model(modelCtor, modelConfig);
+ }
+ }
+}
+
+export interface Lb3ModelBooterOptions {
+ root: string;
+}
diff --git a/packages/v3compat/src/compat.component.ts b/packages/v3compat/src/compat.component.ts
new file mode 100644
index 000000000000..814f6531b6c9
--- /dev/null
+++ b/packages/v3compat/src/compat.component.ts
@@ -0,0 +1,11 @@
+// Copyright IBM Corp. 2018. All Rights Reserved.
+// Node module: @loopback/v3compat
+// This file is licensed under the MIT License.
+// License text available at https://opensource.org/licenses/MIT
+
+import {Component} from '@loopback/core';
+import {Lb3ModelBooter} from './boot';
+
+export class CompatComponent implements Component {
+ booters = [Lb3ModelBooter];
+}
diff --git a/packages/v3compat/src/compat.mixin.ts b/packages/v3compat/src/compat.mixin.ts
new file mode 100644
index 000000000000..ace951def6e9
--- /dev/null
+++ b/packages/v3compat/src/compat.mixin.ts
@@ -0,0 +1,45 @@
+// Copyright IBM Corp. 2018. All Rights Reserved.
+// Node module: @loopback/v3compat
+// This file is licensed under the MIT License.
+// License text available at https://opensource.org/licenses/MIT
+
+import {Application, Constructor} from '@loopback/core';
+import {CompatComponent} from './compat.component';
+import {Lb3Application} from './core';
+
+/**
+ * A mixin class for Application that adds `v3compat` property providing
+ * access to LB3 application-like object.
+ *
+ * ```ts
+ * class MyApplication extends CompatMixin(RestApplication) {
+ * constructor() {
+ * const lb3app = this.v3compat;
+ * const Todo = lb3app.registry.createModel(
+ * 'Todo',
+ * {title: {type: 'string', required: true}},
+ * {strict: true}
+ * );
+ * lb3app.dataSource('db', {connector: 'memory'});
+ * lb3app.model(Todo, {dataSource: 'db'});
+ * }
+ * }
+ * ```
+ *
+ * TODO: describe "app.v3compat" property, point users to documentation
+ * for Lb3Application class.
+ *
+ */
+// tslint:disable-next-line:no-any
+export function CompatMixin>(superClass: T) {
+ return class extends superClass {
+ v3compat: Lb3Application;
+
+ // tslint:disable-next-line:no-any
+ constructor(...args: any[]) {
+ super(...args);
+ this.v3compat = new Lb3Application((this as unknown) as Application);
+ this.component(CompatComponent);
+ }
+ };
+}
diff --git a/packages/v3compat/src/core/README.md b/packages/v3compat/src/core/README.md
new file mode 100644
index 000000000000..de529c8f3e37
--- /dev/null
+++ b/packages/v3compat/src/core/README.md
@@ -0,0 +1,3 @@
+# v3compat/core
+
+Source code migrated from [loopback](https://github.com/strongloop/loopback).
diff --git a/packages/v3compat/src/core/index.ts b/packages/v3compat/src/core/index.ts
new file mode 100644
index 000000000000..6b1ef47aacba
--- /dev/null
+++ b/packages/v3compat/src/core/index.ts
@@ -0,0 +1,10 @@
+// Copyright IBM Corp. 2018. All Rights Reserved.
+// Node module: @loopback/v3compat
+// This file is licensed under the MIT License.
+// License text available at https://opensource.org/licenses/MIT
+
+export * from './lb3-types';
+export * from './lb3-application';
+export * from './lb3-registry';
+export * from './lb3-model';
+export * from './lb3-persisted-model';
diff --git a/packages/v3compat/src/core/lb3-application.ts b/packages/v3compat/src/core/lb3-application.ts
new file mode 100644
index 000000000000..038579cf319a
--- /dev/null
+++ b/packages/v3compat/src/core/lb3-application.ts
@@ -0,0 +1,76 @@
+// Copyright IBM Corp. 2018. All Rights Reserved.
+// Node module: @loopback/v3compat
+// This file is licensed under the MIT License.
+// License text available at https://opensource.org/licenses/MIT
+
+import {Application} from '@loopback/core';
+import * as assert from 'assert';
+import * as debugFactory from 'debug';
+import {RestAdapter} from '../remoting';
+import {ModelClass} from './lb3-model';
+import {Lb3Registry} from './lb3-registry';
+import {DataSource, DataSourceConfig, ModelConfig} from './lb3-types';
+
+const debug = debugFactory('loopback:v3compat:application');
+
+export class Lb3Application {
+ readonly restAdapter: RestAdapter;
+ readonly registry: Lb3Registry;
+
+ readonly dataSources: {
+ [name: string]: DataSource;
+ };
+
+ readonly models: {
+ [name: string]: ModelClass;
+ };
+
+ constructor(protected lb4app: Application) {
+ this.registry = new Lb3Registry(lb4app);
+ this.restAdapter = new RestAdapter(lb4app);
+ this.dataSources = Object.create(null);
+ this.models = Object.create(null);
+ }
+
+ dataSource(name: string, config: DataSourceConfig): DataSource {
+ debug('Registering datasource %j with config %j', name, config);
+ // TODO: use the implementation from LB3's lib/application.js
+ const ds = this.registry.createDataSource(name, config);
+ this.dataSources[name] = ds;
+
+ this.lb4app
+ .bind(`datasources.${name}`)
+ .to(ds)
+ .tag('datasource');
+
+ return ds;
+ }
+
+ model(modelCtor: ModelClass, config: ModelConfig) {
+ debug('Registering model %j with config %j', modelCtor.modelName, config);
+ // TODO: use the implementation from LB3's lib/application.js
+ if (typeof config.dataSource === 'string') {
+ const dataSource = this.dataSources[config.dataSource];
+ if (!dataSource) {
+ assert.fail(
+ `${
+ modelCtor.modelName
+ } is referencing a dataSource that does not exist: "${
+ config.dataSource
+ }"`,
+ );
+ }
+ config = Object.assign({}, config, {dataSource});
+ }
+ this.registry.configureModel(modelCtor, config);
+ this.models[modelCtor.modelName] = modelCtor;
+ modelCtor.app = this;
+
+ // TODO: register Model schema for OpenAPI spec
+ this.restAdapter.registerSharedClass(modelCtor.sharedClass);
+ }
+
+ deleteModelByName(modelName: string): void {
+ throw new Error('Not implemented yet.');
+ }
+}
diff --git a/packages/v3compat/src/core/lb3-model.ts b/packages/v3compat/src/core/lb3-model.ts
new file mode 100644
index 000000000000..c93d03fd0f83
--- /dev/null
+++ b/packages/v3compat/src/core/lb3-model.ts
@@ -0,0 +1,232 @@
+// Copyright IBM Corp. 2018. All Rights Reserved.
+// Node module: @loopback/v3compat
+// This file is licensed under the MIT License.
+// License text available at https://opensource.org/licenses/MIT
+
+import {ModelBase, Options, Callback} from 'loopback-datasource-juggler';
+import {
+ RemoteMethodOptions,
+ RemotingErrorHook,
+ RemotingHook,
+ SharedClass,
+} from '../remoting';
+import {Lb3Application} from './lb3-application';
+import {Lb3Registry} from './lb3-registry';
+import {
+ ModelProperties,
+ ModelSettings,
+ PlainDataObject,
+ ComplexValue,
+} from './lb3-types';
+import {PersistedModelClass} from './lb3-persisted-model';
+
+// A workaround for https://github.com/Microsoft/TypeScript/issues/6480
+// I was not able to find a way how to mix arbitrary static properties
+// while preserving constructor signature. We need to contribute this
+// feature to TypeScript ourselves.
+export interface AnyStaticMethods {
+ [methodName: string]: Function;
+}
+
+export type ModelClass = typeof Model;
+
+export declare class Model extends ModelBase {
+ static app: Lb3Application;
+ static registry: Lb3Registry;
+ // TODO: sharedClass
+
+ static readonly super_: ModelClass;
+ static readonly settings: ModelSettings;
+ static sharedCtor?: Function & RemoteMethodOptions;
+ static sharedClass: SharedClass;
+
+ static setup(): void;
+ static beforeRemote(name: string, handler: RemotingHook): void;
+ static afterRemote(name: string, handler: RemotingHook): void;
+ static afterRemoteError(name: string, handler: RemotingErrorHook): void;
+ static remoteMethod(name: string, options: RemoteMethodOptions): void;
+ static disableRemoteMethodByName(name: string): void;
+
+ // TODO (later)
+ // - createOptionsFromRemotingContext
+ // - belongsToRemoting
+ // - hasOneRemoting
+ // - hasManyRemoting
+ // - scopeRemoting
+ // - nestRemoting
+
+ // TODO fix juggler typings and add this method to ModelBase
+ static extend(
+ modelName: string,
+ properties?: ModelProperties,
+ settings?: ModelSettings,
+ ): M;
+
+ // TODO fix juggler typings and include these property getters
+ static readonly base: ModelClass;
+
+ // LB3 models allow arbitrary additional properties by default
+ // NOTE(bajtos) I tried to allow consumers to specify a white-list of allowed
+ // properties, but failed to find a viable way. Also the complexity of such
+ // solution was quickly growing out of hands.
+ // tslint:disable-next-line:no-any
+ [prop: string]: any;
+}
+
+export function setupModelClass(registry: Lb3Registry): typeof Model {
+ const ModelCtor = registry.modelBuilder.define('Model') as typeof Model;
+ ModelCtor.registry = registry;
+
+ // TODO copy implementation of setup, before/after remote hooks, etc.
+ // from LB3's lib/model.js
+
+ ModelCtor.setup = function(this: ModelClass) {
+ // tslint:disable-next-line:no-shadowed-variable
+ const ModelCtor: ModelClass = this;
+ const Parent = ModelCtor.super_;
+
+ if (!ModelCtor.registry && Parent && Parent.registry) {
+ ModelCtor.registry = Parent.registry;
+ }
+
+ const options = ModelCtor.settings;
+
+ // support remoting prototype methods
+ // it's important to setup this function *before* calling `new SharedClass`
+ // otherwise remoting metadata from our base model is picked up
+ ModelCtor.sharedCtor = function(
+ data: PlainDataObject | undefined | null,
+ id: ComplexValue,
+ // tslint:disable-next-line:no-shadowed-variable
+ options: Options | undefined,
+ fn: Callback,
+ ) {
+ // tslint:disable-next-line:no-shadowed-variable no-invalid-this
+ const ModelCtor: ModelClass = this;
+
+ const isRemoteInvocationWithOptions =
+ typeof data !== 'object' &&
+ typeof id === 'object' &&
+ typeof options === 'function';
+ if (isRemoteInvocationWithOptions) {
+ // sharedCtor(id, options, fn)
+ fn = options as Callback;
+ options = id as Options;
+ id = data as ComplexValue;
+ data = null;
+ } else if (typeof data === 'function') {
+ // sharedCtor(fn)
+ fn = data;
+ data = null;
+ id = null;
+ options = undefined;
+ } else if (typeof id === 'function') {
+ // sharedCtor(data, fn)
+ // sharedCtor(id, fn)
+ fn = id;
+ options = undefined;
+
+ if (typeof data !== 'object') {
+ id = data;
+ data = null;
+ } else {
+ id = null;
+ }
+ }
+
+ if (id != null && data) {
+ const model = new ModelCtor(data);
+ model.id = id;
+ fn(null, model);
+ } else if (data) {
+ fn(null, new ModelCtor(data));
+ } else if (id != null) {
+ const filter = {};
+ (ModelCtor as PersistedModelClass).findById(
+ id,
+ filter,
+ options,
+ function(err, model) {
+ if (err) {
+ fn(err);
+ } else if (model) {
+ fn(null, model);
+ } else {
+ err = new Error(`could not find a model with id ${id}`);
+ err.statusCode = 404;
+ err.code = 'MODEL_NOT_FOUND';
+ fn(err);
+ }
+ },
+ );
+ } else {
+ fn(new Error('must specify an {{id}} or {{data}}'));
+ }
+ };
+
+ const idDesc = ModelCtor.modelName + ' id';
+ ModelCtor.sharedCtor.accepts = [
+ {
+ arg: 'id',
+ type: 'any',
+ required: true,
+ http: {source: 'path'},
+ description: idDesc,
+ },
+ // TODO: options from the context
+ // {arg: 'options', type: 'object', http: createOptionsViaModelMethod},
+ ];
+
+ ModelCtor.sharedCtor.http = [{path: '/:id'}];
+
+ ModelCtor.sharedCtor.returns = {root: true};
+
+ const remotingOptions = {};
+ Object.assign(remotingOptions, options.remoting);
+
+ // create a sharedClass
+ ModelCtor.sharedClass = new SharedClass(
+ ModelCtor.modelName,
+ ModelCtor,
+ remotingOptions,
+ );
+
+ ModelCtor.beforeRemote = function(name: string, fn: RemotingHook) {
+ // TODO
+ console.warn('beforeRemote hook not implemented yet');
+ };
+
+ // after remote hook
+ ModelCtor.afterRemote = function(name: string, fn: RemotingHook) {
+ // TODO
+ console.warn('afterRemote hook not implemented yet');
+ };
+
+ ModelCtor.afterRemoteError = function(name: string, fn: RemotingErrorHook) {
+ console.warn('afterRemoteError hook not implemented yet');
+ };
+
+ // TODO: resolve relation functions
+ };
+
+ ModelCtor.remoteMethod = function(this: ModelClass, name, options) {
+ if (options.isStatic === undefined) {
+ const m = name.match(/^prototype\.(.*)$/);
+ if (m) {
+ options.isStatic = false;
+ name = m[1];
+ } else {
+ options.isStatic = true;
+ }
+ }
+
+ // TODO: setupOptionsArgs
+
+ this.sharedClass.defineMethod(name, options);
+ // TODO this.emit('remoteMethodAdded', this.sharedClass);
+ };
+
+ ModelCtor.setup();
+
+ return ModelCtor;
+}
diff --git a/packages/v3compat/src/core/lb3-persisted-model.ts b/packages/v3compat/src/core/lb3-persisted-model.ts
new file mode 100644
index 000000000000..c02f9bf7dbd4
--- /dev/null
+++ b/packages/v3compat/src/core/lb3-persisted-model.ts
@@ -0,0 +1,362 @@
+// Copyright IBM Corp. 2018. All Rights Reserved.
+// Node module: @loopback/v3compat
+// This file is licensed under the MIT License.
+// License text available at https://opensource.org/licenses/MIT
+
+import {Constructor} from '@loopback/core';
+import {RemoteMethodOptions} from '../remoting';
+import {AnyStaticMethods, Model} from './lb3-model';
+import {Lb3Registry} from './lb3-registry';
+import {
+ Callback,
+ ComplexValue,
+ Filter,
+ Options,
+ PlainDataObject,
+ Where,
+} from './lb3-types';
+
+export type PersistedModelClass = typeof PersistedModel;
+
+export declare class PersistedModel extends Model {
+ // CREATE - single instance
+ static create(data: ModelData, options?: Options): Promise;
+ static create(data: ModelData, callback: Callback): void;
+ static create(
+ data: ModelData,
+ options: Options,
+ callback: Callback,
+ ): void;
+
+ // CREATE - array of instances
+ static create(data: ModelData, options?: Options): Promise;
+ static create(data: ModelData[], callback: Callback): void;
+ static create(
+ data: ModelData[],
+ options: Options,
+ callback: Callback,
+ ): void;
+
+ static upsert(data: ModelData, options?: Options): Promise;
+ static upsert(data: ModelData, callback: Callback): void;
+ static upsert(
+ data: ModelData,
+ options: Options,
+ callback: Callback,
+ ): void;
+
+ static updateOrCreate(
+ data: ModelData,
+ options?: Options,
+ ): Promise;
+ static updateOrCreate(
+ data: ModelData,
+ callback: Callback,
+ ): void;
+ static updateOrCreate(
+ data: ModelData,
+ options: Options,
+ callback: Callback,
+ ): void;
+
+ static patchOrCreate(
+ data: ModelData,
+ options?: Options,
+ ): Promise;
+ static patchOrCreate(
+ data: ModelData,
+ options: Options,
+ callback: Callback,
+ ): void;
+ static patchOrCreate(
+ data: ModelData,
+ callback: Callback,
+ ): void;
+
+ static upsertWithWhere(
+ where: Where,
+ data: ModelData,
+ options?: Options,
+ ): Promise;
+ static upsertWithWhere(
+ where: Where,
+ data: ModelData,
+ callback: Callback,
+ ): void;
+ static upsertWithWhere(
+ where: Where,
+ data: ModelData,
+ options: Options,
+ callback: Callback,
+ ): void;
+
+ // TODO: fix the API (callbacks vs promises)
+ static patchOrCreateWithWhere(
+ where: Where,
+ data: ModelData,
+ options?: Options,
+ callback?: Callback,
+ ): Promise;
+
+ // TODO: fix the API (callbacks vs promises)
+ static replaceOrCreate(
+ data: ModelData,
+ options?: Options,
+ callback?: Callback,
+ ): Promise;
+
+ // TODO: fix the API (callbacks vs promises)
+ static findOrCreate(
+ filter: Filter,
+ data: ModelData,
+ options?: Options,
+ callback?: Callback,
+ ): Promise;
+
+ // TODO: fix the API (callbacks vs promises)
+ static exists(
+ id: ComplexValue,
+ options?: Options,
+ callback?: Callback,
+ ): Promise;
+
+ // TODO: fix the API (callbacks vs promises)
+ static findById(
+ id: ComplexValue,
+ filter?: Filter,
+ options?: Options,
+ ): Promise;
+ static findById(
+ id: ComplexValue,
+ filter: Filter,
+ options: Options | undefined,
+ callback: Callback,
+ ): void;
+ static findById(id: ComplexValue, callback?: Callback): void;
+
+ static find(filter?: Filter, options?: Options): Promise;
+ static find(callback: Callback): void;
+ static find(filter: Filter, callback: Callback): void;
+ static find(
+ filter: Filter,
+ options: Options,
+ callback: Callback,
+ ): void;
+
+ // TODO: fix the API (callbacks vs promises)
+ static findOne(
+ filter?: Filter,
+ options?: Options,
+ callback?: Callback,
+ ): Promise;
+
+ // TODO: fix the API (callbacks vs promises)
+ static destroyAll(
+ where?: Where,
+ options?: Options,
+ callback?: Callback,
+ ): Promise;
+
+ // TODO: fix the API (callbacks vs promises)
+ static remove(
+ where?: Where,
+ options?: Options,
+ callback?: Callback,
+ ): Promise;
+
+ // TODO: fix the API (callbacks vs promises)
+ static deleteAll(
+ where?: Where,
+ options?: Options,
+ callback?: Callback,
+ ): Promise;
+
+ // TODO: fix the API (callbacks vs promises)
+ static updateAll(
+ where?: Where,
+ data?: ModelData,
+ options?: Options,
+ callback?: Callback,
+ ): Promise;
+
+ // TODO: fix the API (callbacks vs promises)
+ static update(
+ where?: Where,
+ data?: ModelData,
+ options?: Options,
+ callback?: Callback,
+ ): Promise;
+
+ // TODO: fix the API (callbacks vs promises)
+ static destroyById(
+ id: ComplexValue,
+ options?: Options,
+ callback?: Callback,
+ ): Promise;
+
+ // TODO: fix the API (callbacks vs promises)
+ static removeById(
+ id: ComplexValue,
+ options?: Options,
+ callback?: Callback,
+ ): Promise;
+
+ static deleteById(id: ComplexValue, options?: Options): Promise;
+ static deleteById(id: ComplexValue, callback: Callback): void;
+ static deleteById(
+ id: ComplexValue,
+ options: Options,
+ callback: Callback,
+ ): void;
+
+ // TODO: fix the API (callbacks vs promises)
+ static replaceById(
+ id: ComplexValue,
+ data: ModelData,
+ options?: Options,
+ callback?: Callback,
+ ): Promise;
+
+ // TODO: fix the API (callbacks vs promises)
+ static count(
+ where?: Where,
+ options?: Options,
+ callback?: Callback,
+ ): Promise;
+
+ // TODO: describe PersistedModel API
+ // - prototype.save
+ // - prototype.isNewRecord
+ // - prototype.delete
+ // - prototype.updateAttribute
+ // - prototype.updateAttributes
+ // - prototype.replaceAttributes
+ // - prototype.setId
+ // - prototype.getId
+ // - prototype.getIdName
+}
+
+export type ModelData = PlainDataObject | PersistedModel;
+
+export interface CountResult {
+ count: number;
+}
+
+export function setupPersistedModelClass(
+ registry: Lb3Registry,
+): typeof PersistedModel {
+ const ModelCtor = registry.getModel('Model');
+ const PersistedModelCtor = ModelCtor.extend(
+ 'PersistedModel',
+ );
+
+ // TODO copy impl of setup and DAO methods from LB3's lib/persisted-model.js
+ PersistedModelCtor.setup = function() {
+ // tslint:disable-next-line:no-shadowed-variable no-invalid-this
+ const PersistedModelCtor: PersistedModelClass = this;
+
+ // call Model.setup first
+ ModelCtor.setup.call(PersistedModelCtor);
+
+ setupPersistedModelRemoting(PersistedModelCtor);
+ };
+
+ PersistedModelCtor.setup();
+
+ return PersistedModelCtor;
+}
+
+// tslint:disable-next-line:no-shadowed-variable
+function setupPersistedModelRemoting(PersistedModel: PersistedModelClass) {
+ const typeName = PersistedModel.modelName;
+
+ PersistedModel.create = function() {
+ throw errorNotAttached(PersistedModel.modelName, 'create');
+ };
+
+ setRemoting(PersistedModel, 'create', {
+ description:
+ 'Create a new instance of the model and persist it into the data source.',
+ accessType: 'WRITE',
+ accepts: [
+ {
+ arg: 'data',
+ type: 'object',
+ model: typeName,
+ description: 'Model instance data',
+ http: {source: 'body'},
+ },
+ ],
+ returns: {arg: 'data', type: typeName, root: true},
+ http: {verb: 'post', path: '/'},
+ });
+
+ PersistedModel.find = function() {
+ throw errorNotAttached(PersistedModel.modelName, 'find');
+ };
+
+ setRemoting(PersistedModel, 'find', {
+ description:
+ 'Find all instances of the model matched by filter from the data source.',
+ accessType: 'READ',
+ accepts: [
+ {
+ arg: 'filter',
+ type: 'object',
+ description:
+ 'Filter defining fields, where, include, order, offset, and limit - must be a ' +
+ 'JSON-encoded string ({"something":"value"})',
+ },
+ ],
+ returns: {arg: 'data', type: [typeName], root: true},
+ http: {verb: 'get', path: '/'},
+ });
+
+ PersistedModel.findById = function() {
+ throw errorNotAttached(PersistedModel.modelName, 'find');
+ };
+
+ setRemoting(PersistedModel, 'findById', {
+ description: 'Find a model instance by {{id}} from the data source.',
+ accessType: 'READ',
+ accepts: [
+ {
+ arg: 'id',
+ type: 'any',
+ description: 'Model id',
+ required: true,
+ http: {source: 'path'},
+ },
+ {
+ arg: 'filter',
+ type: 'object',
+ description:
+ 'Filter defining fields and include - must be a JSON-encoded string (' +
+ '{"something":"value"})',
+ },
+ ],
+ returns: {arg: 'data', type: typeName, root: true},
+ http: {verb: 'get', path: '/:id'},
+ // TODO: rest: {after: convertNullToNotFoundError},
+ });
+
+ function setRemoting(
+ // tslint:disable-next-line:no-any
+ scope: any,
+ name: string,
+ options: RemoteMethodOptions,
+ ) {
+ const fn = scope[name];
+ fn._delegate = true;
+ options.isStatic = scope === PersistedModel;
+ PersistedModel.remoteMethod(name, options);
+ }
+}
+
+function errorNotAttached(modelName: string, methodName: string) {
+ return new Error(
+ `Cannot call ${modelName}.${methodName}().` +
+ ` The ${modelName} method has not been setup.` +
+ ' The PersistedModel has not been correctly attached to a DataSource!',
+ );
+}
diff --git a/packages/v3compat/src/core/lb3-registry.ts b/packages/v3compat/src/core/lb3-registry.ts
new file mode 100644
index 000000000000..b860bc94233c
--- /dev/null
+++ b/packages/v3compat/src/core/lb3-registry.ts
@@ -0,0 +1,132 @@
+// Copyright IBM Corp. 2018. All Rights Reserved.
+// Node module: @loopback/v3compat
+// This file is licensed under the MIT License.
+// License text available at https://opensource.org/licenses/MIT
+
+import {Application} from '@loopback/core';
+import * as assert from 'assert';
+import * as debugFactory from 'debug';
+import {ModelBuilder} from 'loopback-datasource-juggler';
+import {ModelClass, setupModelClass} from './lb3-model';
+import {setupPersistedModelClass} from './lb3-persisted-model';
+import {
+ DataSource,
+ DataSourceConfig,
+ ModelConfig,
+ ModelDefinition,
+ ModelProperties,
+ ModelSettings,
+} from './lb3-types';
+
+const debug = debugFactory('loopback:v3compat:mixin');
+
+export class Lb3Registry {
+ public modelBuilder: ModelBuilder = new ModelBuilder();
+
+ constructor(protected lb4app: Application) {
+ const ModelCtor = setupModelClass(this);
+ setupPersistedModelClass(this);
+
+ // Set the default model base class used by loopback-datasource-juggler.
+ // TODO: fix juggler typings and define defaultModelBaseClass property
+ // tslint:disable-next-line:no-any
+ (this.modelBuilder as any).defaultModelBaseClass = ModelCtor;
+ }
+
+ createModel(
+ name: string,
+ properties?: ModelProperties,
+ settings?: ModelSettings,
+ ): T;
+
+ createModel(definition: ModelDefinition): T;
+
+ createModel(
+ nameOrDefinition: string | ModelDefinition,
+ properties: ModelProperties = {},
+ settings: ModelSettings = {},
+ ): T {
+ let name: string;
+ if (typeof nameOrDefinition === 'string') {
+ name = nameOrDefinition;
+ } else {
+ const config = nameOrDefinition;
+ name = config.name;
+ properties = config.properties;
+ settings = buildModelOptionsFromConfig(config);
+
+ assert(
+ typeof name === 'string',
+ 'The model-config property `name` must be a string',
+ );
+ }
+
+ debug('Creating a new model %s with properties %j', name, properties);
+
+ if (!(settings.base || settings.super)) {
+ settings.base = 'PersistedModel';
+ }
+
+ // TODO: use the code from LB3's lib/registry.ts
+ const modelCtor = this.modelBuilder.define(
+ name,
+ properties,
+ settings,
+ ) as unknown;
+
+ return modelCtor as T;
+ }
+
+ createDataSource(name: string, config: DataSourceConfig): DataSource {
+ // TODO: use the code from LB3's lib/registry.ts
+ // (we need to override ds.createModel method)
+ return new DataSource(name, config, this.modelBuilder);
+ }
+
+ configureModel(modelCtor: ModelClass, config: ModelConfig) {
+ // TODO: use the code from LB3's lib/registry.ts
+ if (config.dataSource) {
+ if (config.dataSource instanceof DataSource) {
+ modelCtor.attachTo(config.dataSource);
+ } else {
+ assert.fail(
+ `${
+ modelCtor.modelName
+ } is referencing a dataSource that does not exist: "${
+ config.dataSource
+ }"`,
+ );
+ }
+ }
+ }
+
+ findModel(modelName: string | ModelClass): ModelClass | undefined {
+ if (typeof modelName === 'function') return modelName;
+ return this.modelBuilder.models[modelName] as ModelClass;
+ }
+
+ getModel(modelName: string | ModelClass): ModelClass {
+ const model = this.findModel(modelName);
+ if (model) return model;
+ throw new Error(`Model not found: ${modelName}`);
+ }
+}
+
+function buildModelOptionsFromConfig(config: ModelSettings) {
+ const options = Object.assign({}, config.options);
+ for (const key in config) {
+ if (['name', 'properties', 'options'].indexOf(key) !== -1) {
+ // Skip items which have special meaning
+ continue;
+ }
+
+ if (options[key] !== undefined) {
+ // When both `config.key` and `config.options.key` are set,
+ // use the latter one
+ continue;
+ }
+
+ options[key] = config[key];
+ }
+ return options;
+}
diff --git a/packages/v3compat/src/core/lb3-types.ts b/packages/v3compat/src/core/lb3-types.ts
new file mode 100644
index 000000000000..1de7be6e612c
--- /dev/null
+++ b/packages/v3compat/src/core/lb3-types.ts
@@ -0,0 +1,39 @@
+// Copyright IBM Corp. 2018. All Rights Reserved.
+// Node module: @loopback/v3compat
+// This file is licensed under the MIT License.
+// License text available at https://opensource.org/licenses/MIT
+
+import {DataSource, Options, AnyObject} from 'loopback-datasource-juggler';
+
+export {Callback, Filter, Where} from 'loopback-datasource-juggler';
+export {DataSource, Options};
+
+export type DataSourceConfig = Options;
+
+export type ModelProperties = AnyObject;
+export type ModelSettings = AnyObject;
+
+export interface ModelDefinition extends AnyObject {
+ name: string;
+ // TODO: describe common options and property definition format
+}
+
+export interface ModelConfig extends AnyObject {
+ dataSource: string | DataSource | null;
+ // TODO: describe other settings, e.g. "public", "methods", etc.
+}
+
+// A type-safe way how to express plain-data objects:
+// - safer than PersistedData from juggler, PDO does not allow functions
+// - does not require explicit type casts, e.g. when calling MyModel.create()
+export interface PlainDataObject {
+ [prop: string]: ComplexValue;
+}
+
+export type ComplexValue =
+ | PrimitiveValue // a value
+ | PrimitiveValue[] // an array of values
+ | {[prop: string]: ComplexValue} // an object with values
+ | [{[prop: string]: ComplexValue}]; // an array of objects
+
+export type PrimitiveValue = undefined | null | number | string;
diff --git a/packages/v3compat/src/index.ts b/packages/v3compat/src/index.ts
new file mode 100644
index 000000000000..e79a87105b51
--- /dev/null
+++ b/packages/v3compat/src/index.ts
@@ -0,0 +1,9 @@
+// Copyright IBM Corp. 2018. All Rights Reserved.
+// Node module: @loopback/v3compat
+// This file is licensed under the MIT License.
+// License text available at https://opensource.org/licenses/MIT
+
+export * from './compat.mixin';
+export * from './core';
+export * from './remoting';
+export * from './boot';
diff --git a/packages/v3compat/src/remoting/README.md b/packages/v3compat/src/remoting/README.md
new file mode 100644
index 000000000000..6aad1a983827
--- /dev/null
+++ b/packages/v3compat/src/remoting/README.md
@@ -0,0 +1,4 @@
+# v3compat/remoting
+
+Source code migrated from
+[strong-remoting](https://github.com/strongloop/strong-remoting).
diff --git a/packages/v3compat/src/remoting/index.ts b/packages/v3compat/src/remoting/index.ts
new file mode 100644
index 000000000000..72a28dd87612
--- /dev/null
+++ b/packages/v3compat/src/remoting/index.ts
@@ -0,0 +1,9 @@
+// Copyright IBM Corp. 2018. All Rights Reserved.
+// Node module: @loopback/v3compat
+// This file is licensed under the MIT License.
+// License text available at https://opensource.org/licenses/MIT
+
+export * from './remoting-types';
+export * from './shared-class';
+export * from './shared-method';
+export * from './rest-adapter';
diff --git a/packages/v3compat/src/remoting/lb3-model-controller.ts b/packages/v3compat/src/remoting/lb3-model-controller.ts
new file mode 100644
index 000000000000..9f5a9c152d9a
--- /dev/null
+++ b/packages/v3compat/src/remoting/lb3-model-controller.ts
@@ -0,0 +1,79 @@
+// Copyright IBM Corp. 2018. All Rights Reserved.
+// Node module: @loopback/v3compat
+// This file is licensed under the MIT License.
+// License text available at https://opensource.org/licenses/MIT
+
+import {inject} from '@loopback/core';
+import {OperationArgs, Request, Response, RestBindings} from '@loopback/rest';
+import * as debugFactory from 'debug';
+import {SharedMethod} from './shared-method';
+
+const debug = debugFactory('loopback:v3compat:rest-adapter');
+
+export class Lb3ModelController {
+ @inject(RestBindings.Http.REQUEST)
+ protected request: Request;
+
+ @inject(RestBindings.Http.RESPONSE)
+ protected response: Response;
+
+ // TODO: a property for strong-remoting's HttpContext
+
+ [key: string]: Function | Request | Response;
+
+ protected buildMethodArguments(
+ sharedMethod: SharedMethod,
+ inputArgs: OperationArgs,
+ ) {
+ const finalArgs: OperationArgs = [];
+ for (const argSpec of sharedMethod.accepts) {
+ const source = argSpec.http && argSpec.http.source;
+ switch (source) {
+ case 'req':
+ finalArgs.push(this.request);
+ break;
+ case 'res':
+ finalArgs.push(this.response);
+ break;
+ default:
+ finalArgs.push(inputArgs.shift());
+ }
+ }
+ return finalArgs;
+ }
+
+ protected invokeStaticMethod(
+ sharedMethod: SharedMethod,
+ argsFromSpec: OperationArgs,
+ ) {
+ debug('%s initial args %j', sharedMethod.stringName, argsFromSpec);
+ const args = this.buildMethodArguments(sharedMethod, argsFromSpec);
+ debug('resolved args %j', args);
+
+ // TODO: beforeRemote, afterRemote, afterRemoteError hooks
+
+ const cb = createPromiseCallback();
+ args.push(cb);
+
+ const handler = sharedMethod.getFunction();
+ return handler.apply(sharedMethod.ctor, args) || cb.promise;
+ }
+}
+
+function createPromiseCallback() {
+ let cb: Function = OOPS;
+ // tslint:disable-next-line:no-any
+ const promise = new Promise(function(resolve, reject) {
+ // tslint:disable-next-line:no-any
+ cb = function(err: any, result: any) {
+ if (err) return reject(err);
+ return resolve(result);
+ };
+ });
+ return Object.assign(cb, {promise});
+}
+
+// Dummy function to get rid of TS compiler warning
+function OOPS() {
+ throw new Error('NEVER GET HERE');
+}
diff --git a/packages/v3compat/src/remoting/remoting-types.ts b/packages/v3compat/src/remoting/remoting-types.ts
new file mode 100644
index 000000000000..2333fc8e70a6
--- /dev/null
+++ b/packages/v3compat/src/remoting/remoting-types.ts
@@ -0,0 +1,104 @@
+// Copyright IBM Corp. 2018. All Rights Reserved.
+// Node module: @loopback/v3compat
+// This file is licensed under the MIT License.
+// License text available at https://opensource.org/licenses/MIT
+
+// FIXME Use a real context class/interface from strong-remoting here
+// tslint:disable-next-line:no-any
+export type RemotingContext = any;
+
+/**
+ * The handler function passed to `Model.beforeRemote` and `Model.afterRemote`.
+ */
+export type RemotingHook = RemotingHookCallback | RemotingHookPromise;
+
+export type RemotingHookPromise = (
+ ctx: RemotingContext,
+ unused: void,
+) => Promise;
+export type RemotingHookCallback = (
+ ctx: RemotingContext,
+ unused: void,
+ next: (err?: Error) => void,
+) => void;
+
+/**
+ * The handler function passed to `Model.afterRemoteError`.
+ */
+export type RemotingErrorHook =
+ | RemotingErrorHookCallback
+ | RemotingErrorHookPromise;
+
+export type RemotingErrorHookPromise = (ctx: RemotingContext) => Promise;
+export type RemotingErrorHookCallback = (
+ ctx: RemotingContext,
+ next: (err?: Error) => void,
+) => void;
+
+export interface RemoteClassOptions {
+ // TODO: are there any well-known class options?
+}
+
+export interface RemoteMethodOptions {
+ aliases?: string[];
+ isStatic?: boolean;
+ accepts?: ParameterOptions | ParameterOptions[];
+ returns?: RetvalOptions | RetvalOptions[];
+ // TODO: errors
+ description?: string;
+ notes?: string;
+ documented?: boolean;
+ http?: RestRouteSettings | RestRouteSettings[];
+ // TODO: rest
+ shared?: boolean;
+
+ // user-defined extensions
+ [customKey: string]: unknown;
+}
+
+export interface ParameterOptions {
+ arg?: string;
+ name?: string; // alias for "arg"
+ type?: string | [string];
+ model?: string;
+ required?: boolean;
+ description?: string;
+ http?: RestParameterMapping; // TODO: support function `(ctx) => value`
+}
+
+export type RestParameterSource =
+ | 'req'
+ | 'res'
+ | 'body'
+ | 'form'
+ | 'query'
+ | 'path'
+ | 'header'
+ | 'context';
+
+export interface RestParameterMapping {
+ source?: RestParameterSource;
+}
+
+export interface RetvalOptions {
+ arg?: string;
+ type?: string | [string];
+ model?: string;
+ root?: boolean;
+ description?: string;
+ http?: RestRetvalMapping;
+}
+
+export interface RestRetvalMapping {
+ target?: 'status' | 'header';
+}
+
+export interface RestRouteSettings {
+ path?: string;
+ verb?: string;
+}
+
+export interface RestRoute extends RestRouteSettings {
+ path: string;
+ verb: string;
+}
diff --git a/packages/v3compat/src/remoting/rest-adapter.ts b/packages/v3compat/src/remoting/rest-adapter.ts
new file mode 100644
index 000000000000..ab5b11515688
--- /dev/null
+++ b/packages/v3compat/src/remoting/rest-adapter.ts
@@ -0,0 +1,104 @@
+// Copyright IBM Corp. 2018. All Rights Reserved.;
+// Node module: @loopback/v3compat
+// This file is licensed under the MIT License.
+// License text available at https://opensource.org/licenses/MIT
+
+import {Application} from '@loopback/core';
+import {HttpErrors, operation, OperationArgs} from '@loopback/rest';
+import * as debugFactory from 'debug';
+import {
+ buildRemoteMethodSpec,
+ convertPathFragments,
+ convertVerb,
+ getClassTags,
+} from '../specgen';
+import {Lb3ModelController} from './lb3-model-controller';
+import {SharedClass} from './shared-class';
+import {SharedMethod, joinUrlPaths} from './shared-method';
+
+const debug = debugFactory('loopback:v3compat:rest-adapter');
+
+export class RestAdapter {
+ // TODO: make restApiRoot configurable
+ readonly restApiRoot = '/api';
+
+ constructor(private readonly _app: Application) {}
+
+ registerSharedClass(sharedClass: SharedClass): void {
+ debug('Registering REST API for sharedClass %s', sharedClass.name);
+ const sharedMethods = sharedClass.methods();
+ const tags = getClassTags(sharedClass);
+ const controllerClass = createModelController(sharedClass.name);
+
+ for (const method of sharedMethods) {
+ this.registerSharedMethod(method, tags, controllerClass);
+ }
+
+ this._app.controller(controllerClass);
+ }
+
+ registerSharedMethod(
+ sharedMethod: SharedMethod,
+ tags: string[] | undefined,
+ controllerClass: typeof Lb3ModelController,
+ ) {
+ debug(' %s', sharedMethod.stringName);
+
+ // TODO: handle methods exposed at multiple http endpoints
+ const {verb, path} = sharedMethod.getEndpoints()[0];
+ const spec = buildRemoteMethodSpec(sharedMethod, verb, path, tags);
+
+ const prefix = sharedMethod.isStatic ? '' : 'prototype$';
+ const key = prefix + sharedMethod.name;
+
+ // Define the controller method to invoke the shared method
+ controllerClass.prototype[key] = async function(
+ this: Lb3ModelController,
+ ...args: OperationArgs
+ ) {
+ if (!sharedMethod.isStatic) {
+ // TODO: invoke sharedCtor to obtain the model instance
+ throw new HttpErrors.NotImplemented(
+ 'Instance-level shared methods are not supported yet.',
+ );
+ }
+
+ return this.invokeStaticMethod(sharedMethod, args);
+ };
+
+ debug(' %s %s %j', verb, path, spec);
+
+ // Define OpenAPI Spec Operation for the shared method
+ operation(
+ convertVerb(verb),
+ joinUrlPaths(this.restApiRoot, convertPathFragments(path)),
+ spec,
+ )(controllerClass.prototype, key, {});
+ }
+}
+
+function createModelController(modelName: string): typeof Lb3ModelController {
+ // A simple sanitization to handle most common characters
+ // that are used in model names but cannot be used as a function/class name.
+ // Note that the rules for valid JS indentifiers are way too complex,
+ // implementing a fully spec-compliant sanitization is not worth the effort.
+ // See https://mathiasbynens.be/notes/javascript-identifiers-es6
+ // and createModelClassCtor() in loopback-datasource-juggler's model-builder
+ const name = modelName.replace(/[-.:]/g, '_');
+
+ try {
+ const factory = new Function(
+ 'ControllerBase',
+ `return class ${name} extends ControllerBase {}`,
+ );
+ return factory(Lb3ModelController);
+ } catch (err) {
+ if (err.name === 'SyntaxError') {
+ // modelName is not a valid function/class name, e.g. 'grand-child'
+ // and our simple sanitization was not good enough.
+ // Falling back to an anonymous class
+ return class extends Lb3ModelController {};
+ }
+ throw err;
+ }
+}
diff --git a/packages/v3compat/src/remoting/shared-class.ts b/packages/v3compat/src/remoting/shared-class.ts
new file mode 100644
index 000000000000..76db5b5b30c0
--- /dev/null
+++ b/packages/v3compat/src/remoting/shared-class.ts
@@ -0,0 +1,150 @@
+/// Copyright IBM Corp. 2018. All Rights Reserved.
+// Node module: @loopback/v3compat
+// This file is licensed under the MIT License.
+// License text available at https://opensource.org/licenses/MIT
+
+import {
+ RemoteClassOptions,
+ RestRouteSettings,
+ RemoteMethodOptions,
+ RestRoute,
+} from './remoting-types';
+import {SharedMethod} from './shared-method';
+import assert = require('assert');
+
+export type CtorFunction = Function & {
+ http?: RestRouteSettings | RestRouteSettings[];
+ sharedCtor?: Function;
+ settings?: {
+ swagger?: {
+ tag?: {
+ name?: string;
+ };
+ };
+ };
+
+ [staticKey: string]: Function | unknown;
+};
+
+// See strong-remoting's lib/shared-class.js
+export class SharedClass {
+ private readonly _methods: SharedMethod[] = [];
+ readonly http: RestRoute;
+ readonly sharedCtor: SharedMethod;
+
+ // TODO: _resolvers, _disabledMethods
+
+ constructor(
+ public name: string,
+ public ctor: CtorFunction,
+ public options: RemoteClassOptions,
+ ) {
+ const http = ctor && ctor.http;
+
+ const defaultHttp: RestRoute = {
+ path: '/' + this.name,
+ verb: 'POST',
+ };
+
+ this.http = Object.assign(
+ // set http.path using the name unless it is defined
+ defaultHttp,
+ Array.isArray(http)
+ ? http[0] // LB3 does not support multiple http entries for a class
+ : http,
+ );
+
+ if (typeof ctor === 'function' && ctor.sharedCtor) {
+ this.sharedCtor = new SharedMethod(ctor.sharedCtor, 'sharedCtor', this);
+ }
+
+ assert(
+ this.name,
+ 'must include a remoteNamespace when creating a SharedClass',
+ );
+ }
+
+ methods(/*TODO: options*/) {
+ const ctor = this.ctor;
+ const methods: SharedMethod[] = [];
+ const sc = this;
+ const functionIndex: Function[] = [];
+
+ // static methods
+ eachRemoteFunctionInObject(ctor, function(fn, name) {
+ if (functionIndex.indexOf(fn) === -1) {
+ functionIndex.push(fn);
+ } else {
+ const sharedMethod = find(methods, fn);
+ sharedMethod!.addAlias(name);
+ return;
+ }
+ methods.push(SharedMethod.fromFunction(fn, name, sc, true));
+ });
+
+ // instance methods
+ eachRemoteFunctionInObject(ctor.prototype, function(fn, name) {
+ if (functionIndex.indexOf(fn) === -1) {
+ functionIndex.push(fn);
+ } else {
+ const sharedMethod = find(methods, fn);
+ sharedMethod!.addAlias(name);
+ return;
+ }
+
+ methods.push(SharedMethod.fromFunction(fn, name, sc));
+ });
+
+ // TODO: resolvers
+
+ methods.push(...this._methods);
+
+ // TODO: optionally filter disabled methods
+
+ return methods;
+ }
+
+ defineMethod(name: string, options: RemoteMethodOptions, fn?: Function) {
+ const sharedMethod = new SharedMethod(fn, name, this, options);
+ this._methods.push(sharedMethod);
+ }
+}
+
+function eachRemoteFunctionInObject(
+ // tslint:disable-next-line:no-any
+ obj: any,
+ f: (fn: Function, key: string) => void,
+) {
+ if (!obj) return;
+
+ for (const key in obj) {
+ if (key === 'super_') {
+ // Skip super class
+ continue;
+ }
+ let fn;
+
+ try {
+ fn = obj[key];
+ } catch (e) {}
+
+ // HACK: [rfeng] Do not expose model constructors
+ // We have the following usage to set other model classes as properties
+ // User.email = Email;
+ // User.accessToken = AccessToken;
+ // Both Email and AccessToken can have shared flag set to true
+ if (typeof fn === 'function' && fn.shared && !fn.modelName) {
+ f(fn, key);
+ }
+ }
+}
+function find(
+ methods: SharedMethod[],
+ fn: Function,
+ isStatic: boolean = false,
+) {
+ for (const method of methods) {
+ if (method.isDelegateFor(fn, isStatic)) return method;
+ }
+ return null;
+}
diff --git a/packages/v3compat/src/remoting/shared-method.ts b/packages/v3compat/src/remoting/shared-method.ts
new file mode 100644
index 000000000000..b4b316536f58
--- /dev/null
+++ b/packages/v3compat/src/remoting/shared-method.ts
@@ -0,0 +1,180 @@
+/// Copyright IBM Corp. 2018. All Rights Reserved.
+// Node module: @loopback/v3compat
+// This file is licensed under the MIT License.
+// License text available at https://opensource.org/licenses/MIT
+
+import * as assert from 'assert';
+import {
+ ParameterOptions,
+ RemoteMethodOptions,
+ RestRoute,
+ RetvalOptions,
+} from './remoting-types';
+import {CtorFunction, SharedClass} from './shared-class';
+
+export class SharedMethod {
+ readonly aliases: string[];
+ readonly isStatic: boolean;
+ readonly accepts: ParameterOptions[];
+ readonly returns: RetvalOptions[];
+ readonly description?: string;
+ readonly notes?: string;
+ readonly documented: boolean;
+ readonly http: RestRoute[];
+ readonly shared: boolean;
+ readonly ctor?: CtorFunction;
+ readonly sharedCtor: SharedMethod;
+ readonly isSharedCtor: boolean;
+ readonly stringName: string;
+
+ // user-defined extensions
+ [customKey: string]: unknown;
+
+ constructor(
+ public readonly fn: (Function & RemoteMethodOptions) | undefined,
+ public readonly name: string,
+ public readonly sharedClass: SharedClass,
+ public readonly options: RemoteMethodOptions = {},
+ ) {
+ const fnMeta: RemoteMethodOptions = fn || {};
+ this.aliases = options.aliases || [];
+ this.isStatic = options.isStatic || false;
+ const accepts = options.accepts || fnMeta.accepts || [];
+ const returns = options.returns || fnMeta.returns || [];
+ // TODO: this.errors = options.errors || fn.errors || [];
+ this.description = options.description || fnMeta.description;
+ // TODO: this.accessType = options.accessType || fn.accessType;
+ this.notes = options.notes || fnMeta.notes;
+ this.documented =
+ options.documented !== false && fnMeta.documented !== false;
+ // TODO: this.rest = options.rest || fn.rest || {};
+ this.shared = isShared(fnMeta, options);
+ this.sharedClass = sharedClass;
+
+ if (sharedClass) {
+ this.ctor = sharedClass.ctor;
+ this.sharedCtor = sharedClass.sharedCtor;
+ } else {
+ assert(
+ !!fn,
+ 'A shared method not attached to any class must provide the function.',
+ );
+ }
+ this.isSharedCtor = name === 'sharedCtor';
+
+ this.accepts = accepts && !Array.isArray(accepts) ? [accepts] : accepts;
+ this.accepts.forEach(normalizeArgumentDescriptor);
+
+ this.returns = returns && !Array.isArray(returns) ? [returns] : returns;
+ this.returns.forEach(normalizeArgumentDescriptor);
+
+ const http = options.http || fnMeta.http || {};
+ this.http = (http && !Array.isArray(http) ? [http] : http).map(h => ({
+ verb: h.verb || 'POST',
+ path: h.path || '/' + name,
+ }));
+
+ // TODO: handle stream types
+ // TODO: handle error.options
+
+ if (/^prototype\./.test(name)) {
+ const msg =
+ 'Incorrect API usage. Shared methods on prototypes should be ' +
+ 'created via `new SharedMethod(fn, "name", { isStatic: false })`';
+ throw new Error(msg);
+ }
+
+ this.stringName =
+ (sharedClass ? sharedClass.name : '') +
+ (this.isStatic ? '.' : '.prototype.') +
+ name;
+
+ // Include any remaining metadata to support custom user-defined extensions
+ for (const key in options) {
+ if (key in this) continue;
+ this[key] = options[key];
+ }
+ }
+
+ addAlias(alias: string) {
+ if (this.aliases.indexOf(alias) !== -1) return;
+ this.aliases.push(alias);
+ }
+
+ getFunction(): Function {
+ if (!this.ctor) return this.fn!;
+
+ let fn: Function;
+ if (this.isStatic) {
+ fn = this.ctor[this.name] as Function;
+ } else {
+ fn = this.ctor.prototype[this.name];
+ }
+
+ return fn || this.fn;
+ }
+
+ isDelegateFor(suspect: Function | string, isStatic: boolean = false) {
+ if (suspect!) {
+ switch (typeof suspect) {
+ case 'function':
+ return this.getFunction() === suspect;
+ case 'string':
+ if (this.isStatic !== isStatic) return false;
+ return this.name === suspect || this.aliases.indexOf(suspect) !== -1;
+ }
+ }
+
+ return false;
+ }
+
+ static fromFunction(
+ fn: Function & RemoteMethodOptions,
+ name: string,
+ sharedClass: SharedClass,
+ isStatic: boolean = false,
+ ) {
+ return new SharedMethod(fn, name, sharedClass, {
+ isStatic: isStatic,
+ accepts: fn.accepts,
+ returns: fn.returns,
+ errors: fn.errors,
+ description: fn.description,
+ notes: fn.notes,
+ http: fn.http,
+ rest: fn.rest,
+ });
+ }
+
+ getEndpoints(): RestRoute[] {
+ return this.http.map(route => ({
+ verb: route.verb,
+ // TODO: normalize HTTP paths (if configured)
+ path: joinUrlPaths(this.sharedClass.http.path, route.path),
+ }));
+ }
+}
+
+function isShared(fn: RemoteMethodOptions, options: RemoteMethodOptions) {
+ let shared = options.shared;
+ if (shared === undefined) shared = true;
+ if (fn.shared === false) shared = false;
+ return shared;
+}
+
+function normalizeArgumentDescriptor(desc: ParameterOptions | RetvalOptions) {
+ if (desc.type === 'array') desc.type = ['any'];
+}
+
+export function joinUrlPaths(
+ left: string | undefined,
+ right: string | undefined,
+): string {
+ if (!left) return right || '/';
+ if (!right || right === '/') return left;
+
+ const glue = left[left.length - 1] + right[0];
+ if (glue === '//') return left + right.slice(1);
+ else if (glue[0] === '/' || glue[1] === '/') return left + right;
+ else return left + '/' + right;
+}
diff --git a/packages/v3compat/src/specgen/README.md b/packages/v3compat/src/specgen/README.md
new file mode 100644
index 000000000000..5724db8d4255
--- /dev/null
+++ b/packages/v3compat/src/specgen/README.md
@@ -0,0 +1,4 @@
+# v3compat/core
+
+Source code migrated from
+[loopback-swagger](https://github.com/strongloop/loopback-swagger).
diff --git a/packages/v3compat/src/specgen/index.ts b/packages/v3compat/src/specgen/index.ts
new file mode 100644
index 000000000000..f8b96f9c642f
--- /dev/null
+++ b/packages/v3compat/src/specgen/index.ts
@@ -0,0 +1,6 @@
+// Copyright IBM Corp. 2018. All Rights Reserved.
+// Node module: @loopback/v3compat
+// This file is licensed under the MIT License.
+// License text available at https://opensource.org/licenses/MIT
+
+export * from './lb3-openapi';
diff --git a/packages/v3compat/src/specgen/lb3-openapi.ts b/packages/v3compat/src/specgen/lb3-openapi.ts
new file mode 100644
index 000000000000..5a93fee3ef77
--- /dev/null
+++ b/packages/v3compat/src/specgen/lb3-openapi.ts
@@ -0,0 +1,250 @@
+/// Copyright IBM Corp. 2018. All Rights Reserved.
+// Node module: @loopback/v3compat
+// This file is licensed under the MIT License.
+// License text available at https://opensource.org/licenses/MIT
+
+import {
+ OperationObject,
+ ParameterObject,
+ ResponseObject,
+ SchemaObject,
+ ParameterLocation,
+ RequestBodyObject,
+} from '@loopback/rest';
+import {
+ ParameterOptions,
+ RetvalOptions,
+ SharedMethod,
+ SharedClass,
+ RestParameterSource,
+} from '../remoting';
+import {AssertionError} from 'assert';
+import * as debugFactory from 'debug';
+
+const debug = debugFactory('loopback:v3compat:openapi');
+
+export function buildRemoteMethodSpec(
+ sharedMethod: SharedMethod,
+ verb: string,
+ path: string,
+ tags: string[] | undefined,
+) {
+ const defaultSource = verb.toLowerCase() === 'get' ? 'query' : 'form';
+ const parameters = convertAcceptsToOpenApi(
+ sharedMethod.accepts,
+ defaultSource,
+ );
+ const requestBody = buildRequestBodyObject(sharedMethod.accepts);
+ const response = convertReturnsToOpenApi(sharedMethod.returns);
+ // TODO: support custom status codes
+ // TODO: filter out status/header retvals before deciding the status code
+ const statusCode = sharedMethod.returns.length > 0 ? 200 : 204;
+
+ // TODO: ensure the id is unique and we use the same algorithm as in LB3
+ const operationId =
+ sharedMethod.stringName.replace(/\./g, '_') +
+ '__' +
+ verb +
+ path.replace(/[\/:]+/g, '_');
+
+ const spec: OperationObject = {
+ tags,
+ summary: sharedMethod.description,
+ notes: sharedMethod.notes,
+ operationId,
+ parameters,
+ requestBody,
+ responses: {
+ [statusCode]: response,
+ },
+ };
+ return spec;
+}
+
+function convertAcceptsToOpenApi(
+ accepts: ParameterOptions[],
+ defaultSource: RestParameterSource,
+): ParameterObject[] {
+ // Filter out parameters that are generated from the incoming request,
+ // or generated by functions that use those resources.
+ accepts = accepts.filter(function(arg) {
+ // Allow undocumenting a param.
+ // TODO if (arg.documented === false) return false;
+ // Below conditions are only for 'http'
+ if (!arg.http) return true;
+ // Don't show derived arguments.
+ if (typeof arg.http === 'function') return false;
+ // Don't show arguments set to the incoming http request.
+ // Please note that body needs to be shown, such as User.create().
+ if (
+ arg.http.source === 'req' ||
+ arg.http.source === 'res' ||
+ arg.http.source === 'context' ||
+ // added in v3compat
+ arg.http.source === 'body'
+ ) {
+ return false;
+ }
+ return true;
+ });
+
+ return accepts.map(a => buildParameterSchema(a, defaultSource));
+}
+
+function buildRequestBodyObject(
+ accepts: ParameterOptions[],
+): RequestBodyObject | undefined {
+ const bodyArgs = accepts.filter(a => a.http && a.http.source === 'body');
+ if (bodyArgs.length < 1) return undefined;
+ if (bodyArgs.length > 1)
+ throw new Error(
+ 'v3compat does not support multiple request-body parameters',
+ );
+ const arg = bodyArgs[0];
+ const schema = buildSchemaFromRemotingType(arg.type);
+
+ return {
+ description: arg.description,
+ content: {
+ 'application/json': {schema},
+ },
+ required: arg.required,
+ };
+}
+
+function convertReturnsToOpenApi(returns: RetvalOptions[]): ResponseObject {
+ const description = 'Request was successful.';
+
+ const schema: SchemaObject =
+ returns.length === 1 && returns[0].root
+ ? buildSchemaFromRemotingType(returns[0].type)
+ : // TODO: describe the actual return values
+ {type: 'object', additionalProperties: true};
+
+ return {
+ description,
+ content: {
+ 'application/json': {schema},
+ },
+ };
+}
+
+function buildParameterSchema(
+ paramSpec: ParameterOptions,
+ defaultSource: RestParameterSource,
+): ParameterObject {
+ const name = paramSpec.name || paramSpec.arg;
+ if (!name) {
+ throw new AssertionError({
+ message: 'Parameter option "arg" or "name" is required.',
+ });
+ }
+
+ const httpSource = (paramSpec.http && paramSpec.http.source) || defaultSource;
+ if (httpSource === 'form') {
+ throw new Error(
+ `Parameter source "form" is not supported by OpenAPIv3 and LoopBack4. Param: ${name}.`,
+ );
+ }
+
+ const schema = buildSchemaFromRemotingType(paramSpec.type);
+ debug(
+ 'Converted param %j type %j into schema %j',
+ name,
+ paramSpec.type,
+ schema,
+ );
+
+ const result: ParameterObject = {
+ name,
+ in: httpSource as ParameterLocation,
+ description: paramSpec.description, // TODO: handle arrays/multi-line text
+ required: httpSource === 'path' ? true : !!paramSpec.required,
+ schema,
+ };
+
+ if (schema.type === 'object' && httpSource === 'query')
+ result.style = 'deepObject';
+
+ return result;
+}
+
+// TODO: replace this with a better implementation.
+// Use lib/specgen/schema-builder.js for strong-remoting types
+// Consider using @loopback/repository-json-schema for models
+function buildSchemaFromRemotingType(
+ type: string | [string] | undefined,
+): SchemaObject {
+ // TODO: handle this edge case in a better way
+ if (!type) return {type: 'string'};
+
+ if (Array.isArray(type)) {
+ return {
+ type: 'array',
+ items: buildSchemaFromRemotingType(type[0]),
+ };
+ }
+
+ type = type.toLowerCase();
+ switch (type) {
+ case 'date':
+ return {
+ type: 'string',
+ format: 'date-time',
+ };
+ case 'boolean':
+ case 'integer':
+ case 'number':
+ case 'string':
+ case 'object':
+ return {type};
+ case 'any':
+ // TODO: find a better schema for "any" object to allow numbers, etc.
+ return {type: 'string'};
+ default:
+ return {type: 'object', additionalProperties: true};
+ }
+}
+
+export function getClassTags(sharedClass: SharedClass) {
+ const tags = [];
+
+ const swaggerSettings =
+ (sharedClass &&
+ sharedClass.ctor &&
+ sharedClass.ctor.settings &&
+ sharedClass.ctor.settings.swagger) ||
+ {};
+
+ if (swaggerSettings.tag && swaggerSettings.tag.name) {
+ tags.push(swaggerSettings.tag.name);
+ } else if (sharedClass && sharedClass.name) {
+ tags.push(sharedClass.name);
+ }
+
+ return tags;
+}
+
+export function convertPathFragments(path: string) {
+ return path
+ .split('/')
+ .map(function(fragment) {
+ if (fragment.charAt(0) === ':') {
+ return '{' + fragment.slice(1) + '}';
+ }
+ return fragment;
+ })
+ .join('/');
+}
+
+export function convertVerb(verb: string) {
+ if (verb.toLowerCase() === 'all') {
+ return 'post';
+ }
+
+ if (verb.toLowerCase() === 'del') {
+ return 'delete';
+ }
+
+ return verb.toLowerCase();
+}
diff --git a/packages/v3compat/test/acceptance/compat-app.ts b/packages/v3compat/test/acceptance/compat-app.ts
new file mode 100644
index 000000000000..3942839d77e0
--- /dev/null
+++ b/packages/v3compat/test/acceptance/compat-app.ts
@@ -0,0 +1,16 @@
+// Copyright IBM Corp. 2018. All Rights Reserved.
+// Node module: @loopback/v3compat
+// This file is licensed under the MIT License.
+// License text available at https://opensource.org/licenses/MIT
+
+import {BootMixin} from '@loopback/boot';
+import {RestApplication, RestServerConfig} from '@loopback/rest';
+import {givenHttpServerConfig} from '@loopback/testlab';
+import {CompatMixin} from '../..';
+
+export class CompatApp extends CompatMixin(BootMixin(RestApplication)) {}
+
+export async function createCompatApplication() {
+ const rest: RestServerConfig = Object.assign({}, givenHttpServerConfig());
+ return new CompatApp({rest});
+}
diff --git a/packages/v3compat/test/acceptance/datasource.acceptance.ts b/packages/v3compat/test/acceptance/datasource.acceptance.ts
new file mode 100644
index 000000000000..062b8f851a12
--- /dev/null
+++ b/packages/v3compat/test/acceptance/datasource.acceptance.ts
@@ -0,0 +1,21 @@
+// Copyright IBM Corp. 2018. All Rights Reserved.
+// Node module: @loopback/v3compat
+// This file is licensed under the MIT License.
+// License text available at https://opensource.org/licenses/MIT
+
+import {expect} from '@loopback/testlab';
+import {CompatApp, createCompatApplication} from './compat-app';
+
+describe('v3compat (acceptance)', () => {
+ let app: CompatApp;
+
+ beforeEach(async function givenApplication() {
+ app = await createCompatApplication();
+ });
+
+ it('registers datasource with LB4 app', () => {
+ const created = app.v3compat.dataSource('db', {connector: 'memory'});
+ const bound = app.getSync('datasources.db');
+ expect(bound).to.equal(created);
+ });
+});
diff --git a/packages/v3compat/test/acceptance/lb3-boot.acceptance.ts b/packages/v3compat/test/acceptance/lb3-boot.acceptance.ts
new file mode 100644
index 000000000000..0962f94b8684
--- /dev/null
+++ b/packages/v3compat/test/acceptance/lb3-boot.acceptance.ts
@@ -0,0 +1,70 @@
+// Copyright IBM Corp. 2018. All Rights Reserved.
+// Node module: @loopback/v3compat
+// This file is licensed under the MIT License.
+// License text available at https://opensource.org/licenses/MIT
+
+import {BootMixin} from '@loopback/boot';
+import {RestApplication} from '@loopback/rest';
+import * as path from 'path';
+import {CompatMixin} from '../..';
+import {createCompatApplication} from './compat-app';
+import {expect} from '@loopback/testlab';
+import {AnyStaticMethods} from '../../src';
+
+describe('v3compat (acceptance)', () => {
+ class CompatApp extends CompatMixin(BootMixin(RestApplication)) {}
+
+ let app: CompatApp;
+
+ beforeEach(async function givenApplication() {
+ app = await createCompatApplication();
+ app.v3compat.dataSource('db', {connector: 'memory'});
+ });
+
+ describe('when booting simple Todo app', () => {
+ beforeEach(async function bootTodoApp() {
+ app.projectRoot = path.resolve(__dirname, '../../../fixtures');
+ app.bootOptions = {
+ v3compat: {
+ root: '.',
+ },
+ };
+
+ await app.boot();
+ });
+
+ it('loads models from JSON files', () => {
+ expect(
+ Object.keys(app.v3compat.registry.modelBuilder.models),
+ ).to.containEql('Todo');
+ });
+
+ it('creates models using their JSON definitions', () => {
+ const Todo = app.v3compat.registry.getModel('Todo');
+ expect(Todo.definition.properties).to.containDeep({
+ title: {
+ type: String,
+ required: true,
+ },
+ });
+ });
+
+ it('executes model JS files', () => {
+ const Todo = app.v3compat.registry.getModel('Todo');
+ expect(Object.keys(Todo)).to.containEql('findByTitle');
+ expect(
+ ((Todo as unknown) as AnyStaticMethods).findByTitle,
+ ).to.be.a.Function();
+ });
+
+ it('configures models in the app', () => {
+ expect(Object.keys(app.v3compat.models)).to.deepEqual(['Todo']);
+ });
+
+ it('attaches models to the configured datasource', () => {
+ expect(app.v3compat.models.Todo.dataSource).to.equal(
+ app.v3compat.dataSources.db,
+ );
+ });
+ });
+});
diff --git a/packages/v3compat/test/acceptance/persisted-model.acceptance.ts b/packages/v3compat/test/acceptance/persisted-model.acceptance.ts
new file mode 100644
index 000000000000..e82d03d5934a
--- /dev/null
+++ b/packages/v3compat/test/acceptance/persisted-model.acceptance.ts
@@ -0,0 +1,129 @@
+// Copyright IBM Corp. 2018. All Rights Reserved.
+// Node module: @loopback/v3compat
+// This file is licensed under the MIT License.
+// License text available at https://opensource.org/licenses/MIT
+
+import {Client, createRestAppClient, expect, toJSON} from '@loopback/testlab';
+import {PersistedModelClass} from '../..';
+import {CompatApp, createCompatApplication} from './compat-app';
+
+describe('v3compat (acceptance)', () => {
+ let app: CompatApp;
+ let client: Client;
+
+ beforeEach(async function givenApplication() {
+ app = await createCompatApplication();
+ });
+
+ context('simple PersistedModel', () => {
+ let Todo: PersistedModelClass;
+
+ beforeEach(function setupTodoModel() {
+ const v3app = app.v3compat;
+
+ Todo = v3app.registry.createModel('Todo', {
+ title: {type: String, required: true},
+ });
+ v3app.dataSource('db', {connector: 'memory'});
+ v3app.model(Todo, {dataSource: 'db'});
+ });
+
+ beforeEach(givenClient);
+ afterEach(stopServers);
+
+ it('custom models inherit from PersistedModel by default', () => {
+ expect(Todo.base.modelName).to.equal('PersistedModel');
+ });
+
+ it('defines remote methods', () => {
+ const methodNames = Todo.sharedClass.methods().map(m => m.stringName);
+ expect(methodNames).to.deepEqual([
+ 'Todo.create',
+ 'Todo.find',
+ 'Todo.findById',
+ // TODO: add other CRUD methods
+ ]);
+ });
+
+ it('exposes Todo.find() method', async () => {
+ const todos = await Todo.find();
+ expect(todos).to.deepEqual([]);
+ });
+
+ it('creates a new todo', async () => {
+ const data = {
+ title: 'finish compat layer',
+ };
+ const created = await Todo.create(data);
+ const expected = Object.assign({id: 1}, data);
+ expect(toJSON(created)).to.deepEqual(expected);
+
+ const found = await Todo.findById(created.id);
+ expect(toJSON(found)).to.deepEqual(expected);
+ });
+
+ it.skip('provides getId() API', () => {
+ const todo = new Todo({id: 1, title: 'a-title'});
+ expect(todo.getId()).to.equal(1);
+ });
+
+ it('provides Model.app.models.AnotherModel API', () => {
+ expect(Object.keys(Todo)).to.containEql('app');
+ expect(Object.keys(Todo.app)).to.containEql('models');
+ expect(Object.keys(Todo.app.models)).to.containEql('Todo');
+ expect(Todo.app.models.Todo).to.equal(Todo);
+ });
+
+ it('exposes "GET /api/Todos" endpoint', () => {
+ return client.get('/api/Todos').expect(200, []);
+ });
+
+ it('exposes "GET /api/Todos/:id" endpoint', async () => {
+ const created = await Todo.create({title: 'a task'});
+ await client.get(`/api/Todos/${created.id}`).expect(200, toJSON(created));
+ });
+
+ it('supports ?filter argument encoded as deep-object', async () => {
+ const list = await Promise.all([
+ Todo.create({title: 'first task'}),
+ Todo.create({title: 'second task'}),
+ ]);
+ await client
+ .get('/api/Todos')
+ .query({'filter[where][title]': 'first task'})
+ .expect(200, [toJSON(list[0])]);
+ });
+
+ it('supports ?filter argument encoded as JSON', async () => {
+ const list = await Promise.all([
+ Todo.create({title: 'first task'}),
+ Todo.create({title: 'second task'}),
+ ]);
+ await client
+ .get('/api/Todos')
+ .query({filter: JSON.stringify({where: {title: 'second task'}})})
+ .expect(200, [toJSON(list[1])]);
+ });
+
+ it('supports POST /api/Todos', async () => {
+ const result = await client.post('/api/Todos').send({
+ title: 'new task',
+ });
+
+ const expected = {id: 1, title: 'new task'};
+ expect(result.body).to.deepEqual(expected);
+
+ const found = await Todo.findOne();
+ expect(toJSON(found)).to.deepEqual(expected);
+ });
+ });
+
+ async function givenClient() {
+ await app.start();
+ client = createRestAppClient(app);
+ }
+
+ async function stopServers() {
+ if (app) await app.stop();
+ }
+});
diff --git a/packages/v3compat/tsconfig.build.json b/packages/v3compat/tsconfig.build.json
new file mode 100644
index 000000000000..f8bd0f50ef8f
--- /dev/null
+++ b/packages/v3compat/tsconfig.build.json
@@ -0,0 +1,8 @@
+{
+ "$schema": "http://json.schemastore.org/tsconfig",
+ "extends": "@loopback/build/config/tsconfig.common.json",
+ "compilerOptions": {
+ "rootDir": "."
+ },
+ "include": ["index.ts", "src", "test"]
+}