diff --git a/runtimes/nodejs/package-lock.json b/runtimes/nodejs/package-lock.json index 8860858117..962ae9f644 100644 --- a/runtimes/nodejs/package-lock.json +++ b/runtimes/nodejs/package-lock.json @@ -33,8 +33,10 @@ "multer": "^1.4.5-lts.1", "node-modules-utils": "^1.0.0", "nodemailer": "^6.6.3", + "openapi3-ts": "^4.2.2", "pako": "^2.1.0", "source-map-support": "^0.5.21", + "typescript": "^5.0.2", "typescript-language-server": "^3.3.2", "validator": "^13.7.0", "vscode-languageserver": "^9.0.1", @@ -56,8 +58,7 @@ "@types/node": "^20.9.5", "@types/nodemailer": "^6.4.4", "@types/validator": "^13.1.3", - "@types/ws": "^8.5.3", - "typescript": "^5.0.2" + "@types/ws": "^8.5.3" } }, "node_modules/@aws-crypto/crc32": { @@ -4746,6 +4747,14 @@ "wrappy": "1" } }, + "node_modules/openapi3-ts": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-4.2.2.tgz", + "integrity": "sha512-+9g4actZKeb3czfi9gVQ4Br2Ju3KwhCAQJBNaKgye5KggqcBLIhFHH+nIkcm0BUX00TrAJl6dH4JWgM4G4JWrw==", + "dependencies": { + "yaml": "^2.3.4" + } + }, "node_modules/openid-client": { "version": "5.6.1", "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.6.1.tgz", @@ -5531,10 +5540,9 @@ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" }, "node_modules/typescript": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", - "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", - "dev": true, + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.3.tgz", + "integrity": "sha512-KrPd3PKaCLr78MalgiwJnA25Nm8HAmdwN3mYUYZgG/wizIo9EainNVQI9/yDavtVFRN2h3k8uf3GLHuhDMgEHg==", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5829,6 +5837,17 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, + "node_modules/yaml": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.1.tgz", + "integrity": "sha512-pIXzoImaqmfOrL7teGUBt/T7ZDnyeGBWyXQBvOVhLkWLN37GXv8NMLK406UY6dS51JfcQHsmcW5cJ441bHg6Lg==", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/yocto-queue": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz", diff --git a/runtimes/nodejs/package.json b/runtimes/nodejs/package.json index a6b518f270..18dee562d8 100644 --- a/runtimes/nodejs/package.json +++ b/runtimes/nodejs/package.json @@ -50,6 +50,7 @@ "multer": "^1.4.5-lts.1", "node-modules-utils": "^1.0.0", "nodemailer": "^6.6.3", + "openapi3-ts": "^4.2.2", "pako": "^2.1.0", "source-map-support": "^0.5.21", "typescript-language-server": "^3.3.2", @@ -57,7 +58,8 @@ "vscode-languageserver": "^9.0.1", "vscode-ws-jsonrpc-cjs": "^3.0.0", "ws": "^8.11.0", - "zlib": "^1.0.5" + "zlib": "^1.0.5", + "typescript": "^5.0.2" }, "devDependencies": { "@types/cors": "^2.8.13", @@ -73,8 +75,7 @@ "@types/node": "^20.9.5", "@types/nodemailer": "^6.4.4", "@types/validator": "^13.1.3", - "@types/ws": "^8.5.3", - "typescript": "^5.0.2" + "@types/ws": "^8.5.3" }, "nodemonConfig": { "ignore": [ diff --git a/runtimes/nodejs/src/handler/openapi.ts b/runtimes/nodejs/src/handler/openapi.ts new file mode 100644 index 0000000000..43581af59f --- /dev/null +++ b/runtimes/nodejs/src/handler/openapi.ts @@ -0,0 +1,25 @@ +import { Response } from 'express' +import { parseToken } from '../support/token' +import { IRequest } from '../support/types' +import { buildOpenAPIDefinition } from '../support/openapi' +import Config from '../config' + +export async function handleOpenAPIDefinition(req: IRequest, res: Response) { + // verify the openapi token + const token = req.query['token'] as string + if (!token) { + return res.status(400).send('x-laf-openapi-token is required') + } + const auth = parseToken(token) || null + if (auth?.type !== 'openapi') { + return res.status(403).send('permission denied: invalid openapi token') + } + + const doc = buildOpenAPIDefinition({ + title: `Laf Application ${Config.APPID} Cloud Function API`, + version: '1.0.0', + host: req.get('host'), + apiVersion: '3.0.0', + }) + res.json(doc) +} diff --git a/runtimes/nodejs/src/handler/router.ts b/runtimes/nodejs/src/handler/router.ts index 425aeb43fa..bdcf3be5a3 100644 --- a/runtimes/nodejs/src/handler/router.ts +++ b/runtimes/nodejs/src/handler/router.ts @@ -8,11 +8,13 @@ import { Router } from 'express' import multer from 'multer' import * as path from 'path' + import { handleDatabaseProxy } from './db-proxy' import { handlePackageTypings } from './typings' import { generateUUID } from '../support/utils' import { handleInvokeFunction } from './invoke' import { DatabaseAgent } from '../db' +import { handleOpenAPIDefinition } from './openapi' /** * multer uploader config @@ -44,7 +46,7 @@ router.get('/_/healthz', (_req, res) => { res.status(503).send('db is not ready') } }) - +router.get('/_/api-docs', handleOpenAPIDefinition) /** * Invoke cloud function through HTTP request. * @method * diff --git a/runtimes/nodejs/src/support/engine/cache.ts b/runtimes/nodejs/src/support/engine/cache.ts index 892694387d..d77b1f4ab2 100644 --- a/runtimes/nodejs/src/support/engine/cache.ts +++ b/runtimes/nodejs/src/support/engine/cache.ts @@ -60,4 +60,8 @@ export class FunctionCache { static get(name: string): ICloudFunctionData { return FunctionCache.cache.get(name) } + + static getAll(): ICloudFunctionData[] { + return Array.from(FunctionCache.cache.values()) + } } diff --git a/runtimes/nodejs/src/support/openapi.ts b/runtimes/nodejs/src/support/openapi.ts new file mode 100644 index 0000000000..11e7fa9120 --- /dev/null +++ b/runtimes/nodejs/src/support/openapi.ts @@ -0,0 +1,212 @@ +import { + OpenApiBuilder, + PathsObject, + PathItemObject, + SchemaObjectType, +} from 'openapi3-ts/oas30' +import { FunctionCache, ICloudFunctionData } from './engine' +import * as ts from 'typescript' + +function extractOpenAPIParams(func: ICloudFunctionData) { + const sourceFile = ts.createSourceFile( + func.name, + func.source.code, + ts.ScriptTarget.ES2022, + /*setParentNodes */ true, + ) + const interfaceDeclarations = sourceFile.statements.filter( + ts.isInterfaceDeclaration, + ) + + const getPropertiesInfo = ( + properties: ts.NodeArray, + ): { name: string; type: string | object; required: boolean }[] => { + return properties + .filter(ts.isPropertySignature) + .filter((prop) => !!prop.type) + .map((prop) => { + const type = prop.type! + let propType = 'object' + switch (type.kind) { + case ts.SyntaxKind.StringKeyword: + propType = 'string' + break + case ts.SyntaxKind.BooleanKeyword: + propType = 'boolean' + break + case ts.SyntaxKind.NumberKeyword: + propType = 'number' + break + case ts.SyntaxKind.ObjectKeyword: + propType = 'object' + break + case ts.SyntaxKind.ArrayType: + propType = 'array' + break + default: + propType = 'object' + break + } + + const comments = ts.getTrailingCommentRanges(func.source.code, type.end) + let desc = '' + if (comments && comments.length > 0) { + const comment = func.source.code.slice( + comments[0].pos, + comments[0].end, + ) + desc = comment.slice(2).trim() || '' + } + + return { + name: prop.name.getText(), + type: propType, + required: !prop.questionToken, + desc, + } + }) + } + + const res: { query: any[]; requestBody: any[]; response: any[] } = { + query: [], + requestBody: [], + response: [], + } + + const iQueryDeclaration = interfaceDeclarations.find( + (d) => d.name.getText() === 'IQuery', + ) + if (iQueryDeclaration) { + res.query = getPropertiesInfo(iQueryDeclaration.members) + } + + const iRequestBodyDeclaration = interfaceDeclarations.find( + (d) => d.name.getText() === 'IRequestBody', + ) + if (iRequestBodyDeclaration) { + res.requestBody = getPropertiesInfo(iRequestBodyDeclaration.members) + } + + const iResponseDeclaration = interfaceDeclarations.find( + (d) => d.name.getText() === 'IResponse', + ) + if (iResponseDeclaration) { + res.response = getPropertiesInfo(iResponseDeclaration.members) + } + + return res +} + +export function buildOpenAPIDefinition(apiConfig: { + title: string + version: string + description?: string + contact?: { + name: string + email: string + } + host: string + apiVersion: string +}) { + const paths: PathsObject = {} + + const funcs = FunctionCache.getAll() + funcs.forEach((func) => { + const openApi = extractOpenAPIParams(func) + const path: PathItemObject = {} + if (func.methods.includes('GET')) { + path.get = { + operationId: `${func.name}_GET`, + summary: func.desc, + responses: { + '200': { + description: 'success', + }, + }, + parameters: openApi.query.map((v) => ({ + name: v.name, + in: 'query', + required: v.required, + description: v.desc, + schema: { + type: + typeof v.type === 'string' + ? (v.type as SchemaObjectType) + : 'object', + }, + })), + tags: func.tags, + } + } + ;['POST', 'PUT', 'DELETE', 'PATCH'].forEach((method) => { + if (func.methods.includes(method)) { + path[method.toLowerCase()] = { + operationId: `${func.name}_${method}`, + summary: func.desc, + tags: func.tags, + } + + if (openApi.requestBody.length > 0) { + path[method.toLowerCase()].requestBody = { + required: true, + content: { + 'application/json': { + schema: { + type: 'object', + properties: openApi.requestBody.reduce((prev, v) => { + prev[v.name] = { type: v.type, description: v.desc } + return prev + }, {}), + required: openApi.requestBody + .filter((v) => v.required) + .map((v) => v.name), + }, + }, + }, + } + } + if (openApi.response.length > 0) { + path[method.toLowerCase()].responses = { + default: { + description: 'success', + content: { + 'application/json': { + schema: { + type: 'object', + properties: openApi.response.reduce((prev, v) => { + prev[v.name] = { type: v.type, description: v.desc } + return prev + }, {}), + required: openApi.response + .filter((v) => v.required) + .map((v) => v.name), + }, + }, + }, + }, + } + } + } + }) + + paths[`/${func.name}`] = path + }) + + const builder = OpenApiBuilder.create({ + openapi: apiConfig.apiVersion, + info: { + title: apiConfig.title, + version: apiConfig.version, + description: apiConfig.description, + contact: apiConfig.contact, + }, + paths, + servers: [ + { + url: apiConfig.host, + }, + ], + }) + + return builder.getSpec() +} diff --git a/server/src/application/application.controller.ts b/server/src/application/application.controller.ts index 977b543af8..fb5fd0b008 100644 --- a/server/src/application/application.controller.ts +++ b/server/src/application/application.controller.ts @@ -200,12 +200,18 @@ export class ApplicationController { 'develop', expires, ) + const openapi_token = await this.fn.generateRuntimeToken( + appid, + 'openapi', + expires, + ) const res = { ...data, storage: storage, port: region.gatewayConf.port, develop_token: develop_token, + openapi_token: openapi_token, /** This is the redundant field of Region */ tls: region.gatewayConf.tls.enabled, diff --git a/server/src/function/function.service.ts b/server/src/function/function.service.ts index fa397de09d..09cf61e7fa 100644 --- a/server/src/function/function.service.ts +++ b/server/src/function/function.service.ts @@ -322,7 +322,7 @@ export class FunctionService { async generateRuntimeToken( appid: string, - type: 'trigger' | 'develop', + type: 'trigger' | 'develop' | 'openapi', expireSeconds = 60, ) { assert(appid, 'appid is required')