From 3302a9f33d01bd701f254281ee809770d8bb1cc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Mon, 14 Jan 2019 10:19:16 +0100 Subject: [PATCH 01/15] feat(v3compat): initial commit --- packages/v3compat/.npmrc | 1 + packages/v3compat/LICENSE | 25 ++++++++++ packages/v3compat/README.md | 32 +++++++++++++ packages/v3compat/docs.json | 9 ++++ packages/v3compat/index.d.ts | 6 +++ packages/v3compat/index.js | 6 +++ packages/v3compat/index.ts | 8 ++++ packages/v3compat/package.json | 47 +++++++++++++++++++ packages/v3compat/src/index.ts | 6 +++ .../acceptance/persisted-model.acceptance.ts | 8 ++++ packages/v3compat/tsconfig.build.json | 8 ++++ 11 files changed, 156 insertions(+) create mode 100644 packages/v3compat/.npmrc create mode 100644 packages/v3compat/LICENSE create mode 100644 packages/v3compat/README.md create mode 100644 packages/v3compat/docs.json create mode 100644 packages/v3compat/index.d.ts create mode 100644 packages/v3compat/index.js create mode 100644 packages/v3compat/index.ts create mode 100644 packages/v3compat/package.json create mode 100644 packages/v3compat/src/index.ts create mode 100644 packages/v3compat/test/acceptance/persisted-model.acceptance.ts create mode 100644 packages/v3compat/tsconfig.build.json 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..cb29032c70d3 --- /dev/null +++ b/packages/v3compat/README.md @@ -0,0 +1,32 @@ +# @loopback/v3compat + +A compatibility layer allowing LoopBack 3 projects to carry over their +models to LoopBack 4+. + +## Installation + +```sh +npm install --save @loopback/v3compat +``` + +## Basic use + +TBD + +## 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/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..8dd096d8970b --- /dev/null +++ b/packages/v3compat/package.json @@ -0,0 +1,47 @@ +{ + "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": { + }, + "devDependencies": { + "@loopback/build": "^1.1.0", + "@loopback/testlab": "^1.0.3", + "@loopback/tslint-config": "^1.0.0", + "@types/node": "^10.1.1" + }, + "files": [ + "README.md", + "index.js", + "index.d.ts", + "dist/src", + "dist/index*", + "src" + ] +} diff --git a/packages/v3compat/src/index.ts b/packages/v3compat/src/index.ts new file mode 100644 index 000000000000..27801d135824 --- /dev/null +++ b/packages/v3compat/src/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 const foo = 'a temporary export to pass the compilation'; 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..debce858fd27 --- /dev/null +++ b/packages/v3compat/test/acceptance/persisted-model.acceptance.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 + +describe.skip('v3compat (acceptance)', () => { + // TBD +}); 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"] +} From eb868eeb937d8e75e13c1c1c107b2da447de57c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Mon, 14 Jan 2019 15:09:56 +0100 Subject: [PATCH 02/15] feat(v3compat): model registration and JavaScript DAO API --- packages/v3compat/README.md | 3 +- packages/v3compat/package.json | 6 + packages/v3compat/src/compat.mixin.ts | 44 ++++ packages/v3compat/src/core/README.md | 3 + packages/v3compat/src/core/index.ts | 10 + packages/v3compat/src/core/lb3-application.ts | 54 +++++ packages/v3compat/src/core/lb3-model.ts | 61 ++++++ .../v3compat/src/core/lb3-persisted-model.ts | 194 ++++++++++++++++++ packages/v3compat/src/core/lb3-registry.ts | 105 ++++++++++ packages/v3compat/src/core/lb3-types.ts | 39 ++++ packages/v3compat/src/index.ts | 4 +- packages/v3compat/src/remoting/README.md | 4 + packages/v3compat/src/remoting/index.ts | 6 + .../v3compat/src/remoting/remoting-types.ts | 40 ++++ .../acceptance/persisted-model.acceptance.ts | 83 +++++++- 15 files changed, 651 insertions(+), 5 deletions(-) create mode 100644 packages/v3compat/src/compat.mixin.ts create mode 100644 packages/v3compat/src/core/README.md create mode 100644 packages/v3compat/src/core/index.ts create mode 100644 packages/v3compat/src/core/lb3-application.ts create mode 100644 packages/v3compat/src/core/lb3-model.ts create mode 100644 packages/v3compat/src/core/lb3-persisted-model.ts create mode 100644 packages/v3compat/src/core/lb3-registry.ts create mode 100644 packages/v3compat/src/core/lb3-types.ts create mode 100644 packages/v3compat/src/remoting/README.md create mode 100644 packages/v3compat/src/remoting/index.ts create mode 100644 packages/v3compat/src/remoting/remoting-types.ts diff --git a/packages/v3compat/README.md b/packages/v3compat/README.md index cb29032c70d3..357e939229db 100644 --- a/packages/v3compat/README.md +++ b/packages/v3compat/README.md @@ -1,7 +1,6 @@ # @loopback/v3compat -A compatibility layer allowing LoopBack 3 projects to carry over their -models to LoopBack 4+. +A compatibility layer simplifying migration of LoopBack 3 models to LoopBack 4+. ## Installation diff --git a/packages/v3compat/package.json b/packages/v3compat/package.json index 8dd096d8970b..8d069c773cc5 100644 --- a/packages/v3compat/package.json +++ b/packages/v3compat/package.json @@ -29,11 +29,17 @@ "url": "https://github.com/strongloop/loopback-next.git" }, "dependencies": { + "@loopback/context": "^1.4.0", + "@loopback/core": "^1.1.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": [ diff --git a/packages/v3compat/src/compat.mixin.ts b/packages/v3compat/src/compat.mixin.ts new file mode 100644 index 000000000000..279e07bf78ac --- /dev/null +++ b/packages/v3compat/src/compat.mixin.ts @@ -0,0 +1,44 @@ +// 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/context'; +import {Application} from '@loopback/core'; +import {Lb3Application} from './core/lb3-application'; + +/** + * 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); + } + }; +} 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..ff6a27a21490 --- /dev/null +++ b/packages/v3compat/src/core/lb3-application.ts @@ -0,0 +1,54 @@ +// 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 debugFactory from 'debug'; +import {ModelClass} from './lb3-model'; +import {Lb3Registry} from './lb3-registry'; +import {DataSource, DataSourceConfig, ModelConfig} from './lb3-types'; + +const debug = debugFactory('loopback:v3compat:mixin'); + +export class Lb3Application { + readonly registry: Lb3Registry; + + readonly dataSources: { + [name: string]: DataSource; + }; + + readonly models: { + [name: string]: ModelClass; + }; + + constructor(protected lb4app: Application) { + this.registry = new Lb3Registry(lb4app); + this.dataSources = Object.create(null); + this.models = Object.create(null); + } + + dataSource(name: string, config: DataSourceConfig): DataSource { + debug('registering datasource %s 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; + return ds; + } + + model(modelCtor: ModelClass, config: ModelConfig) { + debug('registering model %s with config %s', modelCtor.modelName, config); + // TODO: use the implementation from LB3's lib/application.js + if (typeof config.dataSource === 'string') { + const dataSource = this.dataSources[config.dataSource]; + config = Object.assign({}, config, {dataSource}); + } + this.registry.configureModel(modelCtor, config); + this.models[modelCtor.modelName] = modelCtor; + modelCtor.app = this; + } + + 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..8c55ffb58445 --- /dev/null +++ b/packages/v3compat/src/core/lb3-model.ts @@ -0,0 +1,61 @@ +// 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} from 'loopback-datasource-juggler'; +import { + RemoteMethodOptions, + RemotingErrorHook, + RemotingHook, +} from '../remoting'; +import {Lb3Application} from './lb3-application'; +import {Lb3Registry} from './lb3-registry'; +import {ModelProperties, ModelSettings} from './lb3-types'; + +export type ModelClass = typeof Model; + +export declare class Model extends ModelBase { + static app: Lb3Application; + static registry: Lb3Registry; + // TODO: 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; + + // 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 + + 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..d18befe145a4 --- /dev/null +++ b/packages/v3compat/src/core/lb3-persisted-model.ts @@ -0,0 +1,194 @@ +// 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 {Model} from './lb3-model'; +import {Lb3Registry} from './lb3-registry'; +import { + Callback, + Options, + PlainDataObject, + Filter, + ComplexValue, + Where, +} from './lb3-types'; + +export type PersistedModelClass = typeof PersistedModel; + +export declare class PersistedModel extends Model { + static create( + data: ModelData, + options?: Options, + callback?: Callback, + ): Promise; + + static create( + data: ModelData[], + options?: Options, + callback?: Callback, + ): Promise; + + static upsert( + data: ModelData, + options?: Options, + callback?: Callback, + ): Promise; + + static updateOrCreate( + data: ModelData, + options?: Options, + callback?: Callback, + ): Promise; + + static patchOrCreate( + data: ModelData, + options?: Options, + callback?: Callback, + ): Promise; + + static upsertWithWhere( + where: Where, + data: ModelData, + options?: Options, + callback?: Callback, + ): Promise; + + static patchOrCreateWithWhere( + where: Where, + data: ModelData, + options?: Options, + callback?: Callback, + ): Promise; + + static replaceOrCreate( + data: ModelData, + options?: Options, + callback?: Callback, + ): Promise; + + static findOrCreate( + filter: Filter, + data: ModelData, + options?: Options, + callback?: Callback, + ): Promise; + + static exists( + id: ComplexValue, + options?: Options, + callback?: Callback, + ): Promise; + + static findById( + id: ComplexValue, + filter?: Filter, + options?: Options, + callback?: Callback, + ): Promise; + + static find( + filter?: Filter, + options?: Options, + callback?: Callback, + ): Promise; + + static findOne( + filter?: Filter, + options?: Options, + callback?: Callback, + ): Promise; + + static destroyAll( + where?: Where, + options?: Options, + callback?: Callback, + ): Promise; + + static remove( + where?: Where, + options?: Options, + callback?: Callback, + ): Promise; + + static deleteAll( + where?: Where, + options?: Options, + callback?: Callback, + ): Promise; + + static updateAll( + where?: Where, + data?: ModelData, + options?: Options, + callback?: Callback, + ): Promise; + + static update( + where?: Where, + data?: ModelData, + options?: Options, + callback?: Callback, + ): Promise; + + static destroyById( + id: ComplexValue, + options?: Options, + callback?: Callback, + ): Promise; + + static removeById( + id: ComplexValue, + options?: Options, + callback?: Callback, + ): Promise; + + static deleteById( + id: ComplexValue, + options?: Options, + callback?: Callback, + ): Promise; + + static replaceById( + id: ComplexValue, + data: ModelData, + options?: Options, + callback?: Callback, + ): Promise; + + 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 + + return PersistedModelCtor; +} diff --git a/packages/v3compat/src/core/lb3-registry.ts b/packages/v3compat/src/core/lb3-registry.ts new file mode 100644 index 000000000000..52c667c6e7fa --- /dev/null +++ b/packages/v3compat/src/core/lb3-registry.ts @@ -0,0 +1,105 @@ +// 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 { + if (typeof nameOrDefinition !== 'string') + // TODO + throw new Error( + 'createModel from a definition object is not supported yet', + ); + const name = nameOrDefinition; + + debug( + 'Creating a new model %s with properties %j', + nameOrDefinition, + properties, + ); + + // 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}`); + } +} 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 index 27801d135824..70a181d72fd9 100644 --- a/packages/v3compat/src/index.ts +++ b/packages/v3compat/src/index.ts @@ -3,4 +3,6 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -export const foo = 'a temporary export to pass the compilation'; +export * from './compat.mixin'; +export * from './core'; +export * from './remoting'; 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..89d5745fa85e --- /dev/null +++ b/packages/v3compat/src/remoting/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 './remoting-types'; diff --git a/packages/v3compat/src/remoting/remoting-types.ts b/packages/v3compat/src/remoting/remoting-types.ts new file mode 100644 index 000000000000..6255ab689e0e --- /dev/null +++ b/packages/v3compat/src/remoting/remoting-types.ts @@ -0,0 +1,40 @@ +// 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 RemoteMethodOptions { + // todo: describe http, accepts, returns, etc. +} diff --git a/packages/v3compat/test/acceptance/persisted-model.acceptance.ts b/packages/v3compat/test/acceptance/persisted-model.acceptance.ts index debce858fd27..64ba73766a29 100644 --- a/packages/v3compat/test/acceptance/persisted-model.acceptance.ts +++ b/packages/v3compat/test/acceptance/persisted-model.acceptance.ts @@ -3,6 +3,85 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -describe.skip('v3compat (acceptance)', () => { - // TBD +import {RestApplication, RestServerConfig} from '@loopback/rest'; +import { + Client, + createRestAppClient, + expect, + givenHttpServerConfig, + toJSON, +} from '@loopback/testlab'; +import {CompatMixin, PersistedModelClass} from '../..'; + +describe('v3compat (acceptance)', () => { + class CompatApp extends CompatMixin(RestApplication) {} + + let app: CompatApp; + let client: Client; + + beforeEach(givenApplication); + + 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('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.skip('exposes "GET /" endpoint', () => { + return client.get('/api/todos').expect(200, []); + }); + }); + + async function givenApplication() { + const rest: RestServerConfig = Object.assign({}, givenHttpServerConfig()); + app = new (CompatMixin(RestApplication))({rest}); + } + + async function givenClient() { + await app.start(); + client = createRestAppClient(app); + } + + async function stopServers() { + if (app) await app.stop(); + } }); From ec441a2b4a60e65ea706d34a42614a69fd95864b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 15 Jan 2019 10:39:51 +0100 Subject: [PATCH 03/15] feat(v3compat): add minimum strong-remoting infrastructure --- packages/v3compat/src/core/lb3-application.ts | 2 +- packages/v3compat/src/remoting/index.ts | 3 + .../v3compat/src/remoting/remoting-types.ts | 58 ++++++- .../v3compat/src/remoting/rest-adapter.ts | 6 + .../v3compat/src/remoting/shared-class.ts | 143 ++++++++++++++++ .../v3compat/src/remoting/shared-method.ts | 156 ++++++++++++++++++ 6 files changed, 366 insertions(+), 2 deletions(-) create mode 100644 packages/v3compat/src/remoting/rest-adapter.ts create mode 100644 packages/v3compat/src/remoting/shared-class.ts create mode 100644 packages/v3compat/src/remoting/shared-method.ts diff --git a/packages/v3compat/src/core/lb3-application.ts b/packages/v3compat/src/core/lb3-application.ts index ff6a27a21490..8d6078c46473 100644 --- a/packages/v3compat/src/core/lb3-application.ts +++ b/packages/v3compat/src/core/lb3-application.ts @@ -9,7 +9,7 @@ import {ModelClass} from './lb3-model'; import {Lb3Registry} from './lb3-registry'; import {DataSource, DataSourceConfig, ModelConfig} from './lb3-types'; -const debug = debugFactory('loopback:v3compat:mixin'); +const debug = debugFactory('loopback:v3compat:application'); export class Lb3Application { readonly registry: Lb3Registry; diff --git a/packages/v3compat/src/remoting/index.ts b/packages/v3compat/src/remoting/index.ts index 89d5745fa85e..72a28dd87612 100644 --- a/packages/v3compat/src/remoting/index.ts +++ b/packages/v3compat/src/remoting/index.ts @@ -4,3 +4,6 @@ // 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/remoting-types.ts b/packages/v3compat/src/remoting/remoting-types.ts index 6255ab689e0e..128cad7ea048 100644 --- a/packages/v3compat/src/remoting/remoting-types.ts +++ b/packages/v3compat/src/remoting/remoting-types.ts @@ -35,6 +35,62 @@ export type RemotingErrorHookCallback = ( next: (err?: Error) => void, ) => void; +export interface RemoteClassOptions { + // TODO: are there any well-known class options? +} + export interface RemoteMethodOptions { - // todo: describe http, accepts, returns, etc. + 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; + type?: string | [string]; + model?: string; + required?: boolean; + description?: string; + http?: RestParameterMapping; // TODO: support function `(ctx) => value` +} + +export interface RestParameterMapping { + source?: + | 'req' + | 'res' + | 'body' + | 'form' + | 'query' + | 'path' + | 'header' + | 'context'; +} + +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; } diff --git a/packages/v3compat/src/remoting/rest-adapter.ts b/packages/v3compat/src/remoting/rest-adapter.ts new file mode 100644 index 000000000000..0f3d29965358 --- /dev/null +++ b/packages/v3compat/src/remoting/rest-adapter.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 class RestAdapter {} diff --git a/packages/v3compat/src/remoting/shared-class.ts b/packages/v3compat/src/remoting/shared-class.ts new file mode 100644 index 000000000000..95ee8710b59c --- /dev/null +++ b/packages/v3compat/src/remoting/shared-class.ts @@ -0,0 +1,143 @@ +/// 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, +} from './remoting-types'; +import {SharedMethod} from './shared-method'; +import assert = require('assert'); + +export type CtorFunction = Function & { + http?: RestRouteSettings; + sharedCtor?: Function; + + [staticKey: string]: Function | unknown; +}; + +// See strong-remoting's lib/shared-class.js +export class SharedClass { + private readonly _methods: SharedMethod[] = []; + readonly http: RestRouteSettings; + readonly sharedCtor: SharedMethod; + + // TODO: _resolvers, _disabledMethods + + constructor( + public name: string, + public ctor: CtorFunction, + public options: RemoteClassOptions, + ) { + const http = ctor && ctor.http; + + const defaultHttp: RestRouteSettings = {}; + defaultHttp.path = '/' + this.name; + + if (Array.isArray(http)) { + // use array as is + this.http = http; + if (http.length === 0) { + http.push(defaultHttp); + } + } else { + // set http.path using the name unless it is defined + this.http = Object.assign(defaultHttp, 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..4151e36b05c5 --- /dev/null +++ b/packages/v3compat/src/remoting/shared-method.ts @@ -0,0 +1,156 @@ +/// 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, + RestRouteSettings, + 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: RestRouteSettings[]; + 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; + const http = options.http || fnMeta.http || {}; + // 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); + + this.http = http && !Array.isArray(http) ? [http] : http; + + // 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, + }); + } +} + +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']; +} From 4ddb7ab53a7d6c044c677f584941e94d648f922c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 15 Jan 2019 13:05:31 +0100 Subject: [PATCH 04/15] feat(v3compat): define remoting metadata for some CRUD methods --- packages/v3compat/src/core/lb3-model.ts | 167 +++++++++++++- .../v3compat/src/core/lb3-persisted-model.ts | 211 ++++++++++++++++-- packages/v3compat/src/core/lb3-registry.ts | 8 +- .../acceptance/persisted-model.acceptance.ts | 14 ++ 4 files changed, 374 insertions(+), 26 deletions(-) diff --git a/packages/v3compat/src/core/lb3-model.ts b/packages/v3compat/src/core/lb3-model.ts index 8c55ffb58445..72aeb9126996 100644 --- a/packages/v3compat/src/core/lb3-model.ts +++ b/packages/v3compat/src/core/lb3-model.ts @@ -3,15 +3,22 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {ModelBase} from 'loopback-datasource-juggler'; +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} from './lb3-types'; +import { + ModelProperties, + ModelSettings, + PlainDataObject, + ComplexValue, +} from './lb3-types'; +import {PersistedModelClass} from './lb3-persisted-model'; export type ModelClass = typeof Model; @@ -20,6 +27,11 @@ export declare class Model extends ModelBase { 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; @@ -42,6 +54,9 @@ export declare class Model extends ModelBase { 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 @@ -57,5 +72,153 @@ export function setupModelClass(registry: Lb3Registry): typeof Model { // 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 index d18befe145a4..4f9453c42b35 100644 --- a/packages/v3compat/src/core/lb3-persisted-model.ts +++ b/packages/v3compat/src/core/lb3-persisted-model.ts @@ -3,57 +3,93 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT +import {RemoteMethodOptions} from '../remoting'; import {Model} from './lb3-model'; import {Lb3Registry} from './lb3-registry'; import { Callback, + ComplexValue, + Filter, Options, PlainDataObject, - Filter, - ComplexValue, 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, - ): Promise; + 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, - ): Promise; + 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, - ): Promise; + options: Options, + callback: Callback, + ): void; static updateOrCreate( data: ModelData, options?: Options, - callback?: Callback, ): Promise; + static updateOrCreate( + data: ModelData, + callback: Callback, + ): void; + static updateOrCreate( + data: ModelData, + options: Options, + callback: Callback, + ): void; static patchOrCreate( data: ModelData, options?: Options, - callback?: Callback, ): 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, - callback?: Callback, ): 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, @@ -61,12 +97,14 @@ export declare class PersistedModel extends Model { 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, @@ -74,49 +112,65 @@ export declare class PersistedModel extends Model { 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, - callback?: Callback, ): 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, - ): Promise; + 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, @@ -124,6 +178,7 @@ export declare class PersistedModel extends Model { callback?: Callback, ): Promise; + // TODO: fix the API (callbacks vs promises) static update( where?: Where, data?: ModelData, @@ -131,24 +186,29 @@ export declare class PersistedModel extends Model { 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, - ): Promise; + options: Options, + callback: Callback, + ): void; + // TODO: fix the API (callbacks vs promises) static replaceById( id: ComplexValue, data: ModelData, @@ -156,6 +216,7 @@ export declare class PersistedModel extends Model { callback?: Callback, ): Promise; + // TODO: fix the API (callbacks vs promises) static count( where?: Where, options?: Options, @@ -189,6 +250,112 @@ export function setupPersistedModelClass( ); // 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 index 52c667c6e7fa..d169c6b9edef 100644 --- a/packages/v3compat/src/core/lb3-registry.ts +++ b/packages/v3compat/src/core/lb3-registry.ts @@ -43,8 +43,8 @@ export class Lb3Registry { createModel( nameOrDefinition: string | ModelDefinition, - properties?: ModelProperties, - settings?: ModelSettings, + properties: ModelProperties = {}, + settings: ModelSettings = {}, ): T { if (typeof nameOrDefinition !== 'string') // TODO @@ -59,6 +59,10 @@ export class Lb3Registry { 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, diff --git a/packages/v3compat/test/acceptance/persisted-model.acceptance.ts b/packages/v3compat/test/acceptance/persisted-model.acceptance.ts index 64ba73766a29..0e6053a1f86f 100644 --- a/packages/v3compat/test/acceptance/persisted-model.acceptance.ts +++ b/packages/v3compat/test/acceptance/persisted-model.acceptance.ts @@ -37,6 +37,20 @@ describe('v3compat (acceptance)', () => { 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([]); From 2893a4946da56f7568e48b954ba99d46d9bdaf9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 15 Jan 2019 13:16:44 +0100 Subject: [PATCH 05/15] docs: add working notes for the spike --- lb3api.md | 138 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 lb3api.md diff --git a/lb3api.md b/lb3api.md new file mode 100644 index 000000000000..00b9e978d47d --- /dev/null +++ b/lb3api.md @@ -0,0 +1,138 @@ +## LoopBack 3 APIs we need to support in LB4 + +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 1k+ lines of new (migrated) code into smaller chunks that will be +easier to review? + +TODO: + +1. expose remote methods via REST +2. boot + +## Should have/next iterations: + +- PersistedModel - all CRUD APIs +- register Models and DataSources for dependency injection? +- allow LB3 models to be attached to LB4 dataSources? +- pick up models/methods added after app start +- hasUpdateOnlyProps (different request body schema for "create" method) +- set options from HTTP context (`http: 'optionsFromRequest'`) +- `{rest: {after: convertNullToNotFoundError}}` +- mixins + +## Model + +- beforeRemote/afterRemote/afterRemoteError +- disableRemoteMethodByName(name) + +## HttpContext +- method +- req +- res +- options +- args (?) +- methodString +- result + +## SharedMethod +- isMethodEnabled(sharedMethod) +- resolve(resolver) +- findMethodByName +- disableMethodByName + +### Application +- app.connector(name, connector) +- app.connectors.{name} + +? app.remotes() + +### Model + +- createOptionsFromRemotingContext +- belongsToRemoting +- Model.hasOneRemoting +- hasManyRemoting +- scopeRemoting +- nestRemoting + +? PersistedModel.createChangeStream + +### KeyValueModel + +- get(key, options, cb) +- set(key, value, options, cb) +- expire(key, options, cb) +- ttl(key, options, cb) +- keys(filter, options, cb) +- iterateKeys(filter, options) + +### Remoting features + +- respond with a Buffer, respond with a ReadableStream + +## Won't have + +- CLS-based context +- global registry: loopback.createModel, loopback.findModel, etc. +- Model.checkAccess(token, modelId, sharedMethod, ctx, cb) +- Model.disableRemoteMethod: was already deprecated +- REST API for creating multiple models in one call (allowArray: true) +- PersistedModel change replication + - diff + - changes + - checkpoint + - currentCheckpoint + - replicate + - createUpdates + - bulkUpdate + - getChangeModel + - getSourceId + - enableChangeTracking + - rectifyAllChanges + - handleChangeError + - rectifyChange + - updateLastChange + - createChangeFilter + - fillCustomChangeProperties + +built-in models +- Access-token +- Acl +- Application +- Change +- Checkpoint +- Email +- RoleMapping +- Role +- Scope +- User + +SharedClass +- find: was already deprecated +- disableMethod: was already deprecated + +SharedMethod +- prototype.invoke(scope, args, remotingOptions, ctx, cb) + +HttpContext +- ~~typeRegistry~~ +- ~~supportedTypes~~ +- invoke +- setReturnArgByName +- getArgByName +- buildArgs +- createStream +- respondWithEventStream +- resolveReponseOperation +- done +- (etc.) + +Remoting +- XML +- JSON API +- piping retval of remote function into response From fa06c1c393e988e8f7a76a2126ef4728532bd83d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Thu, 17 Jan 2019 14:17:19 +0100 Subject: [PATCH 06/15] feat(v3compat): rest adapter --- lb3api.md | 1 + packages/openapi-v3/src/controller-spec.ts | 4 +- packages/v3compat/package.json | 1 + packages/v3compat/src/core/lb3-application.ts | 6 + .../src/remoting/Lb3ModelController.ts | 41 ++++ .../v3compat/src/remoting/remoting-types.ts | 26 +- .../v3compat/src/remoting/rest-adapter.ts | 109 ++++++++- .../v3compat/src/remoting/shared-class.ts | 33 ++- .../v3compat/src/remoting/shared-method.ts | 32 ++- packages/v3compat/src/specgen/README.md | 4 + packages/v3compat/src/specgen/index.ts | 6 + packages/v3compat/src/specgen/lb3-openapi.ts | 227 ++++++++++++++++++ .../acceptance/persisted-model.acceptance.ts | 34 ++- 13 files changed, 493 insertions(+), 31 deletions(-) create mode 100644 packages/v3compat/src/remoting/Lb3ModelController.ts create mode 100644 packages/v3compat/src/specgen/README.md create mode 100644 packages/v3compat/src/specgen/index.ts create mode 100644 packages/v3compat/src/specgen/lb3-openapi.ts diff --git a/lb3api.md b/lb3api.md index 00b9e978d47d..a5822aaf56ea 100644 --- a/lb3api.md +++ b/lb3api.md @@ -24,6 +24,7 @@ TODO: - set options from HTTP context (`http: 'optionsFromRequest'`) - `{rest: {after: convertNullToNotFoundError}}` - mixins +- case-insensitive URL paths (?) (/Todo is same as /todo) ## Model 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/package.json b/packages/v3compat/package.json index 8d069c773cc5..8cfaa8be0d35 100644 --- a/packages/v3compat/package.json +++ b/packages/v3compat/package.json @@ -31,6 +31,7 @@ "dependencies": { "@loopback/context": "^1.4.0", "@loopback/core": "^1.1.3", + "@loopback/rest": "^1.5.3", "debug": "^4.1.1", "loopback-datasource-juggler": "^4.5.0" }, diff --git a/packages/v3compat/src/core/lb3-application.ts b/packages/v3compat/src/core/lb3-application.ts index 8d6078c46473..04dbdc743efd 100644 --- a/packages/v3compat/src/core/lb3-application.ts +++ b/packages/v3compat/src/core/lb3-application.ts @@ -5,6 +5,7 @@ import {Application} from '@loopback/core'; 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'; @@ -12,6 +13,7 @@ import {DataSource, DataSourceConfig, ModelConfig} from './lb3-types'; const debug = debugFactory('loopback:v3compat:application'); export class Lb3Application { + readonly restAdapter: RestAdapter; readonly registry: Lb3Registry; readonly dataSources: { @@ -24,6 +26,7 @@ export class Lb3Application { constructor(protected lb4app: Application) { this.registry = new Lb3Registry(lb4app); + this.restAdapter = new RestAdapter(lb4app); this.dataSources = Object.create(null); this.models = Object.create(null); } @@ -46,6 +49,9 @@ export class Lb3Application { this.registry.configureModel(modelCtor, config); this.models[modelCtor.modelName] = modelCtor; modelCtor.app = this; + + // TODO: register Model schema + this.restAdapter.registerSharedClass(modelCtor.sharedClass); } deleteModelByName(modelName: string): void { diff --git a/packages/v3compat/src/remoting/Lb3ModelController.ts b/packages/v3compat/src/remoting/Lb3ModelController.ts new file mode 100644 index 000000000000..3b04ce680132 --- /dev/null +++ b/packages/v3compat/src/remoting/Lb3ModelController.ts @@ -0,0 +1,41 @@ +// 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 {Request, Response, RestBindings, OperationArgs} from '@loopback/rest'; +import {SharedMethod} from './shared-method'; + +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; + } +} diff --git a/packages/v3compat/src/remoting/remoting-types.ts b/packages/v3compat/src/remoting/remoting-types.ts index 128cad7ea048..2333fc8e70a6 100644 --- a/packages/v3compat/src/remoting/remoting-types.ts +++ b/packages/v3compat/src/remoting/remoting-types.ts @@ -58,6 +58,7 @@ export interface RemoteMethodOptions { export interface ParameterOptions { arg?: string; + name?: string; // alias for "arg" type?: string | [string]; model?: string; required?: boolean; @@ -65,16 +66,18 @@ export interface ParameterOptions { http?: RestParameterMapping; // TODO: support function `(ctx) => value` } +export type RestParameterSource = + | 'req' + | 'res' + | 'body' + | 'form' + | 'query' + | 'path' + | 'header' + | 'context'; + export interface RestParameterMapping { - source?: - | 'req' - | 'res' - | 'body' - | 'form' - | 'query' - | 'path' - | 'header' - | 'context'; + source?: RestParameterSource; } export interface RetvalOptions { @@ -94,3 +97,8 @@ 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 index 0f3d29965358..f216f85fe89c 100644 --- a/packages/v3compat/src/remoting/rest-adapter.ts +++ b/packages/v3compat/src/remoting/rest-adapter.ts @@ -1,6 +1,111 @@ -// Copyright IBM Corp. 2018. All Rights Reserved. +// 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 class RestAdapter {} +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 './Lb3ModelController'; +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 + ) { + debug('%s initial args %j', sharedMethod.stringName, args); + args = this.buildMethodArguments(sharedMethod, args); + debug('resolved args %j', args); + if (!sharedMethod.isStatic) { + // TODO: invoke sharedCtor to obtain the model instance + throw new HttpErrors.NotImplemented( + 'Instance-level shared methods are not supported yet.', + ); + } + + // TODO: beforeRemote, afterRemote, afterRemoteError hooks + + const handler = sharedMethod.getFunction(); + // TODO: callback mode + return await handler.apply(sharedMethod.ctor, 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 index 95ee8710b59c..76db5b5b30c0 100644 --- a/packages/v3compat/src/remoting/shared-class.ts +++ b/packages/v3compat/src/remoting/shared-class.ts @@ -7,13 +7,21 @@ import { RemoteClassOptions, RestRouteSettings, RemoteMethodOptions, + RestRoute, } from './remoting-types'; import {SharedMethod} from './shared-method'; import assert = require('assert'); export type CtorFunction = Function & { - http?: RestRouteSettings; + http?: RestRouteSettings | RestRouteSettings[]; sharedCtor?: Function; + settings?: { + swagger?: { + tag?: { + name?: string; + }; + }; + }; [staticKey: string]: Function | unknown; }; @@ -21,7 +29,7 @@ export type CtorFunction = Function & { // See strong-remoting's lib/shared-class.js export class SharedClass { private readonly _methods: SharedMethod[] = []; - readonly http: RestRouteSettings; + readonly http: RestRoute; readonly sharedCtor: SharedMethod; // TODO: _resolvers, _disabledMethods @@ -33,19 +41,18 @@ export class SharedClass { ) { const http = ctor && ctor.http; - const defaultHttp: RestRouteSettings = {}; - defaultHttp.path = '/' + this.name; + const defaultHttp: RestRoute = { + path: '/' + this.name, + verb: 'POST', + }; - if (Array.isArray(http)) { - // use array as is - this.http = http; - if (http.length === 0) { - http.push(defaultHttp); - } - } else { + this.http = Object.assign( // set http.path using the name unless it is defined - this.http = Object.assign(defaultHttp, http); - } + 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); diff --git a/packages/v3compat/src/remoting/shared-method.ts b/packages/v3compat/src/remoting/shared-method.ts index 4151e36b05c5..b4b316536f58 100644 --- a/packages/v3compat/src/remoting/shared-method.ts +++ b/packages/v3compat/src/remoting/shared-method.ts @@ -7,7 +7,7 @@ import * as assert from 'assert'; import { ParameterOptions, RemoteMethodOptions, - RestRouteSettings, + RestRoute, RetvalOptions, } from './remoting-types'; import {CtorFunction, SharedClass} from './shared-class'; @@ -20,7 +20,7 @@ export class SharedMethod { readonly description?: string; readonly notes?: string; readonly documented: boolean; - readonly http: RestRouteSettings[]; + readonly http: RestRoute[]; readonly shared: boolean; readonly ctor?: CtorFunction; readonly sharedCtor: SharedMethod; @@ -47,7 +47,6 @@ export class SharedMethod { this.notes = options.notes || fnMeta.notes; this.documented = options.documented !== false && fnMeta.documented !== false; - const http = options.http || fnMeta.http || {}; // TODO: this.rest = options.rest || fn.rest || {}; this.shared = isShared(fnMeta, options); this.sharedClass = sharedClass; @@ -69,7 +68,11 @@ export class SharedMethod { this.returns = returns && !Array.isArray(returns) ? [returns] : returns; this.returns.forEach(normalizeArgumentDescriptor); - this.http = http && !Array.isArray(http) ? [http] : http; + 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 @@ -142,6 +145,14 @@ export class SharedMethod { 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) { @@ -154,3 +165,16 @@ function isShared(fn: RemoteMethodOptions, options: RemoteMethodOptions) { 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..1fffd243d27b --- /dev/null +++ b/packages/v3compat/src/specgen/lb3-openapi.ts @@ -0,0 +1,227 @@ +/// 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, +} 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 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, + // TODO: 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 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/persisted-model.acceptance.ts b/packages/v3compat/test/acceptance/persisted-model.acceptance.ts index 0e6053a1f86f..ce7eb6eba4a4 100644 --- a/packages/v3compat/test/acceptance/persisted-model.acceptance.ts +++ b/packages/v3compat/test/acceptance/persisted-model.acceptance.ts @@ -80,9 +80,39 @@ describe('v3compat (acceptance)', () => { expect(Todo.app.models.Todo).to.equal(Todo); }); - it.skip('exposes "GET /" endpoint', () => { - return client.get('/api/todos').expect(200, []); + 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])]); + }); + + // TODO + it.skip('supports POST /api/Todos'); }); async function givenApplication() { From f9843eb434dcc22cc4a37766fe7e2ca1b98781d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Mon, 21 Jan 2019 08:52:43 +0100 Subject: [PATCH 07/15] feat(v3compat): request body parameters --- packages/v3compat/src/specgen/lb3-openapi.ts | 25 ++++++++++++++++++- .../acceptance/persisted-model.acceptance.ts | 13 ++++++++-- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/packages/v3compat/src/specgen/lb3-openapi.ts b/packages/v3compat/src/specgen/lb3-openapi.ts index 1fffd243d27b..5a93fee3ef77 100644 --- a/packages/v3compat/src/specgen/lb3-openapi.ts +++ b/packages/v3compat/src/specgen/lb3-openapi.ts @@ -9,6 +9,7 @@ import { ResponseObject, SchemaObject, ParameterLocation, + RequestBodyObject, } from '@loopback/rest'; import { ParameterOptions, @@ -33,6 +34,7 @@ export function buildRemoteMethodSpec( 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 @@ -51,7 +53,7 @@ export function buildRemoteMethodSpec( notes: sharedMethod.notes, operationId, parameters, - // TODO: requestBody, + requestBody, responses: { [statusCode]: response, }, @@ -89,6 +91,27 @@ function convertAcceptsToOpenApi( 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.'; diff --git a/packages/v3compat/test/acceptance/persisted-model.acceptance.ts b/packages/v3compat/test/acceptance/persisted-model.acceptance.ts index ce7eb6eba4a4..f86aecc4a217 100644 --- a/packages/v3compat/test/acceptance/persisted-model.acceptance.ts +++ b/packages/v3compat/test/acceptance/persisted-model.acceptance.ts @@ -111,8 +111,17 @@ describe('v3compat (acceptance)', () => { .expect(200, [toJSON(list[1])]); }); - // TODO - it.skip('supports POST /api/Todos'); + 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 givenApplication() { From cee5f84fa41268c564372c629e02913e959658ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Mon, 21 Jan 2019 09:51:03 +0100 Subject: [PATCH 08/15] feat(v3compat): bind datasources in Context --- packages/v3compat/src/core/lb3-application.ts | 6 +++++ .../test/acceptance/datasource.acceptance.ts | 27 +++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 packages/v3compat/test/acceptance/datasource.acceptance.ts diff --git a/packages/v3compat/src/core/lb3-application.ts b/packages/v3compat/src/core/lb3-application.ts index 04dbdc743efd..c4620676b12e 100644 --- a/packages/v3compat/src/core/lb3-application.ts +++ b/packages/v3compat/src/core/lb3-application.ts @@ -36,6 +36,12 @@ export class Lb3Application { // 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; } diff --git a/packages/v3compat/test/acceptance/datasource.acceptance.ts b/packages/v3compat/test/acceptance/datasource.acceptance.ts new file mode 100644 index 000000000000..d19ddb1f603c --- /dev/null +++ b/packages/v3compat/test/acceptance/datasource.acceptance.ts @@ -0,0 +1,27 @@ +// 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 {RestApplication, RestServerConfig} from '@loopback/rest'; +import {expect, givenHttpServerConfig} from '@loopback/testlab'; +import {CompatMixin} from '../..'; + +describe('v3compat (acceptance)', () => { + class CompatApp extends CompatMixin(RestApplication) {} + + let app: CompatApp; + + beforeEach(givenApplication); + + 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); + }); + + async function givenApplication() { + const rest: RestServerConfig = Object.assign({}, givenHttpServerConfig()); + app = new CompatApp({rest}); + } +}); From d1348e8332c4d83a62143189b5854734b59aa37b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Mon, 21 Jan 2019 14:17:29 +0100 Subject: [PATCH 09/15] feat(v3compat): boot LB3 models --- lb3api.md | 41 ++++-- packages/v3compat/fixtures/model-config.json | 6 + packages/v3compat/fixtures/models/todo.js | 11 ++ packages/v3compat/fixtures/models/todo.json | 11 ++ packages/v3compat/package.json | 2 +- packages/v3compat/src/boot/README.md | 4 + packages/v3compat/src/boot/index.ts | 6 + .../v3compat/src/boot/lb3-model-booter.ts | 121 ++++++++++++++++++ packages/v3compat/src/compat.component.ts | 11 ++ packages/v3compat/src/compat.mixin.ts | 4 +- packages/v3compat/src/core/lb3-application.ts | 6 +- packages/v3compat/src/core/lb3-model.ts | 8 ++ .../v3compat/src/core/lb3-persisted-model.ts | 3 +- packages/v3compat/src/core/lb3-registry.ts | 43 +++++-- packages/v3compat/src/index.ts | 1 + .../v3compat/test/acceptance/compat-app.ts | 16 +++ .../test/acceptance/datasource.acceptance.ts | 16 +-- .../test/acceptance/lb3-boot.acceptance.ts | 70 ++++++++++ .../acceptance/persisted-model.acceptance.ts | 23 +--- 19 files changed, 347 insertions(+), 56 deletions(-) create mode 100644 packages/v3compat/fixtures/model-config.json create mode 100644 packages/v3compat/fixtures/models/todo.js create mode 100644 packages/v3compat/fixtures/models/todo.json create mode 100644 packages/v3compat/src/boot/README.md create mode 100644 packages/v3compat/src/boot/index.ts create mode 100644 packages/v3compat/src/boot/lb3-model-booter.ts create mode 100644 packages/v3compat/src/compat.component.ts create mode 100644 packages/v3compat/test/acceptance/compat-app.ts create mode 100644 packages/v3compat/test/acceptance/lb3-boot.acceptance.ts diff --git a/lb3api.md b/lb3api.md index a5822aaf56ea..3e9d44ba17fc 100644 --- a/lb3api.md +++ b/lb3api.md @@ -6,32 +6,36 @@ 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 1k+ lines of new (migrated) code into smaller chunks that will be +How to split 2k+ lines of new (migrated) code into smaller chunks that will be easier to review? -TODO: - -1. expose remote methods via REST -2. boot +Should we register LB3 Models for dependency injection into LB4 code? Register +them as repositories, models, services, or something else? ## Should have/next iterations: - PersistedModel - all CRUD APIs -- register Models and DataSources for dependency injection? -- allow LB3 models to be attached to LB4 dataSources? - pick up models/methods added after app start - hasUpdateOnlyProps (different request body schema for "create" method) - set options from HTTP context (`http: 'optionsFromRequest'`) - `{rest: {after: convertNullToNotFoundError}}` - mixins - case-insensitive URL paths (?) (/Todo is same as /todo) +- model-sources and mixin-sources in model-config.json + +On aside -## Model +- https://github.com/Microsoft/TypeScript/issues/6480 +- extract the Booter contract into a standalone package so that v3compat does + not have to inherit entire boot + +### Model - beforeRemote/afterRemote/afterRemoteError - disableRemoteMethodByName(name) -## HttpContext +### HttpContext + - method - req - res @@ -40,13 +44,15 @@ TODO: - methodString - result -## SharedMethod +### SharedMethod + - isMethodEnabled(sharedMethod) - resolve(resolver) - findMethodByName - disableMethodByName ### Application + - app.connector(name, connector) - app.connectors.{name} @@ -74,10 +80,12 @@ TODO: ### Remoting features -- respond with a Buffer, respond with a ReadableStream +- respond with a Buffer, respond with a ReadableStream -## Won't have +## WILL NOT HAVE +- allow LB3 models to be attached to LB4 dataSources. This won't work + because LB3 requires all datasources to share the same ModelBuilder - CLS-based context - global registry: loopback.createModel, loopback.findModel, etc. - Model.checkAccess(token, modelId, sharedMethod, ctx, cb) @@ -102,6 +110,7 @@ TODO: - fillCustomChangeProperties built-in models + - Access-token - Acl - Application @@ -114,13 +123,16 @@ built-in models - User SharedClass + - find: was already deprecated - disableMethod: was already deprecated SharedMethod + - prototype.invoke(scope, args, remotingOptions, ctx, cb) HttpContext + - ~~typeRegistry~~ - ~~supportedTypes~~ - invoke @@ -134,6 +146,11 @@ HttpContext - (etc.) Remoting + - XML - JSON API - piping retval of remote function into response + +Booting + +- datasources 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/package.json b/packages/v3compat/package.json index 8cfaa8be0d35..464c3204a188 100644 --- a/packages/v3compat/package.json +++ b/packages/v3compat/package.json @@ -29,7 +29,7 @@ "url": "https://github.com/strongloop/loopback-next.git" }, "dependencies": { - "@loopback/context": "^1.4.0", + "@loopback/boot": "^1.0.10", "@loopback/core": "^1.1.3", "@loopback/rest": "^1.5.3", "debug": "^4.1.1", 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..ce5470724c47 --- /dev/null +++ b/packages/v3compat/src/boot/lb3-model-booter.ts @@ -0,0 +1,121 @@ +// 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 = { + 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 index 279e07bf78ac..bad40ac36a5f 100644 --- a/packages/v3compat/src/compat.mixin.ts +++ b/packages/v3compat/src/compat.mixin.ts @@ -5,7 +5,8 @@ import {Constructor} from '@loopback/context'; import {Application} from '@loopback/core'; -import {Lb3Application} from './core/lb3-application'; +import {CompatComponent} from './compat.component'; +import {Lb3Application} from './core'; /** * A mixin class for Application that adds `v3compat` property providing @@ -39,6 +40,7 @@ export function CompatMixin>(superClass: T) { constructor(...args: any[]) { super(...args); this.v3compat = new Lb3Application((this as unknown) as Application); + this.component(CompatComponent); } }; } diff --git a/packages/v3compat/src/core/lb3-application.ts b/packages/v3compat/src/core/lb3-application.ts index c4620676b12e..2c92a85b7843 100644 --- a/packages/v3compat/src/core/lb3-application.ts +++ b/packages/v3compat/src/core/lb3-application.ts @@ -32,7 +32,7 @@ export class Lb3Application { } dataSource(name: string, config: DataSourceConfig): DataSource { - debug('registering datasource %s with config %j', name, config); + 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; @@ -46,7 +46,7 @@ export class Lb3Application { } model(modelCtor: ModelClass, config: ModelConfig) { - debug('registering model %s with config %s', modelCtor.modelName, config); + 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]; @@ -56,7 +56,7 @@ export class Lb3Application { this.models[modelCtor.modelName] = modelCtor; modelCtor.app = this; - // TODO: register Model schema + // TODO: register Model schema for OpenAPI spec this.restAdapter.registerSharedClass(modelCtor.sharedClass); } diff --git a/packages/v3compat/src/core/lb3-model.ts b/packages/v3compat/src/core/lb3-model.ts index 72aeb9126996..c93d03fd0f83 100644 --- a/packages/v3compat/src/core/lb3-model.ts +++ b/packages/v3compat/src/core/lb3-model.ts @@ -20,6 +20,14 @@ import { } 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 { diff --git a/packages/v3compat/src/core/lb3-persisted-model.ts b/packages/v3compat/src/core/lb3-persisted-model.ts index 4f9453c42b35..c02f9bf7dbd4 100644 --- a/packages/v3compat/src/core/lb3-persisted-model.ts +++ b/packages/v3compat/src/core/lb3-persisted-model.ts @@ -3,8 +3,9 @@ // 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 {Model} from './lb3-model'; +import {AnyStaticMethods, Model} from './lb3-model'; import {Lb3Registry} from './lb3-registry'; import { Callback, diff --git a/packages/v3compat/src/core/lb3-registry.ts b/packages/v3compat/src/core/lb3-registry.ts index d169c6b9edef..b860bc94233c 100644 --- a/packages/v3compat/src/core/lb3-registry.ts +++ b/packages/v3compat/src/core/lb3-registry.ts @@ -46,18 +46,22 @@ export class Lb3Registry { properties: ModelProperties = {}, settings: ModelSettings = {}, ): T { - if (typeof nameOrDefinition !== 'string') - // TODO - throw new Error( - 'createModel from a definition object is not supported yet', + 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', ); - const name = nameOrDefinition; + } - debug( - 'Creating a new model %s with properties %j', - nameOrDefinition, - properties, - ); + debug('Creating a new model %s with properties %j', name, properties); if (!(settings.base || settings.super)) { settings.base = 'PersistedModel'; @@ -107,3 +111,22 @@ export class Lb3Registry { 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/index.ts b/packages/v3compat/src/index.ts index 70a181d72fd9..e79a87105b51 100644 --- a/packages/v3compat/src/index.ts +++ b/packages/v3compat/src/index.ts @@ -6,3 +6,4 @@ export * from './compat.mixin'; export * from './core'; export * from './remoting'; +export * from './boot'; 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 index d19ddb1f603c..062b8f851a12 100644 --- a/packages/v3compat/test/acceptance/datasource.acceptance.ts +++ b/packages/v3compat/test/acceptance/datasource.acceptance.ts @@ -3,25 +3,19 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {RestApplication, RestServerConfig} from '@loopback/rest'; -import {expect, givenHttpServerConfig} from '@loopback/testlab'; -import {CompatMixin} from '../..'; +import {expect} from '@loopback/testlab'; +import {CompatApp, createCompatApplication} from './compat-app'; describe('v3compat (acceptance)', () => { - class CompatApp extends CompatMixin(RestApplication) {} - let app: CompatApp; - beforeEach(givenApplication); + 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); }); - - async function givenApplication() { - const rest: RestServerConfig = Object.assign({}, givenHttpServerConfig()); - app = new CompatApp({rest}); - } }); 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 index f86aecc4a217..e82d03d5934a 100644 --- a/packages/v3compat/test/acceptance/persisted-model.acceptance.ts +++ b/packages/v3compat/test/acceptance/persisted-model.acceptance.ts @@ -3,23 +3,17 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {RestApplication, RestServerConfig} from '@loopback/rest'; -import { - Client, - createRestAppClient, - expect, - givenHttpServerConfig, - toJSON, -} from '@loopback/testlab'; -import {CompatMixin, PersistedModelClass} from '../..'; +import {Client, createRestAppClient, expect, toJSON} from '@loopback/testlab'; +import {PersistedModelClass} from '../..'; +import {CompatApp, createCompatApplication} from './compat-app'; describe('v3compat (acceptance)', () => { - class CompatApp extends CompatMixin(RestApplication) {} - let app: CompatApp; let client: Client; - beforeEach(givenApplication); + beforeEach(async function givenApplication() { + app = await createCompatApplication(); + }); context('simple PersistedModel', () => { let Todo: PersistedModelClass; @@ -124,11 +118,6 @@ describe('v3compat (acceptance)', () => { }); }); - async function givenApplication() { - const rest: RestServerConfig = Object.assign({}, givenHttpServerConfig()); - app = new (CompatMixin(RestApplication))({rest}); - } - async function givenClient() { await app.start(); client = createRestAppClient(app); From 8309591424a3e04c118786aab2183a8d80d27a64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Mon, 21 Jan 2019 14:58:09 +0100 Subject: [PATCH 10/15] fix(v3compat): fail when attaching a model to an unknown datasource --- packages/v3compat/src/core/lb3-application.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/v3compat/src/core/lb3-application.ts b/packages/v3compat/src/core/lb3-application.ts index 2c92a85b7843..038579cf319a 100644 --- a/packages/v3compat/src/core/lb3-application.ts +++ b/packages/v3compat/src/core/lb3-application.ts @@ -4,6 +4,7 @@ // 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'; @@ -50,6 +51,15 @@ export class Lb3Application { // 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); From cb4a147134b65a4794dd0b2ac5490e4ca6e34ef8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Mon, 21 Jan 2019 15:02:51 +0100 Subject: [PATCH 11/15] feat(example-lb3models): initial commit --- examples/lb3models/LICENSE | 25 +++++ examples/lb3models/README.md | 94 +++++++++++++++++++ examples/lb3models/index.d.ts | 6 ++ examples/lb3models/index.js | 26 +++++ examples/lb3models/index.ts | 8 ++ examples/lb3models/legacy/model-config.json | 6 ++ .../lb3models/legacy/models/coffee-shop.js | 28 ++++++ .../lb3models/legacy/models/coffee-shop.json | 22 +++++ examples/lb3models/package.json | 65 +++++++++++++ examples/lb3models/public/index.html | 73 ++++++++++++++ examples/lb3models/src/application.ts | 56 +++++++++++ examples/lb3models/src/index.ts | 17 ++++ examples/lb3models/src/migrate.ts | 25 +++++ examples/lb3models/src/sequence.ts | 41 ++++++++ examples/lb3models/tsconfig.build.json | 8 ++ examples/lb3models/tslint.build.json | 4 + examples/lb3models/tslint.json | 4 + 17 files changed, 508 insertions(+) create mode 100644 examples/lb3models/LICENSE create mode 100644 examples/lb3models/README.md create mode 100644 examples/lb3models/index.d.ts create mode 100644 examples/lb3models/index.js create mode 100644 examples/lb3models/index.ts create mode 100644 examples/lb3models/legacy/model-config.json create mode 100644 examples/lb3models/legacy/models/coffee-shop.js create mode 100644 examples/lb3models/legacy/models/coffee-shop.json create mode 100644 examples/lb3models/package.json create mode 100644 examples/lb3models/public/index.html create mode 100644 examples/lb3models/src/application.ts create mode 100644 examples/lb3models/src/index.ts create mode 100644 examples/lb3models/src/migrate.ts create mode 100644 examples/lb3models/src/sequence.ts create mode 100644 examples/lb3models/tsconfig.build.json create mode 100644 examples/lb3models/tslint.build.json create mode 100644 examples/lb3models/tslint.json 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..e80f4218a598 --- /dev/null +++ b/examples/lb3models/README.md @@ -0,0 +1,94 @@ +# @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. Edit your boot configuration in `src/application.ts` and add the following + section: + + ```ts + { + v3compat: { + // from "/dist/src/application.ts" to "/legacy" + root: '../../legacy', + }, + } + ``` + +6. 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

