Skip to content

Commit

Permalink
feat(runtime): support generating openapi docs (#1923)
Browse files Browse the repository at this point in the history
* feat(runtime): support generating openapi docs

* generate openapi preview token
  • Loading branch information
0fatal committed Apr 3, 2024
1 parent a4c8ad8 commit bdfe724
Show file tree
Hide file tree
Showing 8 changed files with 280 additions and 11 deletions.
31 changes: 25 additions & 6 deletions runtimes/nodejs/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions runtimes/nodejs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,16 @@
"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",
"validator": "^13.7.0",
"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",
Expand All @@ -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": [
Expand Down
25 changes: 25 additions & 0 deletions runtimes/nodejs/src/handler/openapi.ts
Original file line number Diff line number Diff line change
@@ -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)
}
4 changes: 3 additions & 1 deletion runtimes/nodejs/src/handler/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 *
Expand Down
4 changes: 4 additions & 0 deletions runtimes/nodejs/src/support/engine/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
}
212 changes: 212 additions & 0 deletions runtimes/nodejs/src/support/openapi.ts
Original file line number Diff line number Diff line change
@@ -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<ts.TypeElement>,
): { 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()
}
6 changes: 6 additions & 0 deletions server/src/application/application.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion server/src/function/function.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down

0 comments on commit bdfe724

Please sign in to comment.