@budibase/server
Version:
Budibase Web Server
504 lines (457 loc) • 14.8 kB
text/typescript
import { GetQueriesOptions, ImportInfo } from "./base"
import {
BodyType,
Query,
QueryParameter,
RestTemplateQueryMetadata,
} from "@budibase/types"
import { QueryVerbToHttpMethod } from "../../../../../constants"
import { OpenAPI, OpenAPIV3 } from "openapi-types"
import { OpenAPISource } from "./base/openapi"
import { URL } from "url"
import {
GeneratedRequestBody,
buildSerializableRequestBody,
generateRequestBodyFromExample,
generateRequestBodyFromSchema,
buildKeyValueRequestBody,
} from "./utils/requestBody"
type ServerObject = OpenAPIV3.ServerObject
type ServerVariableObject = OpenAPIV3.ServerVariableObject
const isReferenceObject = (
value: unknown
): value is OpenAPIV3.ReferenceObject => {
return (
value != null &&
typeof value === "object" &&
Object.prototype.hasOwnProperty.call(value, "$ref")
)
}
const isOpenAPI3 = (
document: OpenAPI.Document
): document is OpenAPIV3.Document => {
if (!("openapi" in document)) {
return false
}
const { openapi } = document as { openapi: string }
return openapi.startsWith("3.")
}
const methods: string[] = Object.values(OpenAPIV3.HttpMethods)
const isOperation = (
key: string,
pathItem: any
): pathItem is OpenAPIV3.OperationObject => {
return methods.includes(key)
}
/**
* OpenAPI Version 3.0
* https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.0.md
*/
export class OpenAPI3 extends OpenAPISource {
document!: OpenAPIV3.Document
serverVariableBindings: Record<string, string> = {}
private securityHeaders: Map<string, string> = new Map()
private resolveRef<T>(ref: string): T | undefined {
if (!ref || !ref.startsWith("#/")) {
return undefined
}
const parts = ref
.slice(2)
.split("/")
.map(part => part.replace(/~1/g, "/").replace(/~0/g, "~"))
let current: any = this.document
for (let part of parts) {
if (current == null || typeof current !== "object") {
return undefined
}
current = current[part]
}
return current as T | undefined
}
private resolveMaybeRef<T>(
value: T | OpenAPIV3.ReferenceObject | undefined
): T | undefined {
if (!value) {
return undefined
}
if (isReferenceObject(value)) {
return this.resolveRef<T>(value.$ref)
}
return value as T
}
private normalizeParameters(
params?: (OpenAPIV3.ParameterObject | OpenAPIV3.ReferenceObject)[]
): OpenAPIV3.ParameterObject[] {
if (!Array.isArray(params)) {
return []
}
const resolved: OpenAPIV3.ParameterObject[] = []
for (const param of params) {
const normalized = this.resolveMaybeRef<OpenAPIV3.ParameterObject>(param)
if (normalized) {
resolved.push(normalized)
}
}
return resolved
}
private getMimeTypes(operation: OpenAPIV3.OperationObject): string[] {
const request = this.resolveMaybeRef<OpenAPIV3.RequestBodyObject>(
operation.requestBody
)
if (request?.content) {
return Object.keys(request.content)
}
return []
}
private getRequestBody(
operation: OpenAPIV3.OperationObject,
bindingRoot: string,
mimeTypeOverride?: string
): GeneratedRequestBody | undefined {
const request = this.resolveMaybeRef<OpenAPIV3.RequestBodyObject>(
operation.requestBody
)
if (!request) {
return undefined
}
const supportedMimeTypes = this.getMimeTypes(operation)
const mimeType = mimeTypeOverride || supportedMimeTypes[0]
if (!mimeType) {
return undefined
}
const content = request.content[mimeType]
if (!content) {
return undefined
}
if (content.example) {
return generateRequestBodyFromExample(content.example, bindingRoot)
}
const schema = this.resolveMaybeRef<OpenAPIV3.SchemaObject>(content.schema)
if (!schema) {
return undefined
}
if (schema.example) {
return generateRequestBodyFromExample(schema.example, bindingRoot)
}
return generateRequestBodyFromSchema(schema, bindingRoot)
}
private getDocsUrl = (
operation: OpenAPIV3.OperationObject
): string | undefined => {
return (
operation.externalDocs?.url ||
this.document.externalDocs?.url ||
this.document.info?.termsOfService ||
this.document.info?.contact?.url
)
}
private buildRestTemplateMetadata = (
operation: OpenAPIV3.OperationObject,
path: string,
requestBody: GeneratedRequestBody | undefined,
parameters: QueryParameter[],
bodyType?: BodyType
): RestTemplateQueryMetadata => {
const metadata: RestTemplateQueryMetadata = {
operationId: operation.operationId,
docsUrl: this.getDocsUrl(operation),
description: operation.description || operation.summary,
originalPath: path,
}
let parsedBody = buildSerializableRequestBody(requestBody?.body)
if (
parsedBody !== undefined &&
bodyType &&
(bodyType === BodyType.FORM_DATA || bodyType === BodyType.ENCODED)
) {
parsedBody = buildKeyValueRequestBody(parsedBody)
}
if (parsedBody !== undefined) {
metadata.originalRequestBody = parsedBody
}
const defaultBindings = this.buildDefaultBindings(
parameters,
requestBody?.bindings
)
if (defaultBindings) {
metadata.defaultBindings = defaultBindings
}
return metadata
}
private getPrimaryServer = (): ServerObject | undefined => {
if (this.document?.servers?.length) {
return this.document.servers[0] as ServerObject
}
}
private setServerVariableBindings = (server?: ServerObject) => {
this.serverVariableBindings = {}
const variables = server?.variables || {}
for (let [variableName, variable] of Object.entries(variables)) {
this.serverVariableBindings[variableName] = variable?.default || ""
}
}
private setSecurityHeaders = () => {
this.securityHeaders = new Map()
const securitySchemes = this.document.components?.securitySchemes
if (!securitySchemes) {
return
}
for (const scheme of Object.values(securitySchemes)) {
const resolvedScheme =
this.resolveMaybeRef<OpenAPIV3.SecuritySchemeObject>(
scheme as OpenAPIV3.SecuritySchemeObject | OpenAPIV3.ReferenceObject
)
const headerName = this.getSecuritySchemeHeader(resolvedScheme)
if (headerName) {
this.securityHeaders.set(headerName.toLowerCase(), headerName)
}
}
}
private getSecuritySchemeHeader(
scheme?: OpenAPIV3.SecuritySchemeObject
): string | undefined {
if (!scheme) {
return undefined
}
if (scheme.type === "apiKey" && scheme.in === "header" && scheme.name) {
return scheme.name
}
return undefined
}
private isSecurityHeader(name?: string): boolean {
if (!name) {
return false
}
return this.securityHeaders.has(name.toLowerCase())
}
isSupported = async (data: string): Promise<boolean> => {
try {
const document = await this.parseData(data)
if (isOpenAPI3(document)) {
this.document = document
this.serverVariableBindings = {}
this.setSecurityHeaders()
return true
} else {
return false
}
} catch (err) {
return false
}
}
getServerVariableBindings = () => {
if (!Object.keys(this.serverVariableBindings).length) {
this.setServerVariableBindings(this.getPrimaryServer())
}
return { ...this.serverVariableBindings }
}
getSecurityHeaders(): string[] {
return Array.from(new Set(this.securityHeaders.values()))
}
private getEndpoints = async (): Promise<ImportInfo["endpoints"]> => {
const queries = await this.getQueries("")
const endpoints: ImportInfo["endpoints"] = []
for (const query of queries) {
const metadata = query.restTemplateMetadata
if (!metadata) {
continue
}
const path = metadata.originalPath || query.fields.path || ""
const method = QueryVerbToHttpMethod[query.queryVerb]
if (!this.isSupportedMethod(method)) {
continue
}
endpoints.push({
id: this.buildEndpointId(method || "", path),
name: query.name,
method: method?.toUpperCase() || "",
path,
description: metadata.description,
queryVerb: query.queryVerb,
operationId: metadata.operationId,
docsUrl: metadata.docsUrl,
originalPath: metadata.originalPath,
originalRequestBody: metadata.originalRequestBody,
defaultBindings: metadata.defaultBindings,
bodyType: query.fields.bodyType,
headers:
query.fields.headers && Object.keys(query.fields.headers).length > 0
? query.fields.headers
: undefined,
queryString: query.fields.queryString || undefined,
})
}
return endpoints
}
getInfo = async (): Promise<ImportInfo> => {
const name = this.document.info.title || "OpenAPI Import"
let url: string | undefined
if (this.document.servers?.length) {
const serverUrl = (this.document.servers[0] as ServerObject)?.url
url = serverUrl ? this.convertPathVariables(serverUrl) : undefined
}
const docsUrl =
this.document.externalDocs?.url ||
this.document.info?.termsOfService ||
this.document.info?.contact?.url
return {
name,
url,
docsUrl,
endpoints: await this.getEndpoints(),
securityHeaders: this.getSecurityHeaders(),
}
}
getImportSource(): string {
return "openapi3.0"
}
getQueries = async (
datasourceId: string,
options?: GetQueriesOptions
): Promise<Query[]> => {
let url: string | URL | undefined
let serverVariables: Record<string, ServerVariableObject> = {}
const primaryServer = this.getPrimaryServer()
if (primaryServer) {
url = primaryServer.url
serverVariables = primaryServer.variables || {}
}
this.setServerVariableBindings(primaryServer)
const queries: Query[] = []
const filterIds = options?.filterIds
const staticVariables = options?.staticVariables || {}
for (let [path, pathItemObject] of Object.entries(this.document.paths)) {
if (!pathItemObject) {
continue
}
const pathItem = pathItemObject as OpenAPIV3.PathItemObject
const pathParams = this.normalizeParameters(pathItem.parameters)
for (let [methodName, maybeOperation] of Object.entries(pathItem)) {
if (!isOperation(methodName, maybeOperation)) {
continue
}
const operation = maybeOperation as OpenAPIV3.OperationObject
if (!this.isSupportedMethod(methodName)) {
continue
}
const endpointId = this.buildEndpointId(methodName, path)
if (filterIds && !filterIds.has(endpointId)) {
continue
}
const name = operation.operationId || path
let queryString = ""
const headers: { [key: string]: unknown } = {}
const setHeader = (headerName: string, value: unknown) => {
if (!headerName) {
return
}
const normalized = headerName.toLowerCase()
const existingKey = Object.keys(headers).find(
existing => existing.toLowerCase() === normalized
)
headers[existingKey || headerName] = value
}
const mimeTypes = this.getMimeTypes(operation)
const primaryMimeType = mimeTypes[0]
const requestBody = this.methodHasRequestBody(methodName)
? this.getRequestBody(
operation,
operation.operationId || path,
primaryMimeType
)
: undefined
const parameters: QueryParameter[] = []
const ensureParameter = (paramName: string, defaultValue = "") => {
if (!parameters.some(parameter => parameter.name === paramName)) {
parameters.push({
name: paramName,
default: defaultValue,
})
}
}
if (primaryMimeType) {
setHeader("Content-Type", primaryMimeType)
}
// combine the path parameters with the operation parameters
const operationParams = this.normalizeParameters(operation.parameters)
const allParams = [...pathParams, ...operationParams]
for (let param of allParams) {
let skipParameterBinding = false
switch (param.in) {
case "query": {
let prefix = ""
if (queryString) {
prefix = "&"
}
queryString = `${queryString}${prefix}${param.name}={{${param.name}}}`
break
}
case "header":
if (this.isSecurityHeader(param.name)) {
skipParameterBinding = true
break
}
setHeader(param.name, `{{${param.name}}}`)
break
case "path":
// do nothing: param is already in the path
break
case "formData":
// future enhancement
break
}
if (
!skipParameterBinding &&
["query", "header", "path"].includes(param.in)
) {
let defaultValue = ""
const schema = this.resolveMaybeRef<OpenAPIV3.SchemaObject>(
param.schema
)
if (schema?.default !== undefined) {
defaultValue = String(schema.default)
}
ensureParameter(param.name, defaultValue)
}
}
for (let [variableName, variable] of Object.entries(serverVariables)) {
const hasStaticVariable = Object.prototype.hasOwnProperty.call(
staticVariables,
variableName
)
const defaultValue = hasStaticVariable
? `{{ ${variableName} }}`
: (variable?.default ?? "")
ensureParameter(variableName, defaultValue)
}
const bodyType =
mimeTypes.length > 0
? this.bodyTypeFromMimeType(primaryMimeType)
: undefined
const restTemplateMetadata = this.buildRestTemplateMetadata(
operation,
path,
requestBody,
parameters,
bodyType
)
const query = this.constructQuery(
datasourceId,
name,
methodName,
path,
url,
queryString,
headers,
parameters,
requestBody?.body,
requestBody?.bindings ?? {},
bodyType,
restTemplateMetadata
)
queries.push(query)
}
}
return queries
}
}