UNPKG

@budibase/server

Version:
504 lines (457 loc) • 14.8 kB
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 } }