+ +

OpenAPI spec: /openapi.json

+

API Explorer: /explorer

+
+ + + + + + diff --git a/examples/lb3models/src/application.ts b/examples/lb3models/src/application.ts new file mode 100644 index 000000000000..7b61a3402c39 --- /dev/null +++ b/examples/lb3models/src/application.ts @@ -0,0 +1,56 @@ +// 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 {RepositoryMixin} from '@loopback/repository'; +import {RestApplication} from '@loopback/rest'; +import {RestExplorerComponent} from '@loopback/rest-explorer'; +import {ServiceMixin} from '@loopback/service-proxy'; +import {CompatMixin} from '@loopback/v3compat'; +import * as path from 'path'; +import {MySequence} from './sequence'; + +export class TodoListApplication extends CompatMixin( + BootMixin(ServiceMixin(RepositoryMixin(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, + }, + + v3compat: { + // from "/dist/src/application.ts" to "/legacy" + root: '../../legacy', + }, + }; + + 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"] +} From e0413f7a94cfa603f7a2c9f6ec5f73935c79a610 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Mon, 21 Jan 2019 15:18:03 +0100 Subject: [PATCH 12/15] feat(v3compat): support callback-based remote methods --- .../src/remoting/Lb3ModelController.ts | 40 ++++++++++++++++++- .../v3compat/src/remoting/rest-adapter.ts | 9 +---- 2 files changed, 40 insertions(+), 9 deletions(-) diff --git a/packages/v3compat/src/remoting/Lb3ModelController.ts b/packages/v3compat/src/remoting/Lb3ModelController.ts index 3b04ce680132..9f5a9c152d9a 100644 --- a/packages/v3compat/src/remoting/Lb3ModelController.ts +++ b/packages/v3compat/src/remoting/Lb3ModelController.ts @@ -4,9 +4,12 @@ // License text available at https://opensource.org/licenses/MIT import {inject} from '@loopback/core'; -import {Request, Response, RestBindings, OperationArgs} from '@loopback/rest'; +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; @@ -38,4 +41,39 @@ export class Lb3ModelController { } 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/rest-adapter.ts b/packages/v3compat/src/remoting/rest-adapter.ts index f216f85fe89c..f3401b8e2bfd 100644 --- a/packages/v3compat/src/remoting/rest-adapter.ts +++ b/packages/v3compat/src/remoting/rest-adapter.ts @@ -56,9 +56,6 @@ export class RestAdapter { this: Lb3ModelController, ...args: OperationArgs ) { - debug('%s initial args %j', sharedMethod.stringName, args); - args = this.buildMethodArguments(sharedMethod, args); - debug('resolved args %j', args); if (!sharedMethod.isStatic) { // TODO: invoke sharedCtor to obtain the model instance throw new HttpErrors.NotImplemented( @@ -66,11 +63,7 @@ export class RestAdapter { ); } - // TODO: beforeRemote, afterRemote, afterRemoteError hooks - - const handler = sharedMethod.getFunction(); - // TODO: callback mode - return await handler.apply(sharedMethod.ctor, args); + return this.invokeStaticMethod(sharedMethod, args); }; debug(' %s %s %j', verb, path, spec); From 63f46b55dbd06bb4018a24545c50889542f2532a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 22 Jan 2019 10:33:52 +0100 Subject: [PATCH 13/15] feat(v3compat): use a more sensible default root for LB3 model booter --- examples/lb3models/README.md | 14 +------------- examples/lb3models/src/application.ts | 5 ----- packages/v3compat/src/boot/lb3-model-booter.ts | 3 ++- 3 files changed, 3 insertions(+), 19 deletions(-) diff --git a/examples/lb3models/README.md b/examples/lb3models/README.md index e80f4218a598..5b8ac6b0a37b 100644 --- a/examples/lb3models/README.md +++ b/examples/lb3models/README.md @@ -39,19 +39,7 @@ application. } ``` -5. Edit your boot configuration in `src/application.ts` and add the following - section: - - ```ts - { - v3compat: { - // from "/dist/src/application.ts" to "/legacy" - root: '../../legacy', - }, - } - ``` - -6. Register your legacy datasources in Application's constructor. +5. Register your legacy datasources in Application's constructor. ```ts this.v3compat.dataSource('mysqlDs', { diff --git a/examples/lb3models/src/application.ts b/examples/lb3models/src/application.ts index 7b61a3402c39..2b967462e0c5 100644 --- a/examples/lb3models/src/application.ts +++ b/examples/lb3models/src/application.ts @@ -36,11 +36,6 @@ export class TodoListApplication extends CompatMixin( extensions: ['.controller.js'], nested: true, }, - - v3compat: { - // from "/dist/src/application.ts" to "/legacy" - root: '../../legacy', - }, }; this.v3compat.dataSource('mysqlDs', { diff --git a/packages/v3compat/src/boot/lb3-model-booter.ts b/packages/v3compat/src/boot/lb3-model-booter.ts index ce5470724c47..f5fac2a7426a 100644 --- a/packages/v3compat/src/boot/lb3-model-booter.ts +++ b/packages/v3compat/src/boot/lb3-model-booter.ts @@ -16,7 +16,8 @@ const fileExists = promisify(fs.exists); const debug = debugFactory('loopback:v3compat:model-booter'); const DefaultOptions = { - root: './legacy', + // from "/dist/src/application.ts" to "/legacy" + root: '../../legacy', }; export class Lb3ModelBooter implements Booter { From de3529b0aa84b6b7680169c4ef0fdd367b406568 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 22 Jan 2019 13:25:07 +0100 Subject: [PATCH 14/15] docs(v3compat): add developer and spike documentation --- SPIKE-NOTES.md | 30 ++ lb3api.md | 156 ----------- packages/v3compat/README.md | 258 +++++++++++++++++- packages/v3compat/docs/internal-design.md | 142 ++++++++++ ...lController.ts => lb3-model-controller.ts} | 0 .../v3compat/src/remoting/rest-adapter.ts | 2 +- 6 files changed, 430 insertions(+), 158 deletions(-) create mode 100644 SPIKE-NOTES.md delete mode 100644 lb3api.md create mode 100644 packages/v3compat/docs/internal-design.md rename packages/v3compat/src/remoting/{Lb3ModelController.ts => lb3-model-controller.ts} (100%) 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/lb3api.md b/lb3api.md deleted file mode 100644 index 3e9d44ba17fc..000000000000 --- a/lb3api.md +++ /dev/null @@ -1,156 +0,0 @@ -## LoopBack 3 APIs we need to support in LB4 - -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 have/next iterations: - -- PersistedModel - all CRUD APIs -- pick up models/methods added after app start -- hasUpdateOnlyProps (different request body schema for "create" method) -- set options from HTTP context (`http: 'optionsFromRequest'`) -- `{rest: {after: convertNullToNotFoundError}}` -- mixins -- case-insensitive URL paths (?) (/Todo is same as /todo) -- model-sources and mixin-sources in model-config.json - -On aside - -- https://github.com/Microsoft/TypeScript/issues/6480 -- extract the Booter contract into a standalone package so that v3compat does - not have to inherit entire boot - -### Model - -- beforeRemote/afterRemote/afterRemoteError -- disableRemoteMethodByName(name) - -### HttpContext - -- method -- req -- res -- options -- args (?) -- methodString -- result - -### SharedMethod - -- isMethodEnabled(sharedMethod) -- resolve(resolver) -- findMethodByName -- disableMethodByName - -### Application - -- app.connector(name, connector) -- app.connectors.{name} - -? app.remotes() - -### Model - -- createOptionsFromRemotingContext -- belongsToRemoting -- Model.hasOneRemoting -- hasManyRemoting -- scopeRemoting -- nestRemoting - -? PersistedModel.createChangeStream - -### KeyValueModel - -- get(key, options, cb) -- set(key, value, options, cb) -- expire(key, options, cb) -- ttl(key, options, cb) -- keys(filter, options, cb) -- iterateKeys(filter, options) - -### Remoting features - -- respond with a Buffer, respond with a ReadableStream - -## WILL NOT HAVE - -- allow LB3 models to be attached to LB4 dataSources. This won't work - because LB3 requires all datasources to share the same ModelBuilder -- CLS-based context -- global registry: loopback.createModel, loopback.findModel, etc. -- Model.checkAccess(token, modelId, sharedMethod, ctx, cb) -- Model.disableRemoteMethod: was already deprecated -- REST API for creating multiple models in one call (allowArray: true) -- PersistedModel change replication - - diff - - changes - - checkpoint - - currentCheckpoint - - replicate - - createUpdates - - bulkUpdate - - getChangeModel - - getSourceId - - enableChangeTracking - - rectifyAllChanges - - handleChangeError - - rectifyChange - - updateLastChange - - createChangeFilter - - fillCustomChangeProperties - -built-in models - -- Access-token -- Acl -- Application -- Change -- Checkpoint -- Email -- RoleMapping -- Role -- Scope -- User - -SharedClass - -- find: was already deprecated -- disableMethod: was already deprecated - -SharedMethod - -- prototype.invoke(scope, args, remotingOptions, ctx, cb) - -HttpContext - -- ~~typeRegistry~~ -- ~~supportedTypes~~ -- invoke -- setReturnArgByName -- getArgByName -- buildArgs -- createStream -- respondWithEventStream -- resolveReponseOperation -- done -- (etc.) - -Remoting - -- XML -- JSON API -- piping retval of remote function into response - -Booting - -- datasources diff --git a/packages/v3compat/README.md b/packages/v3compat/README.md index 357e939229db..024aefd889f1 100644 --- a/packages/v3compat/README.md +++ b/packages/v3compat/README.md @@ -10,7 +10,263 @@ npm install --save @loopback/v3compat ## Basic use -TBD +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 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/src/remoting/Lb3ModelController.ts b/packages/v3compat/src/remoting/lb3-model-controller.ts similarity index 100% rename from packages/v3compat/src/remoting/Lb3ModelController.ts rename to packages/v3compat/src/remoting/lb3-model-controller.ts diff --git a/packages/v3compat/src/remoting/rest-adapter.ts b/packages/v3compat/src/remoting/rest-adapter.ts index f3401b8e2bfd..ab5b11515688 100644 --- a/packages/v3compat/src/remoting/rest-adapter.ts +++ b/packages/v3compat/src/remoting/rest-adapter.ts @@ -12,7 +12,7 @@ import { convertVerb, getClassTags, } from '../specgen'; -import {Lb3ModelController} from './Lb3ModelController'; +import {Lb3ModelController} from './lb3-model-controller'; import {SharedClass} from './shared-class'; import {SharedMethod, joinUrlPaths} from './shared-method'; From 0465a94f08a973fa903f4c5400759598c1951249 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Tue, 22 Jan 2019 14:38:22 +0100 Subject: [PATCH 15/15] fix: make "npm test" pass --- examples/lb3models/src/application.ts | 4 +--- packages/context/src/binding-decorator.ts | 2 +- packages/v3compat/src/compat.mixin.ts | 3 +-- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/examples/lb3models/src/application.ts b/examples/lb3models/src/application.ts index 2b967462e0c5..c973895c7e2e 100644 --- a/examples/lb3models/src/application.ts +++ b/examples/lb3models/src/application.ts @@ -5,16 +5,14 @@ import {BootMixin} from '@loopback/boot'; import {ApplicationConfig} from '@loopback/core'; -import {RepositoryMixin} from '@loopback/repository'; import {RestApplication} from '@loopback/rest'; import {RestExplorerComponent} from '@loopback/rest-explorer'; -import {ServiceMixin} from '@loopback/service-proxy'; import {CompatMixin} from '@loopback/v3compat'; import * as path from 'path'; import {MySequence} from './sequence'; export class TodoListApplication extends CompatMixin( - BootMixin(ServiceMixin(RepositoryMixin(RestApplication))), + BootMixin(RestApplication), ) { constructor(options: ApplicationConfig = {}) { super(options); 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/v3compat/src/compat.mixin.ts b/packages/v3compat/src/compat.mixin.ts index bad40ac36a5f..ace951def6e9 100644 --- a/packages/v3compat/src/compat.mixin.ts +++ b/packages/v3compat/src/compat.mixin.ts @@ -3,8 +3,7 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {Constructor} from '@loopback/context'; -import {Application} from '@loopback/core'; +import {Application, Constructor} from '@loopback/core'; import {CompatComponent} from './compat.component'; import {Lb3Application} from './core';