UNPKG

cloudformation-declarations

Version:

TypeScript declarations and helpers for writing CloudFormation templates in TS or JS.

283 lines (259 loc) 12.4 kB
import {sync as globSync} from 'glob'; import { toPairs, map, each, includes, fromPairs, without, sortBy } from 'lodash'; import outdent from 'outdent'; import * as assert from 'assert'; import * as request from 'request-promise'; import { normalizeUrl, template as t, prettyPrintTs, log, Cache, JQueryFactory, writeFile, readJsonFile, writeJsonFile } from './utils'; import {each as asyncEach} from 'bluebird'; import { JSDOM } from 'jsdom'; import { SpecFile, SpecType, SpecProperty } from './spec-file'; const paths = { generatedDeclaration: 'src/declarations/generated/cloudformation-types.ts', docsCache: 'cache/html-docs.json', specificationsDirectory: 'specifications' }; async function main() { const rootNamespace = new Namespace(); const specs: {[path: string]: SpecFile} = Object.create(null); type Entry = {$: JQueryStatic, window: Window}; /** Cache of parsed HTML documentation with JQuery instances, stored by URL. */ const parsedDocs = new (class extends Cache<string, Entry> { maxItems = 30; dispose(v: Entry) { v.window.close(); } createItem(k: string, ck: string) { const window = new JSDOM(docs[ck]).window; const $ = JQueryFactory(window); return {$, window}; } }); /** Get a JQuery object for the given documentation URL */ function get$(url: string) { return parsedDocs.getOrCreate(url).$; } const docs: {[url: string]: string} = Object.create(null); // Pull docs from filesystem cache to avoid re-downloading all of them again. Object.assign(docs, readJsonFile(paths.docsCache, {})); // Load all AWS specs into memory globSync('**.json', {cwd: paths.specificationsDirectory}).map(path => { specs[path] = readJsonFile(`specifications/${ path }`); }); try { await asyncEach(toPairs(specs), async ([path, spec]) => { await asyncEach([ ...toPairs(spec.PropertyTypes), ...toPairs(spec.ResourceTypes) ], cb); async function cb([name, type]: [string, SpecType]) { createType(name); await fetchDocs(type.Documentation); asyncEach(toPairs(type.Properties), async ([propName, prop]: [string, SpecProperty]) => { await fetchDocs(prop.Documentation); }); } function createType(name: string) { rootNamespace.getOrCreateChildType(name); } async function fetchDocs(url: string) { const actualUrl = normalizeUrl(url); if(typeof docs[actualUrl] === 'string') { return; } console.log(`Downloading ${ url }`); docs[actualUrl] = await request(actualUrl); } }); } finally { // Save documentation cache back to disc writeJsonFile(paths.docsCache, docs); } const allResourceTypes: Array<string> = []; const renderedIdentifierPaths = new Set<string>(); /** Generated type declaration .ts */ const declaration = t ` import * as C from '../core'; ${ map(specs, (spec, path) => { const specJson: SpecFile = readJsonFile(`specifications/${ path }`); const allDeclarations = sortBy([ ...toPairs(specJson.ResourceTypes).map(v => [...v, 'resource'] as [string, SpecType, 'resource']), ...toPairs(specJson.PropertyTypes).map(v => [...v, 'property'] as [string, SpecType, 'property']) ], v => v[0]); return t` ${ map(allDeclarations, ([k, v, type]) => renderType(v, k, type)) } `; function renderType(v: SpecType, k: string, specType: 'resource' | 'property') { const isResource = specType === 'resource'; const nameParts = k.split(/::|\./); const namespace = nameParts.slice(0, -1).join('.'); const identifier = nameParts[nameParts.length - 1]; const identifierPath = namespace ? `${ namespace }.${ identifier }` : `${ identifier }`; if(renderedIdentifierPaths.has(identifierPath)) { return `/* already emitted ${ identifierPath } */\n`; } renderedIdentifierPaths.add(identifierPath); if(isResource) allResourceTypes.push(identifierPath); const propertiesIdentifierPath = `${ identifierPath }.Properties`; const $ = get$(v.Documentation); // Emit `declaration` and wrap it in a namespace declaration if necessary function declarationInNamespace(identifierPath: string, declaration: any) { const namespace = identifierPath.split('.').slice(0, -1).join('.'); return t ` ${ namespace && `export namespace ${ namespace } {`} ${ declaration } ${ namespace && `}` } `; } log(`Generating declaration for ${ identifierPath }...`); const description = $('.topictitle+p').text(); return t ` ${ isResource && declarationInNamespace(identifierPath, t ` /** * ${ description } * * Documentation: ${ v.Documentation } */ export interface ${ identifier } extends C.CommonResourceProps { Type: '${ k }' = '${ k }'; Properties: ${ propertiesIdentifierPath } } /** * ${ description } * * Documentation: ${ v.Documentation } */ export function ${ identifier }(props: C.Omit<${ identifier }, 'Type'>): ${ identifier } { return {Type: '${ k }', ...props}; } `) } ${ declarationInNamespace(isResource ? `${ identifierPath }.Properties` : identifierPath, t ` export interface ${ isResource ? 'Properties' : identifier } { ${ map(v.Properties, (prop, propName) => { const $dt = $('.variablelist>dl>dt').filter((i, e) => $(e).text() === propName); const $dd = $dt.find('+*'); const description = $dd.find('>p').eq(0).text(); const type = $dd.find('>p>em').filter((i, e) => $(e).text() === 'Type').parent().text().slice(6); return t ` /** * ${ type } * * ${ description } * * UpdateType: ${ prop.UpdateType } * Documentation: ${ prop.Documentation } */ ${ propName }${!prop.Required && '?'}: ${ renderTypeString(prop, rootNamespace.getOrCreateChildNamespace(identifierPath)) }; `; }) } } `) } `; } }) } export type Resource =\n${ allResourceTypes.join('\n| ') }; `; function parseTypeName(fullName: string) { const nameParts = fullName.split(/::|\./); let namespace: string | undefined = nameParts.slice(0, -1).join('.'); if(namespace === '') namespace = undefined; const name = nameParts[nameParts.length - 1]; return {namespace, name}; } function renderTypeString(prop: SpecProperty, relativeTo: Namespace): string { if(prop.PrimitiveType) { return renderPrimitiveType(prop.PrimitiveType); } if(prop.Type) { if(prop.PrimitiveItemType) { return `${ renderPropertyType(prop.Type, relativeTo, [renderPrimitiveType(prop.PrimitiveItemType)]) }`; } else if(prop.ItemType) { return `${ renderPropertyType(prop.Type, relativeTo, [renderPropertyType(prop.ItemType, relativeTo)]) }`; } else { return `${ renderPropertyType(prop.Type, relativeTo) }`; } } throw new Error('Unexpected property'); } function renderPrimitiveType(prim: string): string { return `C.CF${ prim }`; } function renderPropertyType(findName: string, relativeTo: Namespace, generics?: Array<string>): string { const genericsStr = generics && generics.length ? `<${ generics.join(', ') }>` : ''; if(includes(['Map', 'List'], findName)) return `C.CF${ findName }${ genericsStr }`; const ret = relativeTo.resolveType(findName)!.fullName(); assert(typeof ret === 'string'); return `${ ret }${ genericsStr }`; } writeFile(paths.generatedDeclaration, prettyPrintTs(declaration)); } class Shared { constructor(name?: string, parent?: Namespace) { name != null && (this.name = name); parent != null && (this._parent = parent); } _parent: Namespace | null = null; name: string | null = null; fullName(): string | null { const parentName = this._parent ? this._parent.fullName() : null; return parentName ? `${ parentName }.${ this.name }` : this.name; } _splitPath(s: string): Array<string> { return s.split(/::|\./); } toJSON() { return Object.assign({}, this, {_parent: undefined}); } } /** Represents an AWS "Type" in the CloudFormation schema (e.g. AWS::ApiGateway::Account) */ class Type extends Shared { } /** Represents an AWS "Namespace" in the Cloudformation schema (e.g. AWS::ApiGateway) */ class Namespace extends Shared { _namespaces = new Map<string, Namespace>(); _types = new Map<string, Type>(); getOrCreateChildType(awsFullName: string | Array<string>): Type { if(typeof awsFullName === 'string') awsFullName = this._splitPath(awsFullName); if(awsFullName.length === 1) { let type = this._types.get(awsFullName[0]); if(!type) { type = new Type(awsFullName[0], this); this._types.set(awsFullName[0], type); } return type; } else { return this.getOrCreateChildNamespace(awsFullName.slice(0, -1)).getOrCreateChildType(awsFullName.slice(-1)); } } getType(awsFullName: string | Array<string>): Type | null { if(typeof awsFullName === 'string') awsFullName = this._splitPath(awsFullName); if(awsFullName.length === 1) return this._types.get(awsFullName[0]) || null; const ns = this._namespaces.get(awsFullName[0]); return ns ? ns.getType(awsFullName.slice(1)) : null; } getOrCreateChildNamespace(awsFullName: string | Array<string>): Namespace { if(typeof awsFullName === 'string') awsFullName = this._splitPath(awsFullName); let ns = this._namespaces.get(awsFullName[0]); if(!ns) this._namespaces.set(awsFullName[0], ns = new Namespace(awsFullName[0], this)); if(awsFullName.length === 1) return ns; return ns.getOrCreateChildNamespace(awsFullName.slice(1)); } resolveType(awsFullName: string | Array<string>): Type | null { if(typeof awsFullName === 'string') awsFullName = this._splitPath(awsFullName); const type = this.getType(awsFullName); if(type) return type; if(this._parent) return this._parent.resolveType(awsFullName); return null; } toJSON() { return Object.assign({}, super.toJSON(), { _namespaces: fromPairs(Array.from(this._namespaces)), _types: fromPairs(Array.from(this._types)) }); } } main().catch(e => { console.error(e); process.exit(1); });