john-goldsmith/vscode-aws-cloudformation-auto-template-generator

View on GitHub
src/commands/insert-resource.ts

Summary

Maintainability
C
7 hrs
Test Coverage
B
84%
import { window, workspace, ProgressLocation, Position, Selection, ExtensionContext } from 'vscode'
import { CloudFormation, SharedIniFileCredentials } from 'aws-sdk'
import { promisify } from 'util'
import yaml from 'js-yaml'

import pkg from '../../package.json'
import StateKeys from '../cache-keys'
import {
  isFormatJson,
  isVisibilityPublic,
  isExpired,
  isValidFormat,
  getTemplate,
  getAllTypes,
  getLogicalId
} from '../utils'
import {
  EXTENSION_NAME,
  PROFILE_CONFIGURATION_PROPERTY,
  REGION_CONFIGURATION_PROPERTY,
  RESOURCE_VISIBILITY_CONFIGURATION_PROPERTY,
  TEMPLATE_FORMAT_CONFIGURATION_PROPERTY,
  PUBLIC_TYPE_LIST_CACHE_TTL_DAYS,
  PUBLIC_TYPE_CACHE_TTL_DAYS,
  PRIVATE_TYPE_LIST_CACHE_TTL_DAYS,
  PRIVATE_TYPE_CACHE_TTL_DAYS
} from '../config'
import {
  NoActiveTextEditorError,
  AccessKeyIdMissingError,
  SecretAccessKeyMissingError
} from '../errors'
import { ResourceTypeSchema } from '../utils/get-template'

const {
  withProgress,
  setStatusBarMessage,
  showQuickPick,
  showErrorMessage
} = window
const { getConfiguration } = workspace

/**
 * Inserts a fully-expanded CloudFormation resource template into the
 * currently active text editor. The format of the template will first
 * attempt to match the file mode (language ID) of the text editor
 * (detecting either JSON or YAML), otherwise deferring the user's
 * preferred setting.
 *
 * @param {ExtensionContext} context
 * @return {Function<Promise<undefined>>}
 * @throws {NoActiveTextEditorError}
 * @throws {AccessKeyIdMissingError}
 * @throws {SecretAccessKeyMissingError}
 */
