diff --git a/docs/site/Calling-other-APIs-and-Web-Services.md b/docs/site/Calling-other-APIs-and-Web-Services.md index 7526617f03fe..5970ab5c26e9 100644 --- a/docs/site/Calling-other-APIs-and-Web-Services.md +++ b/docs/site/Calling-other-APIs-and-Web-Services.md @@ -9,12 +9,121 @@ summary: --- Your API implementation often needs to interact with REST APIs, SOAP Web -Services or other forms of APIs. +Services, gRPC microservices, or other forms of APIs. -How to: +To facilitate calling other APIs or web services, we introduce `@loopback/service-proxy` +module to provide a common set of interfaces for interacting with backend services. -- Set up a Service -- Use services with controllers -- Reuse models between services and controllers +## Installation -{% include content/tbd.html %} +``` +$ npm install --save @loopback/service-proxy +``` + +## Usage + +### Define a data source for the service backend + +```ts +import {DataSourceConstructor, juggler} from '@loopback/service-proxy'; + +const ds: juggler.DataSource = new DataSourceConstructor({ + name: 'GoogleMapGeoCode', + connector: 'rest', + options: { + headers: { + 'accept': 'application/json', + 'content-type': 'application/json' + } + }, + operations: [ + { + template: { + method: 'GET', + url: 'http://maps.googleapis.com/maps/api/geocode/{format=json}', + query: { + address: '{street},{city},{zipcode}', + sensor: '{sensor=false}' + }, + responsePath: '$.results[0].geometry.location[0]' + }, + functions: { + geocode: ['street', 'city', 'zipcode'] + } + } + ] +}); +``` + +### Bind data sources to the context + +```ts +import {Context} from '@loopback/context'; + +const context = new Context(); +context.bind('dataSources.geoService').to(ds); +``` + +**NOTE**: Once we start to support declarative datasources with `@loopback/boot`, +the datasource configuration files can be dropped into `src/datasources` to be +discovered and bound automatically. + +### Declare the service interface + +To promote type safety, we recommend you to declare data types and service +interfaces in TypeScript and use them to access the service proxy. + +```ts +interface GeoCode { + lat: number; + lng: number; +} + +interface GeoService { + geocode(street: string, city: string, zipcode: string): Promise; +} +``` + +Alternately, we also provide a weakly-typed generic service interface as follows: + +```ts +/** + * A generic service interface with any number of methods that return a promise + */ +export interface GenericService { + [methodName: string]: (...args: any[]) => Promise; +} +``` + +To reference the `GenericService`: + +```ts +import {GenericService} from '@loopback/service-proxy'; +``` + +**NOTE**: We'll introduce tools in the future to generate TypeScript service +interfaces from service specifications such as OpenAPI spec. + +### Declare service proxies for your controller + +If your controller needs to interact with backend services, declare such +dependencies using `@serviceProxy` on constructor parameters or instance +properties of the controller class. + +```ts +import {serviceProxy} from '@loopback/service-proxy'; + +export class MyController { + + @serviceProxy('geoService') + private geoService: GeoService; + +} +``` + +### Get an instance of your controller + +```ts +context.bind('controllers.MyController').toClass(MyController); +const myController = await context.get('controllers.MyController'); +``` diff --git a/packages/service-proxy/.npmrc b/packages/service-proxy/.npmrc new file mode 100644 index 000000000000..43c97e719a5a --- /dev/null +++ b/packages/service-proxy/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/service-proxy/LICENSE b/packages/service-proxy/LICENSE new file mode 100644 index 000000000000..a5e95ac4c9cd --- /dev/null +++ b/packages/service-proxy/LICENSE @@ -0,0 +1,25 @@ +Copyright (c) IBM Corp. 2018. All Rights Reserved. +Node module: @loopback/service-proxy +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/service-proxy/README.md b/packages/service-proxy/README.md new file mode 100644 index 000000000000..916900847a45 --- /dev/null +++ b/packages/service-proxy/README.md @@ -0,0 +1,31 @@ +# @loopback/service-proxy + +This module provides a common set of interfaces for interacting with service +oriented backends such as REST APIs, SOAP Web Services, and gRPC microservices. + +## Installation + +``` +$ npm install @loopback/service-proxy +``` + +## Basic use + +See https://loopback.io/doc/en/lb4/Calling-other-APIs-and-web-services.html + +## Contributions + +- [Guidelines](https://github.com/strongloop/loopback-next/wiki/Contributing##guidelines) +- [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/service-proxy/docs.json b/packages/service-proxy/docs.json new file mode 100644 index 000000000000..699495ede9e4 --- /dev/null +++ b/packages/service-proxy/docs.json @@ -0,0 +1,9 @@ +{ + "content": [ + "./index.ts", + "./src/index.ts", + "./src/decorators/service.decorator.ts", + "./src/legacy-juggler-bridge.ts" + ], + "codeSectionDepth": 4 +} diff --git a/packages/service-proxy/index.d.ts b/packages/service-proxy/index.d.ts new file mode 100644 index 000000000000..5d1085bdecf4 --- /dev/null +++ b/packages/service-proxy/index.d.ts @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2017. All Rights Reserved. +// Node module: @loopback/service-proxy +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export * from './dist'; diff --git a/packages/service-proxy/index.js b/packages/service-proxy/index.js new file mode 100644 index 000000000000..4bae4b7000c7 --- /dev/null +++ b/packages/service-proxy/index.js @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2017. All Rights Reserved. +// Node module: @loopback/service-proxy +// 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/service-proxy/index.ts b/packages/service-proxy/index.ts new file mode 100644 index 000000000000..a26fd0532530 --- /dev/null +++ b/packages/service-proxy/index.ts @@ -0,0 +1,8 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/service-proxy +// 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/service-proxy/package.json b/packages/service-proxy/package.json new file mode 100644 index 000000000000..d698f2cd4095 --- /dev/null +++ b/packages/service-proxy/package.json @@ -0,0 +1,44 @@ +{ + "name": "@loopback/service-proxy-proxy", + "version": "0.2.0", + "description": "Service integration for LoopBack 4", + "engines": { + "node": ">=8" + }, + "main": "index", + "scripts": { + "acceptance": "lb-mocha \"DIST/test/acceptance/**/*.js\"", + "build": "lb-tsc es2017", + "build:apidocs": "lb-apidocs", + "clean": "lb-clean loopback-service-proxy*.tgz dist package api-docs", + "integration": "lb-mocha \"DIST/test/integration/**/*.js\"", + "pretest": "npm run build", + "test": "lb-mocha \"DIST/test/unit/**/*.js\" \"DIST/test/acceptance/**/*.js\" \"DIST/test/integration/**/*.js\"", + "unit": "lb-mocha \"DIST/test/unit/**/*.js\"", + "verify": "npm pack && tar xf loopback-service-proxy*.tgz && tree package && npm run clean" + }, + "author": "IBM", + "copyright.owner": "IBM Corp.", + "license": "MIT", + "devDependencies": { + "@loopback/build": "^0.2.0", + "@loopback/testlab": "^0.2.0" + }, + "dependencies": { + "@loopback/context": "^0.8.1", + "@loopback/core": "^0.6.1", + "loopback-datasource-juggler": "^3.15.2" + }, + "files": [ + "README.md", + "index.js", + "index.d.ts", + "dist/src", + "api-docs", + "src" + ], + "repository": { + "type": "git", + "url": "https://github.com/strongloop/loopback-next.git" + } +} diff --git a/packages/service-proxy/src/decorators/service.decorator.ts b/packages/service-proxy/src/decorators/service.decorator.ts new file mode 100644 index 000000000000..033b94309524 --- /dev/null +++ b/packages/service-proxy/src/decorators/service.decorator.ts @@ -0,0 +1,76 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/service-proxy +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + MetadataAccessor, + inject, + Context, + Injection, + InjectionMetadata, +} from '@loopback/context'; +import {getService, juggler} from '..'; + +/** + * Type definition for decorators returned by `@serviceProxy` decorator factory + */ +export type ServiceProxyDecorator = PropertyDecorator | ParameterDecorator; + +export const SERVICE_PROXY_KEY = MetadataAccessor.create< + string, + ServiceProxyDecorator +>('service.proxy'); + +/** + * Metadata for a service proxy + */ +export class ServiceProxyMetadata implements InjectionMetadata { + decorator = '@serviceProxy'; + dataSourceName?: string; + dataSource?: juggler.DataSource; + + constructor(dataSource: string | juggler.DataSource) { + if (typeof dataSource === 'string') { + this.dataSourceName = dataSource; + } else { + this.dataSource = dataSource; + } + } +} + +export function serviceProxy(dataSource: string | juggler.DataSource) { + return function( + target: Object, + key: symbol | string, + parameterIndex?: number, + ) { + if (key || typeof parameterIndex === 'number') { + const meta = new ServiceProxyMetadata(dataSource); + inject('', meta, resolve)(target, key, parameterIndex); + } else { + throw new Error( + '@serviceProxy can only be applied to properties or method parameters', + ); + } + }; +} + +/** + * Resolve the @repository injection + * @param ctx Context + * @param injection Injection metadata + */ +async function resolve(ctx: Context, injection: Injection) { + const meta = injection.metadata as ServiceProxyMetadata; + if (meta.dataSource) return getService(meta.dataSource); + if (meta.dataSourceName) { + const ds = await ctx.get( + 'datasources.' + meta.dataSourceName, + ); + return getService(ds); + } + throw new Error( + '@serviceProxy must provide a name or an instance of DataSource', + ); +} diff --git a/packages/service-proxy/src/index.ts b/packages/service-proxy/src/index.ts new file mode 100644 index 000000000000..28662abbf031 --- /dev/null +++ b/packages/service-proxy/src/index.ts @@ -0,0 +1,8 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/service-proxy +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export * from './legacy-juggler-bridge'; +export * from './loopback-datasource-juggler'; +export * from './decorators/service.decorator'; diff --git a/packages/service-proxy/src/legacy-juggler-bridge.ts b/packages/service-proxy/src/legacy-juggler-bridge.ts new file mode 100644 index 000000000000..4c1cb3a75828 --- /dev/null +++ b/packages/service-proxy/src/legacy-juggler-bridge.ts @@ -0,0 +1,29 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/service-proxy +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +const jugglerModule = require('loopback-datasource-juggler'); + +import {juggler} from './loopback-datasource-juggler'; + +export const DataSourceConstructor = jugglerModule.DataSource as typeof juggler.DataSource; + +/** + * A generic service interface with any number of methods that return a promise + */ +export interface GenericService { + // tslint:disable-next-line:no-any + [methodName: string]: (...args: any[]) => Promise; +} + +/** + * Get a service proxy from a LoopBack 3.x data source backed by + * service-oriented connectors such as `rest`, `soap`, and `grpc`. + * + * @param ds A legacy data source + * @typeparam T The generic type of service interface + */ +export function getService(ds: juggler.DataSource): T { + return ds.DataAccessObject as T; +} diff --git a/packages/service-proxy/src/loopback-datasource-juggler.ts b/packages/service-proxy/src/loopback-datasource-juggler.ts new file mode 100644 index 000000000000..1ef24c7c27a5 --- /dev/null +++ b/packages/service-proxy/src/loopback-datasource-juggler.ts @@ -0,0 +1,22 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/service-proxy +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +// tslint:disable:no-any + +export declare namespace juggler { + /** + * DataSource instance properties/operations + */ + export class DataSource { + name: string; + connected?: boolean; + connector?: object; + settings: {[name: string]: any}; + DataAccessObject: {[name: string]: any}; + + constructor(name?: string, settings?: {[name: string]: any}); + constructor(settings?: {[name: string]: any}); + } +} diff --git a/packages/service-proxy/test/integration/service-proxy.integration.ts b/packages/service-proxy/test/integration/service-proxy.integration.ts new file mode 100644 index 000000000000..76574733ab2c --- /dev/null +++ b/packages/service-proxy/test/integration/service-proxy.integration.ts @@ -0,0 +1,65 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/service-proxy +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect} from '@loopback/testlab'; + +import { + DataSourceConstructor, + juggler, + getService, + GenericService, +} from '../..'; + +import {MockConnector} from '../mock-service.connector'; + +describe('service-proxy', () => { + let ds: juggler.DataSource; + + before(function() { + ds = new DataSourceConstructor({ + name: 'mock', + connector: MockConnector, + }); + }); + + it('invokes geocode()', async () => { + type GeoCode = { + lat: number; + lng: number; + }; + + interface GeoService { + geocode(street: string, city: string, zipcode: string): Promise; + } + + const loc = { + street: '107 S B St', + city: 'San Mateo', + zipcode: '94401', + }; + + const geoService = getService(ds); + const result = await geoService.geocode(loc.street, loc.city, loc.zipcode); + + // { lat: 37.5669986, lng: -122.3237495 } + expect(result.lat).approximately(37.5669986, 0.5); + expect(result.lng).approximately(-122.3237495, 0.5); + }); + + it('invokes geocode() as GenericService', async () => { + const loc = { + street: '107 S B St', + city: 'San Mateo', + zipcode: '94401', + }; + + const geoService: GenericService = getService(ds); + const result = await geoService.geocode(loc.street, loc.city, loc.zipcode); + + // { lat: 37.5669986, lng: -122.3237495 } + expect(result.lat).approximately(37.5669986, 0.5); + expect(result.lng).approximately(-122.3237495, 0.5); + }); +}); diff --git a/packages/service-proxy/test/mock-service.connector.ts b/packages/service-proxy/test/mock-service.connector.ts new file mode 100644 index 000000000000..33f832cbe705 --- /dev/null +++ b/packages/service-proxy/test/mock-service.connector.ts @@ -0,0 +1,50 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/service-proxy +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {juggler} from '..'; +/** + * A mockup service connector + */ +export class MockConnector { + connected?: boolean; + dataSource: juggler.DataSource; + + static initialize( + dataSource: juggler.DataSource, + // tslint:disable-next-line:no-any + cb: (err: any, result: any) => void, + ) { + const connector = new MockConnector(); + connector.dataSource = dataSource; + dataSource.connector = connector; + connector.connect(cb); + } + + connect( + // tslint:disable-next-line:no-any + cb: (err: any, connected: boolean) => void, + ) { + this.connected = true; + this.dataSource.connected = true; + process.nextTick(() => { + cb(null, true); + }); + } + + get DataAccessObject() { + return { + geocode: async function(street: string, city: string, zipcode: string) { + return { + lat: 37.5669986, + lng: -122.3237495, + }; + }, + + // loopback-datasource-juggler expects a prototype + // https://github.com/strongloop/loopback-datasource-juggler/blob/v3.18.1/lib/datasource.js#L168 + prototype: {}, + }; + } +} diff --git a/packages/service-proxy/test/unit/decorators/service-proxy.decorator.unit.ts b/packages/service-proxy/test/unit/decorators/service-proxy.decorator.unit.ts new file mode 100644 index 000000000000..32f7eb2a4370 --- /dev/null +++ b/packages/service-proxy/test/unit/decorators/service-proxy.decorator.unit.ts @@ -0,0 +1,80 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/service-proxy +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect} from '@loopback/testlab'; +import {Context} from '@loopback/context'; +import {serviceProxy} from '../../../'; + +import {DataSourceConstructor, juggler} from '../../../'; + +import {MockConnector} from '../../mock-service.connector'; + +interface GeoCode { + lat: number; + lng: number; +} + +interface GeoService { + geocode(street: string, city: string, zipcode: string): Promise; +} + +class MyController { + constructor(@serviceProxy('googleMap') public geoService: GeoService) {} +} + +describe('serviceProxy decorator', () => { + let ctx: Context; + let ds: juggler.DataSource; + + before(function() { + ds = new DataSourceConstructor({ + name: 'db', + connector: MockConnector, + }); + + ctx = new Context(); + ctx.bind('datasources.googleMap').to(ds); + ctx.bind('controllers.MyController').toClass(MyController); + }); + + it('supports @serviceProxy(dataSourceName)', async () => { + const myController = await ctx.get( + 'controllers.MyController', + ); + expect(myController.geoService).to.be.a.Function(); + }); + + it('supports @serviceProxy(dataSource)', async () => { + class MyControllerWithService { + constructor(@serviceProxy(ds) public geoService: GeoService) {} + } + + ctx + .bind('controllers.MyControllerWithService') + .toClass(MyControllerWithService); + + const myController = await ctx.get( + 'controllers.MyControllerWithService', + ); + expect(myController.geoService).to.be.a.Function(); + }); + + it('throws an error if dataSource is not present in @serviceProxy', () => { + class MyControllerWithService { + constructor(@serviceProxy('') public geoService: GeoService) {} + } + + ctx + .bind('controllers.MyControllerWithService') + .toClass(MyControllerWithService); + + const controller = ctx.get( + 'controllers.MyControllerWithService', + ); + return expect(controller).to.be.rejectedWith( + '@serviceProxy must provide a name or an instance of DataSource', + ); + }); +}); diff --git a/packages/service-proxy/tsconfig.build.json b/packages/service-proxy/tsconfig.build.json new file mode 100644 index 000000000000..3ffcd508d23e --- /dev/null +++ b/packages/service-proxy/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "extends": "../build/config/tsconfig.common.json", + "compilerOptions": { + "rootDir": "." + }, + "include": ["index.ts", "src", "test"] +}