From 9b74bd56c22edc7e9599dafc06c164178495c199 Mon Sep 17 00:00:00 2001 From: wss-git Date: Wed, 9 Jun 2021 11:14:08 +0800 Subject: [PATCH] feat: init repo --- README.md | 43 +++++++++ package.json | 6 +- publish.yaml | 2 +- src/common/help.ts | 77 ++++++++++++++++ src/common/logger.ts | 46 +++++----- src/index.ts | 133 ++++++++++++++++++---------- src/interface/entity.ts | 38 ++++++++ src/lib/client.ts | 14 +++ src/lib/event.ts | 54 ++++++++++++ src/lib/remote-invoke.ts | 186 +++++++++++++++++++++++++++++++++++++++ src/lib/stdin.ts | 33 +++++++ 11 files changed, 559 insertions(+), 73 deletions(-) create mode 100644 src/common/help.ts create mode 100644 src/interface/entity.ts create mode 100644 src/lib/client.ts create mode 100644 src/lib/event.ts create mode 100644 src/lib/remote-invoke.ts create mode 100644 src/lib/stdin.ts diff --git a/README.md b/README.md index 885487d..05d8daa 100644 --- a/README.md +++ b/README.md @@ -1 +1,44 @@ ## 组件说明 + +fc-remote-invoke 组件调用 FC 函数。 + +## 带有 YAML 文件用法 + +### yaml 配置 + +```` +edition: 1.0.0 # 命令行YAML规范版本,遵循语义化版本(Semantic Versioning)规范 +name: compoent-test # 项目名称 + +services: + component-test: + component: devsapp/fc-remote-invoke # 这里引入的是相对路径,正式配置替换成你自己的component名称即可 + props: + region: ${region} + serviceName: ${serviceName} + functionName: ${functionName} +```` + +### 函数调用 + + +```` +$ s exec -- invoke --invocation-type sync --event ${payload} +$ s exec -- invoke --invocation-type async --event-file ${path} +$ s exec -- invoke --event-stdin +```` + +## CLI 用法 + +```` +$ s cli fc-remote-invoke invoke --region * --service-name * --function-name * --invocation-type sync --event ${payload} +$ s cli fc-remote-invoke invoke --region * --service-name * --function-name * --invocation-type async --event-file ${path} +$ s cli fc-remote-invoke invoke --region * --service-name * --function-name * --event-stdin +```` + +## 特别说明 + +当函数是 http 函数时,event最终获取值目前仅支持 json 字符串,[示例参考](https://github.com/devsapp/fc-remote-invoke/blob/master/example/http.json) + +invocation-type 选填,默认 sync +event 选填,event 函数默认为空字符串,http 函数默认 GET 请求,其他参数为空 \ No newline at end of file diff --git a/package.json b/package.json index 7922340..866f5d5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fc-remote-invoke", - "version": "0.0.1", + "version": "0.0.6", "description": "This is a component demo for Serverless Devs Tool ", "keywords": [ "Serverless", @@ -32,8 +32,10 @@ "dependencies": { "@alicloud/fc2": "^2.2.2", "@serverless-devs/core": "^0.0.*", + "fs-extra": "^10.0.0", "i18n": "^0.13.2", - "lodash.get": "^4.4.2" + "lodash.get": "^4.4.2", + "readline": "^1.3.0" }, "devDependencies": { "@types/lodash": "^4.14.168", diff --git a/publish.yaml b/publish.yaml index 701a198..8c8b7d6 100644 --- a/publish.yaml +++ b/publish.yaml @@ -2,7 +2,7 @@ Type: Component Name: fc-remote-invoke Provider: - 其它 -Version: 0.0.1 +Version: 0.0.6 Description: 初始化component模板 HomePage: https://www.serverless-devs.com Tags: #标签详情 diff --git a/src/common/help.ts b/src/common/help.ts new file mode 100644 index 0000000..ad7fea7 --- /dev/null +++ b/src/common/help.ts @@ -0,0 +1,77 @@ +export default [ + { + header: 'Description', + content: 'Remote Invoke', + }, + { + header: 'Usage', + content: '$ fc-remote-invoke invoke ', + }, + { + header: 'Options', + optionList: [ + { + name: 'invocation-type', + description: 'Invocation type: optional value "async"|"sync", default value "sync" (default: "sync")', + alias: '-t', + type: String, + }, + { + name: 'event', + description: 'Event data (strings) passed to the function during invocation (default: "")', + alias: '-e', + type: String, + }, + { + name: 'event-file', + description: 'A file containing event data passed to the function during invoke.', + alias: '-f', + type: String, + }, + { + name: 'event-stdin', + description: 'Read from standard input, to support script pipeline.', + alias: '-s', + type: Boolean, + }, + { + name: 'region', + description: 'Pass in region in cli mode', + type: String, + }, + { + name: 'service-name', + description: 'Pass in service name in cli mode', + type: String, + }, + { + name: 'function-name', + description: 'Pass in function name in cli mode', + type: String, + }, + ], + }, + { + header: 'Global Options', + optionList: [ + { + name: 'help', + description: 'fc-remote-invoke help for command', + alias: 'h', + type: Boolean, + }, + ], + }, + { + header: 'Examples with Yaml', + content: [ + '$ s exec -- invoke ', + ], + }, + { + header: 'Examples with Cli', + content: [ + '$ s cli fc-remote-invoke invoke --region * --service-name * --function-name * ', + ], + }, +] \ No newline at end of file diff --git a/src/common/logger.ts b/src/common/logger.ts index 988b6d3..2c95b65 100644 --- a/src/common/logger.ts +++ b/src/common/logger.ts @@ -3,34 +3,32 @@ import i18n from './i18n'; import { Logger } from '@serverless-devs/core'; export default class ComponentLogger { - static CONTENT = 'FC-REMOTE-INVOKE'; - static setContent(content) { - ComponentLogger.CONTENT = content; - } - static log(m, color?: 'black' | 'red' | 'green' | 'yellow' | 'blue' | 'magenta' | 'cyan' | 'white' | 'whiteBright' | 'gray') { - Logger.log(i18n.__(m) || m, color); - } - static info(m) { - Logger.info(ComponentLogger.CONTENT, i18n.__(m) || m); - } + static CONTENT = 'FC-REMOTE-INVOKE'; + static setContent(content) { + ComponentLogger.CONTENT = content; + } + static log(m, color?: 'black' | 'red' | 'green' | 'yellow' | 'blue' | 'magenta' | 'cyan' | 'white' | 'whiteBright' | 'gray') { + Logger.log(i18n.__(m) || m, color); + } + static info(m) { + Logger.info(ComponentLogger.CONTENT, i18n.__(m) || m); + } - static debug(m) { - Logger.debug(ComponentLogger.CONTENT, i18n.__(m) || m); - } + static debug(m) { + Logger.debug(ComponentLogger.CONTENT, i18n.__(m) || m); + } - static error(m) { - Logger.error(ComponentLogger.CONTENT, i18n.__(m) || m); - } + static error(m) { + Logger.error(ComponentLogger.CONTENT, i18n.__(m) || m); + } - static warning(m) { - Logger.warn(ComponentLogger.CONTENT, i18n.__(m) || m); - } - - - static success(m) { - Logger.log(i18n.__(m) || m, 'green'); - } + static warning(m) { + Logger.warn(ComponentLogger.CONTENT, i18n.__(m) || m); + } + static success(m) { + Logger.log(i18n.__(m) || m, 'green'); + } } diff --git a/src/index.ts b/src/index.ts index 71cfa57..4ed8f3b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,45 +1,93 @@ -import logger from './common/logger'; import _ from 'lodash'; import * as core from '@serverless-devs/core'; +import logger from './common/logger'; +import help from './common/help'; +import { InputProps, ICredentials, isProperties, IProperties } from './interface/entity'; +import RemoteInvoke from './lib/remote-invoke'; export default class FcRemoteInvoke { - async report(componentName: string, command: string, accountID?: string, access?: string): Promise { - let uid: string = accountID; - if (_.isEmpty(accountID)) { - const credentials = await core.getCredential(access); - uid = credentials.AccountID; - } - + async report(componentName: string, command: string, accountID: string): Promise { core.reportComponent(componentName, { command, - uid, + uid: accountID, }); } - async handlerInputs(inputs): Promise { - const project = inputs?.project; - const properties = inputs?.props; - const access: string = project?.access; - const appName: string = inputs?.appName; - const credentials = await core.getCredential(access); - // 去除 args 的行首以及行尾的空格 - const args: string = inputs?.args.replace(/(^\s*)|(\s*$)/g, ''); - const curPath: any = inputs?.path; - const projectName: string = project?.projectName; - const { region } = properties; + async handlerInputs(inputs: InputProps): Promise { + const credentials: ICredentials = await core.getCredential(inputs?.project?.access); - if (args?.includes('help')) { + // 去除 args 的行首以及行尾的空格 + const args: string = (inputs?.args || '').replace(/(^\s*)|(\s*$)/g, ''); + logger.debug(`input args: ${args}`); + + const parsedArgs: {[key: string]: any} = core.commandParse({ args }, { + boolean: ['help', 'event-stdin'], + string: ['invocation-type', 'event', 'event-file', 'region', 'service-name', 'function-name', 'qualifier'], + alias: { + 'help': 'h', + 'event': 'e', + 'invocation-type': 't', + 'event-file': 'f', + 'event-stdin': 's', + } + }); + + const argsData: any = parsedArgs?.data || {}; + logger.debug(`command parse: ${JSON.stringify(argsData)}`); + if (argsData.help) { return { - region, credentials, - curPath, - args, - access, + isHelp: true, }; } - - return {}; + const { + e: event, + f: eventFile, + s: eventStdin, + t: invocationType = 'sync', + } = argsData; + const eventPayload = { event, eventFile, eventStdin }; + // @ts-ignore: 判断三个值有几个真 + const eventFlag = !!event + !!eventFile + !!eventStdin; + + if (eventFlag > 1) { + throw new Error('event | event-file | event-stdin must choose one.'); + } else if (eventFlag === 0) { + eventPayload.event = ''; + } + + if (!['sync', 'async'].includes(invocationType)) { + throw new Error('invocation-type enum value sync, async.'); + } + + logger.debug(`input props: ${JSON.stringify(inputs.props)}`); + + let props: IProperties = { + region: argsData.region, + serviceName: argsData['service-name'], + functionName: argsData['function-name'], + }; + logger.debug(`input args props: ${JSON.stringify(props)}`); + + if (!isProperties(props)) { + props = inputs.props; + } + logger.debug(`props: ${JSON.stringify(props)}`); + + if (!isProperties(props)) { + throw new Error('region/serviceName(service-name)/functionName(function-name) can not be empty.'); + } + + props.qualifier = argsData.qualifier || inputs.props?.qualifier; + + return { + props, + credentials, + eventPayload, + isHelp: false, + invocationType: _.upperFirst(invocationType), + }; } /** @@ -47,29 +95,22 @@ export default class FcRemoteInvoke { * @param inputs * @returns */ - public async invoke(inputs): Promise { + public async invoke(inputs: InputProps): Promise { const { - args, - credentials + props, + eventPayload, + credentials, + isHelp, + invocationType, } = await this.handlerInputs(inputs); - await this.report('fc-remote-invoke', 'invoke', credentials?.AccountID); - const parsedArgs: {[key: string]: any} = core.commandParse({ args }, { - boolean: ['debug'], - alias: { 'help': 'h', - 'config': 'c', - 'mode': 'm', - 'event': 'e', - 'event-file': 'f', - 'event-stdin': 's', - 'debug-port': 'd' - } - }); - const argsData: any = parsedArgs?.data || {}; - if (argsData.help) { - // TODO: help info + // await this.report('fc-remote-invoke', 'invoke', credentials?.AccountID); + + if (isHelp) { + core.help(help) return; } - return {}; + const remoteInvoke = new RemoteInvoke(props.region, credentials); + await remoteInvoke.invoke(props, eventPayload, { invocationType }); } } diff --git a/src/interface/entity.ts b/src/interface/entity.ts new file mode 100644 index 0000000..f914226 --- /dev/null +++ b/src/interface/entity.ts @@ -0,0 +1,38 @@ +export interface ICredentials { + AccountID?: string, + AccessKeyID?: string, + AccessKeySecret?: string, + SecurityToken?: string, +} + +export interface InputProps { + props?: IProperties, // 用户自定义输入 + credentials: ICredentials, // 用户秘钥 + appName: string, // + project: { + component: string, // 组件名(支持本地绝对路径) + access: string, // 访问秘钥名 + projectName: string, // 项目名 + }, + command: string, // 执行指令 + args: string, // 命令行 扩展参数 + path: { + configPath: string // 配置路径 + } +} + +export interface IProperties { + region: string; + serviceName: string; + functionName: string; + qualifier?: string; +} +export function isProperties(args: any): args is IProperties { + return args && args.region && args.serviceName && args.functionName; +} + +export interface IEventPayload { + event?: string, + eventFile?: string, + eventStdin?: boolean, +} diff --git a/src/lib/client.ts b/src/lib/client.ts new file mode 100644 index 0000000..542ab13 --- /dev/null +++ b/src/lib/client.ts @@ -0,0 +1,14 @@ +import FC from '@alicloud/fc2'; +import { ICredentials } from '../interface/entity'; + +export default class Client { + static buildFcClient(region: string, credentials: ICredentials) { + return new FC(credentials.AccountID, { + accessKeyID: credentials.AccessKeyID, + accessKeySecret: credentials.AccessKeySecret, + securityToken: credentials.SecurityToken, + region, + timeout: 6000000, + }) + } +} \ No newline at end of file diff --git a/src/lib/event.ts b/src/lib/event.ts new file mode 100644 index 0000000..e3b46cf --- /dev/null +++ b/src/lib/event.ts @@ -0,0 +1,54 @@ +import path from 'path'; +import fs from 'fs-extra'; +import { isString } from 'lodash'; +import readline from 'readline'; +import logger from '../common/logger'; +import { getStdin } from './stdin'; + +export default class File { + + static async getEvent(eventFile) { + let event = await getStdin(); // read from pipes + + if (!eventFile) return event; + + return await new Promise((resolve, reject) => { + let input; + + if (eventFile === '-') { // read from stdin + logger.log('Reading event data from stdin, which can be ended with Enter then Ctrl+D') + input = process.stdin; + } else { + input = fs.createReadStream(eventFile, { + encoding: 'utf-8' + }) + } + const rl = readline.createInterface({ + input, + output: process.stdout + }) + + event = ''; + rl.on('line', (line) => { + event += line + }) + rl.on('close', () => resolve(event)) + + rl.on('SIGINT', () => reject(new Error('^C'))) + }) + } + + static async eventPriority(eventPriority) { + let eventFile: string; + + if (isString(eventPriority.event)) { + return eventPriority.event; + } else if (eventPriority.eventStdin) { + eventFile = '-'; + } else if (eventPriority.eventFile) { + eventFile = path.resolve(process.cwd(), eventPriority.eventFile); + } + + return await this.getEvent(eventFile) + } +} \ No newline at end of file diff --git a/src/lib/remote-invoke.ts b/src/lib/remote-invoke.ts new file mode 100644 index 0000000..94f552f --- /dev/null +++ b/src/lib/remote-invoke.ts @@ -0,0 +1,186 @@ +import _ from 'lodash'; +import Client from './client'; +import { IProperties, IEventPayload } from '../interface/entity'; +import Event from './event'; +import logger from '../common/logger'; + +export default class RemoteInvoke { + fcClient: any; + accountId: string; + + constructor(region: string, credentials) { + this.accountId = credentials.AccountID; + this.fcClient = Client.buildFcClient(region, credentials); + } + + async invoke (props: IProperties, eventPayload: IEventPayload, { invocationType }) { + const event = await Event.eventPriority(eventPayload); + logger.debug(`event: ${event}`); + + const { + region, + serviceName, + functionName, + qualifier, + } = props; + const httpTriggers = await this.getHttpTrigger(serviceName, functionName) + + const payload: any = { event, serviceName, functionName, qualifier }; + if (_.isEmpty(httpTriggers)) { + payload.invocationType = invocationType; + payload.event = event; + await this.eventInvoke(payload); + } else { + payload.region = region; + try { + payload.event = event ? JSON.parse(event) : {}; + } catch (ex) { + logger.debug(ex); + throw new Error('handler event error. Example: https://github.com/devsapp/fc-remote-invoke/blob/master/example/http.json'); + } + + await this.httpInvoke(payload); + } + } + + async getHttpTrigger(serviceName, functionName) { + const { data } = await this.fcClient.listTriggers(serviceName, functionName); + logger.debug(`get listTriggers: ${JSON.stringify(data)}`); + + if (_.isEmpty(data.triggers)) { return [] } + + const httpTrigger = data.triggers.filter(t => t.triggerType === 'http' || t.triggerType === 'https') + if (_.isEmpty(httpTrigger)) { return [] } + + return httpTrigger; + } + + async eventInvoke({ + serviceName, + functionName, + event, + qualifier = 'LATEST', + invocationType + }) { + + if (invocationType === 'Sync') { + const rs = await this.fcClient.invokeFunction(serviceName, functionName, event, { + 'X-Fc-Log-Type': 'Tail', + 'X-Fc-Invocation-Type': invocationType + }, qualifier); + + const log = rs.headers['x-fc-log-result']; + + if (log) { + logger.log('========= FC invoke Logs begin =========', 'yellow'); + const decodedLog = Buffer.from(log, 'base64'); + logger.log(decodedLog.toString()); + logger.log('========= FC invoke Logs end =========', 'yellow'); + + logger.log('\nFC Invoke Result:', 'green'); + console.log(rs.data); + console.log('\n'); + } + } else { + await this.fcClient.invokeFunction(serviceName, functionName, event, { + 'X-Fc-Invocation-Type': invocationType + }, qualifier); + + logger.log('`${serviceName}/${functionName} async invoke success.\n`', 'green'); + } + } + + async httpInvoke({ region, serviceName, functionName, event, qualifier }) { + const q = qualifier ? `.${qualifier}` : ''; + event.path = `/proxy/${serviceName}${q}/${functionName}/${event.path || ''}`; + + logger.log(`https://${this.accountId}.${region}.fc.aliyuncs.com/2016-08-15/proxy/${serviceName}${q}/${functionName}/`); + await this.request(event) + } + + /** + * @param event: { body, headers, method, queries, path } + * path 组装后的路径 /proxy/serviceName/functionName/path , + */ + async request (event) { + const { headers, queries, method, path: p, body } = this.handlerHttpParmase(event); + + let resp; + try { + const mt = method.toLocaleUpperCase(); + logger.debug(`method is ${mt}.`); + logger.debug(`start invoke.`); + if (mt === 'GET') { + resp = await this.fcClient.get(p, queries, headers); + } else if (mt === 'POST') { + resp = await this.fcClient.post(p, body, headers, queries); + } else if (mt === 'PUT') { + resp = await this.fcClient.put(p, body, headers); + } else if (mt === 'DELETE') { + resp = await this.fcClient.request('DELETE', p, queries, null, headers); + /* else if (method.toLocaleUpperCase() === 'PATCH') { + resp = await this.fcClient.request('PATCH', p, queries, body, headers); + } else if (method.toLocaleUpperCase() === 'HEAD') { + resp = await this.fcClient.request('HEAD', p, queries, body, headers); + } */ + } else { + logger.log(`Does not support ${method} requests temporarily.`); + } + } catch (e) { + logger.debug(e); + if (e.message === 'Unexpected token r in JSON at position 0' && e.stack.includes('/fc2/lib/client.js') && e.stack.includes('at Client.request')) { + throw new Error('The body in http responss is not in json format, but the content-type in response header is application/json. We recommend that you make the format of the response body be consistent with the content-type in response header.'); + } + throw e; + } + logger.debug(`end invoke.`); + + if (resp) { + const log = resp.headers['x-fc-log-result']; + if (log) { + logger.log('\n========= FC invoke Logs begin =========', 'yellow'); + const decodedLog = Buffer.from(log, 'base64') + logger.log(decodedLog.toString()) + logger.log('========= FC invoke Logs end =========', 'yellow'); + } + logger.log('\nFC Invoke Result:', 'green'); + console.log(resp.data); + console.log('\n'); + } + } + + handlerHttpParmase (event) { + const { body = '', headers = {}, method = 'GET', queries = '', path: p = '' } = event; + + let postBody; + if (body) { + let buff = null; + if (Buffer.isBuffer(body)) { + buff = body; + headers['content-type'] = 'application/octet-stream'; + } else if (typeof body === 'string') { + buff = Buffer.from(body, 'utf8'); + headers['content-type'] = 'application/octet-stream'; + } else if (typeof body.pipe === 'function') { + buff = body; + headers['content-type'] = 'application/octet-stream'; + } else { + buff = Buffer.from(JSON.stringify(body), 'utf8'); + headers['content-type'] = 'application/json'; + } + postBody = buff; + } + + if (!headers['X-Fc-Log-Type']) { + headers['X-Fc-Log-Type'] = 'Tail'; + } + + return { + headers, + queries, + method, + path: p, + body: postBody + } + } +} \ No newline at end of file diff --git a/src/lib/stdin.ts b/src/lib/stdin.ts new file mode 100644 index 0000000..4c178a1 --- /dev/null +++ b/src/lib/stdin.ts @@ -0,0 +1,33 @@ +const {stdin} = process; + +export async function getStdin() { + let result = ''; + + if (stdin.isTTY) { + return result; + } + + stdin.setEncoding('utf8'); + + for await (const chunk of stdin) { + result += chunk; + } + + return result; +} + +getStdin.buffer = async () => { + const result = []; + let length = 0; + + if (stdin.isTTY) { + return Buffer.concat([]); + } + + for await (const chunk of stdin) { + result.push(chunk); + length += chunk.length; + } + + return Buffer.concat(result, length); +}; \ No newline at end of file