export default function insertResource(context: ExtensionContext) {
  return async (): Promise<void> => {
    try {
      const { activeTextEditor } = window
      if (!activeTextEditor) throw new NoActiveTextEditorError()
      const { workspaceState } = context
      const configuration = getConfiguration()

      const editorTabSize = configuration.get<number>('editor.tabSize', 2)

      // @ts-expect-error Element implicitly has an 'any' type because expression of type 'string' can't be used to index type
      const { default: defaultProfile }: { default: string } = pkg.contributes.configuration.properties[`${EXTENSION_NAME}.${PROFILE_CONFIGURATION_PROPERTY}`]
      const profile = await configuration.get(`${EXTENSION_NAME}.${PROFILE_CONFIGURATION_PROPERTY}`, defaultProfile)
      // if (!profile) {
      //   await executeCommand('extension.setProfile')
      // }

      // @ts-expect-error Element implicitly has an 'any' type because expression of type 'string' can't be used to index type
      const { default: defaultRegion }: { default: string } = pkg.contributes.configuration.properties[`${EXTENSION_NAME}.${REGION_CONFIGURATION_PROPERTY}`]
      const region = await configuration.get(`${EXTENSION_NAME}.${REGION_CONFIGURATION_PROPERTY}`, defaultRegion)
      // if (!region) {
      //   await executeCommand('extension.setRegion')
      // }

      const creds = new SharedIniFileCredentials({profile})
      const { accessKeyId, secretAccessKey } = creds
      if (!accessKeyId) throw new AccessKeyIdMissingError()
      if (!secretAccessKey) throw new SecretAccessKeyMissingError()
      const cloudformation = new CloudFormation({
        accessKeyId,
        secretAccessKey,
        region
      })

      // @ts-expect-error Element implicitly has an 'any' type because expression of type 'string' can't be used to index type
      const { default: defaultResourceVisibility }: { default: string } = pkg.contributes.configuration.properties[`${EXTENSION_NAME}.${RESOURCE_VISIBILITY_CONFIGURATION_PROPERTY}`]
      const visibility = await configuration.get(`${EXTENSION_NAME}.${RESOURCE_VISIBILITY_CONFIGURATION_PROPERTY}`, defaultResourceVisibility)
      const isPublic = isVisibilityPublic(visibility)
      const typeListCacheKey = isPublic
        ? StateKeys.PUBLIC_TYPE_LIST_CACHE_KEY
        : StateKeys.PRIVATE_TYPE_LIST_CACHE_KEY
      const typeListCacheTtlDays = isPublic
        ? PUBLIC_TYPE_LIST_CACHE_TTL_DAYS
        : PRIVATE_TYPE_LIST_CACHE_TTL_DAYS
      let types: CloudFormation.TypeSummaries = []
      const typeListCache = await workspaceState.get(typeListCacheKey, { updatedAt: 0, typeSummaries: [] })
      const isTypesCacheExpired = isExpired(typeListCache.updatedAt, typeListCacheTtlDays)
      if (isTypesCacheExpired) {
        types = await withProgress({
          location: ProgressLocation.Notification,
          title: `Fetching all ${visibility.toLowerCase()} resources...`
        }, () => getAllTypes(cloudformation, visibility))
        setStatusBarMessage(`Fetched ${types.length} resources`, 3000)
        await workspaceState.update(typeListCacheKey, {
          updatedAt: Date.now(),
          typeSummaries: types
        })
      } else {
        types = typeListCache.typeSummaries
      }
      const options = types.map(type => ({ label: type.TypeName as string, value: type.TypeName as string }))
      const selectedTypeName = await showQuickPick(options)
      if (!selectedTypeName) return // Esc

      let type: {
        updatedAt?: number,
        data: CloudFormation.DescribeTypeOutput
      } = {
        data: {}
      }
      const typeCacheKey = isPublic
        ? StateKeys.PUBLIC_TYPE_CACHE_KEY
        : StateKeys.PRIVATE_TYPE_CACHE_KEY
      const typeCacheTtlDays = isPublic
        ? PUBLIC_TYPE_CACHE_TTL_DAYS
        : PRIVATE_TYPE_CACHE_TTL_DAYS
      const typeCache: Record<string, { updatedAt: number, data: CloudFormation.DescribeTypeOutput }> = await workspaceState.get(typeCacheKey, {})
      const selectedTypeCache = typeCache[selectedTypeName.value] || { updatedAt: 0, data: {} }
      const isSelectedTypeCacheExpired = isExpired(selectedTypeCache.updatedAt, typeCacheTtlDays)
      if (isSelectedTypeCacheExpired) {
        const describeType = promisify<CloudFormation.DescribeTypeInput, CloudFormation.DescribeTypeOutput>(cloudformation.describeType.bind(cloudformation))
        const describeTypeResponse = await withProgress({
          location: ProgressLocation.Notification,
          title: `Fetching ${selectedTypeName.value}...`
        }, () => {
          const describeTypeParams: CloudFormation.DescribeTypeInput = {
            Type: 'RESOURCE',
            TypeName: selectedTypeName.value
            // TODO: Implement `VersionId`
          }
          return describeType(describeTypeParams)
        })
        setStatusBarMessage(`Fetched ${selectedTypeName.value}`, 3000)
        await workspaceState.update(typeCacheKey, {
          ...typeCache,
          [selectedTypeName.value]: {
            updatedAt: Date.now(),
            data: describeTypeResponse
          }
        })
        type = {
          updatedAt: Date.now(),
          data: describeTypeResponse
        }
      } else {
        type = typeCache[selectedTypeName.value]
      }

      // @ts-expect-error Element implicitly has an 'any' type because expression of type 'string' can't be used to index type
      const { default: defaultTemplateFormat }: { default: string } = pkg.contributes.configuration.properties[`${EXTENSION_NAME}.${TEMPLATE_FORMAT_CONFIGURATION_PROPERTY}`]
      const { languageId } = activeTextEditor.document
      const templateFormat = isValidFormat(languageId)
        ? languageId
        : await configuration.get(`${EXTENSION_NAME}.${TEMPLATE_FORMAT_CONFIGURATION_PROPERTY}`, defaultTemplateFormat)
      // TODO: if (type.data.Schema) {...}
      const schema: ResourceTypeSchema = JSON.parse(type.data.Schema as string)
      const { line, character } = activeTextEditor.selection.active
      const position = new Position(line, character)
      const logicalId = getLogicalId(schema.typeName)
      const template = getTemplate(schema, logicalId)
      const isJson = isFormatJson(templateFormat)
      const value = isJson
        ? JSON.stringify(template, null, editorTabSize)
        : yaml.dump(template, {indent: editorTabSize})

      await activeTextEditor.edit(textEditorEdit => textEditorEdit.insert(position, value))

      /**
       * @ignore
       * The following automatically highlights the logical ID for easy
       * editing. The expression `line + 1` accounts for the opening curly
       * brace `{` found in JSON. The expression `editorTabSize + 1`
       * accounts for the opening double quotes `"` of the first property
       * found in JSON.
       */
      const [anchorLine, anchorCharacter, activeLine, activeCharacter] = isJson
        ? [line + 1, editorTabSize + 1, line + 1, editorTabSize + 1 + logicalId.length]
        : [line, character, line, logicalId.length]
      const selection = new Selection(anchorLine, anchorCharacter, activeLine, activeCharacter)
      activeTextEditor.selection = selection
    } catch (err) {
      showErrorMessage(err.message)
    }
  }
}