diff --git a/tools/@aws-cdk/spec2cdk/lib/cdk/ast.ts b/tools/@aws-cdk/spec2cdk/lib/cdk/ast.ts index 035b8548c2688..51e7484b39b18 100644 --- a/tools/@aws-cdk/spec2cdk/lib/cdk/ast.ts +++ b/tools/@aws-cdk/spec2cdk/lib/cdk/ast.ts @@ -1,3 +1,4 @@ +/* eslint-disable @cdklabs/no-throw-default-error */ import { SpecDatabase, Resource, Service } from '@aws-cdk/service-spec-types'; import { Module } from '@cdklabs/typewriter'; import { AugmentationsModule } from './augmentation-generator'; @@ -5,32 +6,9 @@ import { CannedMetricsModule } from './canned-metrics'; import { CDK_CORE, CONSTRUCTS, ModuleImportLocations } from './cdk'; import { SelectiveImport } from './relationship-decider'; import { ResourceClass } from './resource-class'; +import { FilePatternValues, IWriter, substituteFilePattern } from '../util'; -/** - * A module containing a single resource - */ -export class ResourceModule extends Module { - public constructor(public readonly service: string, public readonly resource: string) { - super(`@aws-cdk/${service}/${resource}-l1`); - } -} - -/** - * A module containing a service - */ -export class ServiceModule extends Module { - public constructor(public readonly service: string, public readonly shortName: string) { - super(`@aws-cdk/${service}`); - } -} - -export interface AstBuilderProps { - readonly db: SpecDatabase; - /** - * Override the locations modules are imported from - */ - readonly importLocations?: ModuleImportLocations; - +export interface AddServiceProps { /** * Append a suffix at the end of generated names. */ @@ -42,82 +20,133 @@ export interface AstBuilderProps { * @default - not deprecated */ readonly deprecated?: string; -} -export class AstBuilder { /** - * Build a module for all resources in a service + * The target submodule we want to generate these resources into + * + * Practically, only used to render CloudFormation resources into the `core` module, and as a failed + * experiment to emit `aws-kinesisanalyticsv2` into `aws-kinesisanalytics`. */ - public static forService(service: Service, props: AstBuilderProps): AstBuilder { - const scope = new ServiceModule(service.name, service.shortName); - const aug = new AugmentationsModule(props.db, service.name, props.importLocations?.cloudwatch); - const metrics = CannedMetricsModule.forService(props.db, service); + readonly destinationSubmodule?: string; - const ast = new AstBuilder(scope, props, aug, metrics); + /** + * Override the locations modules are imported from + */ + readonly importLocations?: ModuleImportLocations; +} - const resources = props.db.follow('hasResource', service); +export interface AstBuilderProps { + readonly db: SpecDatabase; - for (const link of resources) { - ast.addResource(link.entity); - } - ast.renderImports(); - return ast; - } + readonly modulesRoot?: string; + + readonly filePatterns?: Partial; +} +export interface GenerateFilePatterns { /** - * Build an module for a single resource + * The pattern used to name resource files. + * @default "%serviceName%/%serviceShortName%.generated.ts" */ - public static forResource(resource: Resource, props: AstBuilderProps): AstBuilder { - const parts = resource.cloudFormationType.toLowerCase().split('::'); - const scope = new ResourceModule(parts[1], parts[2]); - const aug = new AugmentationsModule(props.db, parts[1], props.importLocations?.cloudwatch); - const metrics = CannedMetricsModule.forResource(props.db, resource); + readonly resources: string; - const ast = new AstBuilder(scope, props, aug, metrics); - ast.addResource(resource); - ast.renderImports(); - - return ast; - } + /** + * The pattern used to name augmentations. + * @default "%serviceName%/%serviceShortName%-augmentations.generated.ts" + */ + readonly augmentations: string; - public readonly db: SpecDatabase; /** - * Map of CloudFormation resource name to generated class name + * The pattern used to name canned metrics. + * @default "%serviceName%/%serviceShortName%-canned-metrics.generated.ts" */ - public readonly resources: Record = {}; - private nameSuffix?: string; - private deprecated?: string; + readonly cannedMetrics: string; +} + +export function makeNames(patterns: GenerateFilePatterns, values: FilePatternValues): { [k in keyof GenerateFilePatterns]: string } { + return Object.fromEntries(Object.entries(patterns) + .map(([name, pattern]) => [name, substituteFilePattern(pattern, values)] as const)) as any; +} + +export const DEFAULT_FILE_PATTERNS: GenerateFilePatterns = { + resources: '%moduleName%/%serviceShortName%.generated.ts', + augmentations: '%moduleName%/%serviceShortName%-augmentations.generated.ts', + cannedMetrics: '%moduleName%/%serviceShortName%-canned-metrics.generated.ts', +}; + +export class AstBuilder { + public readonly db: SpecDatabase; + public readonly modules = new Map(); + public readonly selectiveImports = new Array(); + + public readonly serviceModules = new Map(); private readonly modulesRootLocation: string; + private readonly filePatterns: GenerateFilePatterns; - protected constructor( - public readonly module: T, + constructor( props: AstBuilderProps, - public readonly augmentations?: AugmentationsModule, - public readonly cannedMetrics?: CannedMetricsModule, ) { this.db = props.db; - this.nameSuffix = props.nameSuffix; - this.deprecated = props.deprecated; - this.modulesRootLocation = props.importLocations?.modulesRoot ?? '../..'; - - CDK_CORE.import(this.module, 'cdk', { fromLocation: props.importLocations?.core }); - CONSTRUCTS.import(this.module, 'constructs'); - CDK_CORE.helpers.import(this.module, 'cfn_parse', { fromLocation: props.importLocations?.coreHelpers }); - CDK_CORE.errors.import(this.module, 'cdk_errors', { fromLocation: props.importLocations?.coreErrors }); + this.modulesRootLocation = props.modulesRoot ?? '../..'; + this.filePatterns = { + ...DEFAULT_FILE_PATTERNS, + ...noUndefined(props?.filePatterns ?? {}), + }; + } + + /** + * Add all resources in a service + */ + public addService(service: Service, props?: AddServiceProps) { + const resources = this.db.follow('hasResource', service); + // Sometimes we emit multiple services into the same submodule + // (aws-kinesisanalyticsv2 gets emitted into aws-kinesisanalytics with a + // suffix. This was a failed experiment we still need to maintain.) + const submod = this.createSubmodule(service, props?.destinationSubmodule, props?.importLocations); + + for (const { entity: resource } of resources) { + this.addResourceToSubmodule(submod, resource, props); + } + + this.renderImports(submod); + return submod; } - public addResource(resource: Resource) { - const resourceClass = new ResourceClass(this.module, this.db, resource, { - suffix: this.nameSuffix, - deprecated: this.deprecated, + /** + * Build an module for a single resource (only used for testing) + */ + public addResource(resource: Resource, props?: AddServiceProps) { + const service = this.db.incoming('hasResource', resource).only().entity; + const submod = this.createSubmodule(service, props?.destinationSubmodule, props?.importLocations); + + this.addResourceToSubmodule(submod, resource, props); + + this.renderImports(submod); + return submod; + } + + public writeAll(writer: IWriter) { + for (const [fileName, module] of this.modules.entries()) { + if (shouldRender(module)) { + writer.write(module, fileName); + } + } + } + + private addResourceToSubmodule(submodule: SubmoduleInfo, resource: Resource, props?: AddServiceProps) { + const resourceModule = submodule.resourcesMod.module; + + const resourceClass = new ResourceClass(resourceModule, this.db, resource, { + suffix: props?.nameSuffix, + deprecated: props?.deprecated, }); - this.resources[resource.cloudFormationType] = resourceClass.spec.name; + submodule.resources[resource.cloudFormationType] = resourceClass.spec.name; resourceClass.build(); this.addImports(resourceClass); - this.augmentations?.augmentResource(resource, resourceClass); + submodule.augmentations.module.augmentResource(resource, resourceClass); } private addImports(resourceClass: ResourceClass) { @@ -140,11 +169,124 @@ export class AstBuilder { } } - public renderImports() { + private renderImports(serviceModules: SubmoduleInfo) { const sortedImports = this.selectiveImports.sort((a, b) => a.moduleName.localeCompare(b.moduleName)); for (const selectiveImport of sortedImports) { const sourceModule = new Module(selectiveImport.moduleName); - sourceModule.importSelective(this.module, selectiveImport.types.map((t) => `${t.originalType} as ${t.aliasedType}`), { fromLocation: `${this.modulesRootLocation}/${sourceModule.name}` }); + sourceModule.importSelective(serviceModules.resourcesMod.module, selectiveImport.types.map((t) => `${t.originalType} as ${t.aliasedType}`), { + fromLocation: `${this.modulesRootLocation}/${sourceModule.name}`, + }); + } + } + + private createSubmodule(service: Service, targetSubmodule?: string, importLocations?: ModuleImportLocations): SubmoduleInfo { + const submoduleName = targetSubmodule ?? service.name; + const key = `${submoduleName}/${service.name}`; + + const submod = this.serviceModules.get(key); + if (submod) { + return submod; } + + // ResourceModule and AugmentationsModule starts out empty and needs to be filled on a resource-by-resource basis + const names = makeNames(this.filePatterns, { moduleName: submoduleName, serviceShortName: service.shortName }); + const resourcesMod = this.rememberModule(this.createResourceModule(submoduleName, service.name, importLocations), names.resources); + const augmentations = this.rememberModule( + new AugmentationsModule(this.db, service.shortName, importLocations?.cloudwatch), + names.augmentations, + ); + + // CannedMetricsModule fill themselves upon construction + const cannedMetrics = this.rememberModule(CannedMetricsModule.forService(this.db, service), names.cannedMetrics); + + const ret: SubmoduleInfo = { + service, + submoduleName: submoduleName, + resourcesMod, + augmentations, + cannedMetrics, + resources: {}, + }; + + this.serviceModules.set(key, ret); + return ret; } + + private createResourceModule(moduleName: string, serviceName: string, importLocations?: ModuleImportLocations) { + const resourceModule = new Module(`@aws-cdk/${moduleName}/${serviceName}`); + CDK_CORE.import(resourceModule, 'cdk', { fromLocation: importLocations?.core }); + CONSTRUCTS.import(resourceModule, 'constructs'); + CDK_CORE.helpers.import(resourceModule, 'cfn_parse', { fromLocation: importLocations?.coreHelpers }); + CDK_CORE.errors.import(resourceModule, 'cdk_errors', { fromLocation: importLocations?.coreErrors }); + return resourceModule; + } + + public module(key: string) { + const ret = this.modules.get(key); + if (!ret) { + throw new Error(`No such module: ${key}`); + } + return ret; + } + + private rememberModule( + module: M, + filePath: string, + ): LocatedModule { + if (this.modules.has(filePath)) { + throw new Error(`Duplicate module key: ${filePath}`); + } + this.modules.set(filePath, module); + + return { module, filePath }; + } +} + +/** + * Bookkeeping around submodules, mostly used to report on what the generator did + * + * (This will be used by cfn2ts later to generate all kinds of codegen metadata) + */ +export interface SubmoduleInfo { + /** + * The name of the submodule of aws-cdk-lib where these service resources got written + */ + readonly submoduleName: string; + + readonly service: Service; + + readonly resourcesMod: LocatedModule; + readonly augmentations: LocatedModule; + readonly cannedMetrics: LocatedModule; + + /** + * Map of CloudFormation resource name to generated class name + */ + readonly resources: Record; +} + +interface LocatedModule { + readonly module: T; + readonly filePath: string; +} + +function noUndefined(x: A | undefined): A | undefined { + if (!x) { + return undefined; + } + return Object.fromEntries(Object.entries(x).filter(([, v]) => v !== undefined)) as any; +} + +export function submoduleFiles(x: SubmoduleInfo): string[] { + const ret = []; + for (const mod of [x.resourcesMod, x.augmentations, x.cannedMetrics]) { + if (shouldRender(mod.module)) { + ret.push(mod.filePath); + } + } + return ret; +} + +function shouldRender(m: Module) { + return m.types.length > 0 || m.initialization.length > 0; } diff --git a/tools/@aws-cdk/spec2cdk/lib/cdk/canned-metrics.ts b/tools/@aws-cdk/spec2cdk/lib/cdk/canned-metrics.ts index 9a87c2ceed871..0d61673ce4dee 100644 --- a/tools/@aws-cdk/spec2cdk/lib/cdk/canned-metrics.ts +++ b/tools/@aws-cdk/spec2cdk/lib/cdk/canned-metrics.ts @@ -36,14 +36,13 @@ export class CannedMetricsModule extends Module { private metrics: Record = {}; private _hasCannedMetrics: boolean = false; + private _returnType: MetricsReturnType | undefined; private constructor(private readonly db: SpecDatabase, service: Service, namespaces: string[]) { super(`${service.name}.canned-metrics`); - const returnType = new MetricsReturnType(this); - for (const namespace of namespaces) { - this.metrics[namespace] = new MetricsClass(this, namespace, returnType); + this.metrics[namespace] = new MetricsClass(this, namespace, this.returnType()); } } @@ -59,6 +58,18 @@ export class CannedMetricsModule extends Module { const dimensions = this.db.follow('usesDimensionSet', metric).map((m) => m.entity); this.metrics[metric.namespace].addMetricWithDimensions(metric, dimensions); } + + /** + * Create and return the ReturnType class + * + * Do this only once, and lazily so we don't generate the type if it goes unused. + */ + private returnType() { + if (!this._returnType) { + this._returnType = new MetricsReturnType(this); + } + return this._returnType; + } } export class MetricsReturnType extends InterfaceType { diff --git a/tools/@aws-cdk/spec2cdk/lib/cfn2ts/index.ts b/tools/@aws-cdk/spec2cdk/lib/cfn2ts/index.ts index 1fed27e369b0e..21ea09397aa72 100644 --- a/tools/@aws-cdk/spec2cdk/lib/cfn2ts/index.ts +++ b/tools/@aws-cdk/spec2cdk/lib/cfn2ts/index.ts @@ -1,7 +1,6 @@ import { loadAwsServiceSpec } from '@aws-cdk/aws-service-spec'; import { Service } from '@aws-cdk/service-spec-types'; import * as fs from 'fs-extra'; -import * as pLimit from 'p-limit'; import * as pkglint from './pkglint'; import { CodeGeneratorOptions, GenerateAllOptions, ModuleMap, ModuleMapScope } from './types'; import type { ModuleImportLocations } from '../cdk/cdk'; @@ -10,11 +9,6 @@ import { log } from '../util'; export * from './types'; -interface GenerateOutput { - outputFiles: string[]; - resources: Record; -} - let serviceCache: Service[]; async function getAllScopes(field: keyof Service = 'name'): Promise { @@ -30,7 +24,7 @@ export default async function generate( scopes: string | string[], outPath: string, options: CodeGeneratorOptions = {}, -): Promise { +): Promise { const coreImport = options.coreImport ?? 'aws-cdk-lib'; let moduleScopes: ModuleMapScope[] = []; if (scopes === '*') { @@ -40,7 +34,7 @@ export default async function generate( } log.info(`cfn-resources: ${moduleScopes.map(s => s.namespace).join(', ')}`); - const generated = await generateModules( + await generateModules( { 'aws-cdk-lib': { services: options.autoGenerateSuffixes ? computeServiceSuffixes(moduleScopes) : moduleScopes, @@ -50,9 +44,9 @@ export default async function generate( outputPath: outPath ?? 'lib', clearOutput: false, filePatterns: { - resources: ({ serviceShortName }) => `${serviceShortName}.generated.ts`, - augmentations: ({ serviceShortName }) => `${serviceShortName}-augmentations.generated.ts`, - cannedMetrics: ({ serviceShortName }) => `${serviceShortName}-canned-metrics.generated.ts`, + resources: '%serviceShortName%.generated.ts', + augmentations: '%serviceShortName%-augmentations.generated.ts', + cannedMetrics: '%serviceShortName%-canned-metrics.generated.ts', }, importLocations: { core: coreImport, @@ -61,8 +55,6 @@ export default async function generate( }, }, ); - - return generated; } /** @@ -163,9 +155,9 @@ export async function generateAll( outputPath: outPath, clearOutput: false, filePatterns: { - resources: ({ moduleName: m, serviceShortName: s }) => `${m}/lib/${s}.generated.ts`, - augmentations: ({ moduleName: m, serviceShortName: s }) => `${m}/lib/${s}-augmentations.generated.ts`, - cannedMetrics: ({ moduleName: m, serviceShortName: s }) => `${m}/lib/${s}-canned-metrics.generated.ts`, + resources: '%moduleName%/lib/%serviceShortName%.generated.ts', + augmentations: '%moduleName%/lib/%serviceShortName%-augmentations.generated.ts', + cannedMetrics: '%moduleName%/lib/%serviceShortName%-canned-metrics.generated.ts', }, importLocations: { core: options.coreImport, @@ -176,13 +168,11 @@ export async function generateAll( }, ); - const limit = pLimit(20); - // eslint-disable-next-line @cdklabs/promiseall-no-unbounded-parallelism - await Promise.all(Object.keys(moduleMap).map((moduleName) => limit(async () => { + Object.keys(moduleMap).forEach((moduleName) => { // Add generated resources and files to module in map moduleMap[moduleName].resources = generated.modules[moduleName].map((m) => m.resources).reduce(mergeObjects, {}); moduleMap[moduleName].files = generated.modules[moduleName].flatMap((m) => m.outputFiles); - }))); + }); return moduleMap; } diff --git a/tools/@aws-cdk/spec2cdk/lib/cli/cli.ts b/tools/@aws-cdk/spec2cdk/lib/cli/cli.ts index ed9b451b94274..93c146e7b5409 100644 --- a/tools/@aws-cdk/spec2cdk/lib/cli/cli.ts +++ b/tools/@aws-cdk/spec2cdk/lib/cli/cli.ts @@ -1,8 +1,8 @@ import * as path from 'node:path'; import { parseArgs } from 'node:util'; import { PositionalArg, showHelp } from './help'; -import { GenerateModuleMap, PatternKeys, generate, generateAll } from '../generate'; -import { log, parsePattern } from '../util'; +import { GenerateModuleMap, GenerateOptions, generate, generateAll } from '../generate'; +import { log } from '../util'; const command = 'spec2cdk'; const args: PositionalArg[] = [{ @@ -22,17 +22,14 @@ const config = { }, 'pattern': { type: 'string', - default: '%moduleName%/%serviceShortName%.generated.ts', description: 'File and path pattern for generated files', }, 'augmentations': { type: 'string', - default: '%moduleName%/%serviceShortName%-augmentations.generated.ts', description: 'File and path pattern for generated augmentations files', }, 'metrics': { type: 'string', - default: '%moduleName%/%serviceShortName%-canned-metrics.generated.ts', description: 'File and path pattern for generated canned metrics files ', }, 'service': { @@ -89,30 +86,13 @@ export async function main(argv: string[]) { throw new EvalError('Please specify the output-path'); } - const pss: Record = { moduleName: true, serviceName: true, serviceShortName: true }; - const outputPath = outputDir ?? path.join(__dirname, '..', 'services'); - const resourceFilePattern = parsePattern( - stringOr(options.pattern, path.join('%moduleName%', '%serviceShortName%.generated.ts')), - pss, - ); - - const augmentationsFilePattern = parsePattern( - stringOr(options.augmentations, path.join('%moduleName%', '%serviceShortName%-augmentations.generated.ts')), - pss, - ); - - const cannedMetricsFilePattern = parsePattern( - stringOr(options.metrics, path.join('%moduleName%', '%serviceShortName%-canned-metrics.generated.ts')), - pss, - ); - - const generatorOptions = { + const generatorOptions: GenerateOptions = { outputPath, filePatterns: { - resources: resourceFilePattern, - augmentations: augmentationsFilePattern, - cannedMetrics: cannedMetricsFilePattern, + resources: options.pattern, + augmentations: options.augmentations, + cannedMetrics: options.metrics, }, clearOutput: options['clear-output'], augmentationsSupport: options['augmentations-support'], @@ -133,14 +113,3 @@ export async function main(argv: string[]) { await generateAll(generatorOptions); } - -function stringOr(pat: unknown, def: string) { - if (!pat) { - return def; - } - if (typeof pat !== 'string') { - // eslint-disable-next-line @cdklabs/no-throw-default-error - throw new Error(`Expected string, got: ${JSON.stringify(pat)}`); - } - return pat; -} diff --git a/tools/@aws-cdk/spec2cdk/lib/generate.ts b/tools/@aws-cdk/spec2cdk/lib/generate.ts index f68d6ef3cadfb..191944f02fa3b 100644 --- a/tools/@aws-cdk/spec2cdk/lib/generate.ts +++ b/tools/@aws-cdk/spec2cdk/lib/generate.ts @@ -2,13 +2,11 @@ import * as path from 'path'; import { loadAwsServiceSpec } from '@aws-cdk/aws-service-spec'; import { DatabaseBuilder } from '@aws-cdk/service-spec-importers'; import { SpecDatabase } from '@aws-cdk/service-spec-types'; -import { TypeScriptRenderer } from '@cdklabs/typewriter'; +import { Module, TypeScriptRenderer } from '@cdklabs/typewriter'; import * as fs from 'fs-extra'; -import { AstBuilder, ServiceModule } from './cdk/ast'; +import { AstBuilder, DEFAULT_FILE_PATTERNS, GenerateFilePatterns, submoduleFiles } from './cdk/ast'; import { ModuleImportLocations } from './cdk/cdk'; -import { queryDb, log, PatternedString, TsFileWriter } from './util'; - -export type PatternKeys = 'moduleName' | 'serviceName' | 'serviceShortName'; +import { queryDb, log, TsFileWriter } from './util'; export interface GenerateServiceRequest { /** @@ -49,26 +47,6 @@ export interface GenerateModuleOptions { readonly moduleImportLocations?: ModuleImportLocations; } -export interface GenerateFilePatterns { - /** - * The pattern used to name resource files. - * @default "%module.name%/%service.short%.generated.ts" - */ - readonly resources?: PatternedString; - - /** - * The pattern used to name augmentations. - * @default "%module.name%/%service.short%-augmentations.generated.ts" - */ - readonly augmentations?: PatternedString; - - /** - * The pattern used to name canned metrics. - * @default "%module.name%/%service.short%-canned-metrics.generated.ts" - */ - readonly cannedMetrics?: PatternedString; -} - export interface GenerateOptions { /** * Default location for module imports @@ -78,7 +56,7 @@ export interface GenerateOptions { /** * Configure where files are created exactly */ - readonly filePatterns?: GenerateFilePatterns; + readonly filePatterns?: Partial; /** * Base path for generated files @@ -118,9 +96,9 @@ export interface GenerateOutput { resources: Record; modules: { [name: string]: Array<{ - module: AstBuilder; + module: Module; options: GenerateModuleOptions; - resources: AstBuilder['resources']; + resources: Record; outputFiles: string[]; }>; }; @@ -163,7 +141,7 @@ export async function generateAll(options: GenerateOptions) { }; } - return generator(db, modules, options); + await generator(db, modules, options); } function enableDebug(options: GenerateOptions) { @@ -180,8 +158,7 @@ async function generator( const timeLabel = '🐢 Completed in'; log.time(timeLabel); log.debug('Options', options); - const { augmentationsSupport, clearOutput, outputPath = process.cwd() } = options; - const filePatterns = ensureFilePatterns(options.filePatterns); + const { clearOutput, outputPath = process.cwd() } = options; const renderer = new TypeScriptRenderer(); @@ -193,57 +170,46 @@ async function generator( fs.removeSync(outputPath); } + const ast = new AstBuilder({ + db, + modulesRoot: options.importLocations?.modulesRoot, + filePatterns: { + ...DEFAULT_FILE_PATTERNS, + ...noUndefined(options.filePatterns), + }, + }); + // Go through the module map log.info('Generating %i modules...', Object.keys(modules).length); for (const [moduleName, moduleOptions] of Object.entries(modules)) { - const { moduleImportLocations: importLocations = options.importLocations, services } = moduleOptions; - moduleMap[moduleName] = queryDb.getServicesByGenerateServiceRequest(db, services).map(([req, s]) => { + const services = queryDb.getServicesByGenerateServiceRequest(db, moduleOptions.services); + + moduleMap[moduleName] = services.map(([req, s]) => { log.debug(moduleName, s.name, 'ast'); - const ast = AstBuilder.forService(s, { - db, - importLocations, + + const submod = ast.addService(s, { + destinationSubmodule: moduleName, nameSuffix: req.suffix, deprecated: req.deprecated, + importLocations: moduleOptions.moduleImportLocations ?? options.importLocations, }); - log.debug(moduleName, s.name, 'render'); - const writer = new TsFileWriter(outputPath, renderer, { - ['moduleName']: moduleName, - ['serviceName']: ast.module.service.toLowerCase(), - ['serviceShortName']: ast.module.shortName.toLowerCase(), - }); - - // Resources - writer.write(ast.module, filePatterns.resources); - - if (ast.augmentations?.hasAugmentations) { - const augFile = writer.write(ast.augmentations, filePatterns.augmentations); - - if (augmentationsSupport) { - const augDir = path.dirname(augFile); - for (const supportMod of ast.augmentations.supportModules) { - writer.write(supportMod, path.resolve(augDir, `${supportMod.importName}.ts`)); - } - } - } - - if (ast.cannedMetrics?.hasCannedMetrics) { - writer.write(ast.cannedMetrics, filePatterns.cannedMetrics); - } - return { - module: ast, + module: submod.resourcesMod.module, options: moduleOptions, - resources: ast.resources, - outputFiles: writer.outputFiles, - }; + resources: submod.resources, + outputFiles: submoduleFiles(submod).map((x) => path.resolve(x)), + } satisfies GenerateOutput['modules'][string][number]; }); } - const result = { + const writer = new TsFileWriter(outputPath, renderer); + ast.writeAll(writer); + + const result: GenerateOutput = { modules: moduleMap, resources: Object.values(moduleMap).flat().map(pick('resources')).reduce(mergeObjects, {}), - outputFiles: Object.values(moduleMap).flat().flatMap(pick('outputFiles')), + outputFiles: writer.outputFiles, }; log.info('Summary:'); @@ -254,15 +220,6 @@ async function generator( return result; } -function ensureFilePatterns(patterns: GenerateFilePatterns = {}): Required { - return { - resources: ({ serviceShortName }) => `${serviceShortName}.generated.ts`, - augmentations: ({ serviceShortName }) => `${serviceShortName}-augmentations.generated.ts`, - cannedMetrics: ({ serviceShortName }) => `${serviceShortName}-canned-metrics.generated.ts`, - ...patterns, - }; -} - function pick(property: keyof T) { type x = typeof property; return (obj: Record): any => { @@ -276,3 +233,10 @@ function mergeObjects(all: T, res: T) { ...res, }; } + +function noUndefined(x: A | undefined): A | undefined { + if (!x) { + return undefined; + } + return Object.fromEntries(Object.entries(x).filter(([, v]) => v !== undefined)) as any; +} diff --git a/tools/@aws-cdk/spec2cdk/lib/util/patterned-name.ts b/tools/@aws-cdk/spec2cdk/lib/util/patterned-name.ts index 88e4e1d708c83..c921bfa092b18 100644 --- a/tools/@aws-cdk/spec2cdk/lib/util/patterned-name.ts +++ b/tools/@aws-cdk/spec2cdk/lib/util/patterned-name.ts @@ -1,10 +1,14 @@ -export function parsePattern(pattern: string, fields: { [k in A]: unknown }): PatternedString { - const placeholders = Object.keys(fields); - if (!placeholders.some((param) => pattern.includes(param))) { - throw new Error(`--pattern must contain one of [${placeholders.join(', ')}]`); +export const PATTERN_FIELDS = ['moduleName', 'serviceName', 'serviceShortName']; + +export type FilePatternKeys = (typeof PATTERN_FIELDS)[number]; + +export function parseFilePattern(pattern: string): FilePatternFormatter { + if (!PATTERN_FIELDS.some((param) => pattern.includes(param))) { + // eslint-disable-next-line @cdklabs/no-throw-default-error + throw new Error(`--pattern must contain one of [${PATTERN_FIELDS.join(', ')}]`); } - return (values: { [k in A]: string }) => { + return (values: { [k in FilePatternKeys]: string }) => { let ret = pattern; for (const [k, v] of Object.entries(values)) { ret = ret.replace(`%${k}%`, String(v)); @@ -13,6 +17,10 @@ export function parsePattern(pattern: string, fields: { [k in }; } -export type PatternValues = { [k in A]: string }; +export type FilePatternValues = { [k in FilePatternKeys]: string }; + +export function substituteFilePattern(pattern: string, values: FilePatternValues): string { + return parseFilePattern(pattern)(values); +} -export type PatternedString = (values: PatternValues) => string; +export type FilePatternFormatter = (values: FilePatternValues) => string; diff --git a/tools/@aws-cdk/spec2cdk/lib/util/ts-file-writer.ts b/tools/@aws-cdk/spec2cdk/lib/util/ts-file-writer.ts index 298cf97ce5f45..4a085fa95f178 100644 --- a/tools/@aws-cdk/spec2cdk/lib/util/ts-file-writer.ts +++ b/tools/@aws-cdk/spec2cdk/lib/util/ts-file-writer.ts @@ -1,29 +1,23 @@ import * as path from 'node:path'; import { Module, TypeScriptRenderer } from '@cdklabs/typewriter'; import * as fs from 'fs-extra'; -import { PatternValues, PatternedString } from './patterned-name'; -import { PatternKeys } from '../generate'; -export class TsFileWriter { +export interface IWriter { + write(module: Module, filePath: string): string; +} + +export class TsFileWriter implements IWriter { public outputFiles = new Array(); constructor( - private readonly outputPath: string, + private readonly rootDir: string, private readonly renderer: TypeScriptRenderer, - private readonly values: PatternValues, ) {} - public write(module: Module, filePath: string | PatternedString): string { - const output = this.resolveFilePath(filePath); - fs.outputFileSync(output, this.renderer.render(module)); - this.outputFiles.push(output); - return output; - } - - private resolveFilePath(filePath: string | PatternedString): string { - if (typeof filePath === 'function') { - return path.join(this.outputPath, filePath(this.values)); - } - return filePath; + public write(module: Module, filePath: string): string { + const fullPath = path.join(this.rootDir, filePath); + fs.outputFileSync(fullPath, this.renderer.render(module)); + this.outputFiles.push(fullPath); + return fullPath; } } diff --git a/tools/@aws-cdk/spec2cdk/test/cli.test.ts b/tools/@aws-cdk/spec2cdk/test/cli.test.ts index 0a2dc78d9946f..34df28fe5af79 100644 --- a/tools/@aws-cdk/spec2cdk/test/cli.test.ts +++ b/tools/@aws-cdk/spec2cdk/test/cli.test.ts @@ -8,8 +8,11 @@ describe('cli', () => { await withTemporaryDirectory(async ({ testDir }) => { await main([testDir, '--service', 'AWS::S3', '--service', 'AWS::SNS']); - expect(fs.existsSync(path.join(testDir, 'aws-s3', 's3.generated.ts'))).toBe(true); - expect(fs.existsSync(path.join(testDir, 'aws-sns', 'sns.generated.ts'))).toBe(true); + const generated = await deepListDir(testDir); + + expect(generated).toContainEqual('aws-s3/s3.generated.ts'); + expect(generated).toContainEqual('aws-sns/sns.generated.ts'); + expect(generated).toContainEqual('aws-sns/sns-canned-metrics.generated.ts'); }); }); }); @@ -31,3 +34,21 @@ async function withTemporaryDirectory(block: (context: TemporaryDirectoryContext }); } } + +async function deepListDir(root: string): Promise { + const result = new Array(); + const queue = [root]; + while (queue.length > 0) { + const dir = queue.shift()!; + const entries = await fs.promises.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + queue.push(fullPath); + } else { + result.push(path.relative(root, fullPath)); + } + } + } + return result; +} diff --git a/tools/@aws-cdk/spec2cdk/test/fake-services.test.ts b/tools/@aws-cdk/spec2cdk/test/fake-services.test.ts index 50ae934cd560e..9dd06fc44708e 100644 --- a/tools/@aws-cdk/spec2cdk/test/fake-services.test.ts +++ b/tools/@aws-cdk/spec2cdk/test/fake-services.test.ts @@ -33,8 +33,9 @@ test('can codegen deprecated service', () => { }); db.link('hasResource', service, resource); - const ast = AstBuilder.forService(service, { db, deprecated: 'in favour of something else' }); + const ast = new AstBuilder({ db }); + const info = ast.addService(service, { deprecated: 'in favour of something else' }); - const rendered = renderer.render(ast.module); + const rendered = renderer.render(info.resourcesMod.module); expect(rendered).toMatchSnapshot(); }); diff --git a/tools/@aws-cdk/spec2cdk/test/history.test.ts b/tools/@aws-cdk/spec2cdk/test/history.test.ts index 9bc4ee0f3fab9..9b0531c798646 100644 --- a/tools/@aws-cdk/spec2cdk/test/history.test.ts +++ b/tools/@aws-cdk/spec2cdk/test/history.test.ts @@ -13,8 +13,10 @@ beforeAll(async () => { // To ensure backwards compatibility we will render previous types test('Previous types are rendered', () => { const resource = db.lookup('resource', 'cloudFormationType', 'equals', 'AWS::CloudFormation::StackSet')[0]; - const ast = AstBuilder.forResource(resource, { db }); - const stackSet = ast.module?.tryFindType('@aws-cdk/cloudformation/stackset-l1.CfnStackSet') as unknown as IScope; + const info = new AstBuilder({ db }).addResource(resource); - expect(stackSet.tryFindType('@aws-cdk/cloudformation/stackset-l1.CfnStackSet.ManagedExecutionProperty')).toBeTruthy(); + const modName = '@aws-cdk/aws-cloudformation/aws-cloudformation'; + const stackSet = info.resourcesMod.module.tryFindType(`${modName}.CfnStackSet`) as unknown as IScope; + + expect(stackSet.tryFindType(`${modName}.CfnStackSet.ManagedExecutionProperty`)).toBeTruthy(); }); diff --git a/tools/@aws-cdk/spec2cdk/test/relationships.test.ts b/tools/@aws-cdk/spec2cdk/test/relationships.test.ts index b127d8f4386ee..2d44fbe0ca800 100644 --- a/tools/@aws-cdk/spec2cdk/test/relationships.test.ts +++ b/tools/@aws-cdk/spec2cdk/test/relationships.test.ts @@ -53,8 +53,9 @@ maybeTest('resource with relationship reference', () => { }); db.link('hasResource', service, sourceResource); - const ast = AstBuilder.forService(service, { db }); - const rendered = renderer.render(ast.module); + const module = new AstBuilder({ db }).addService(service).resourcesMod.module; + + const rendered = renderer.render(module); expect(rendered).toMatchSnapshot(); }); @@ -109,8 +110,9 @@ maybeTest('resource with multiple relationship references', () => { }); db.link('hasResource', service, policyResource); - const ast = AstBuilder.forService(service, { db }); - const rendered = renderer.render(ast.module); + const module = new AstBuilder({ db }).addService(service).resourcesMod.module; + + const rendered = renderer.render(module); expect(rendered).toMatchSnapshot(); }); @@ -157,8 +159,9 @@ maybeTest('resource with nested relationship requiring flattening', () => { db.link('hasResource', service, taskResource); db.link('usesType', taskResource, configType); - const ast = AstBuilder.forService(service, { db }); - const rendered = renderer.render(ast.module); + const module = new AstBuilder({ db }).addService(service).resourcesMod.module; + + const rendered = renderer.render(module); expect(rendered).toMatchSnapshot(); }); @@ -205,8 +208,9 @@ maybeTest('resource with array of nested properties with relationship', () => { db.link('hasResource', service, resourceResource); db.link('usesType', resourceResource, permissionType); - const ast = AstBuilder.forService(service, { db }); - const rendered = renderer.render(ast.module); + const module = new AstBuilder({ db }).addService(service).resourcesMod.module; + + const rendered = renderer.render(module); expect(rendered).toMatchSnapshot(); }); @@ -272,8 +276,9 @@ maybeTest('resource with nested relationship with type history', () => { db.link('usesType', jobResource, configType); db.link('usesType', jobResource, oldConfigType); - const ast = AstBuilder.forService(service, { db }); - const rendered = renderer.render(ast.module); + const module = new AstBuilder({ db }).addService(service).resourcesMod.module; + + const rendered = renderer.render(module); expect(rendered).toMatchSnapshot(); }); @@ -327,8 +332,9 @@ maybeTest('relationship have arns appear first in the constructor chain', () => db.link('hasResource', service, taskResource); db.link('usesType', taskResource, configType); - const ast = AstBuilder.forService(service, { db }); - const rendered = renderer.render(ast.module); + const module = new AstBuilder({ db }).addService(service).resourcesMod.module; + + const rendered = renderer.render(module); const chain = '"roleArn": (props.roleArn as IRoleRef)?.roleRef?.roleArn ?? (props.roleArn as IRoleRef)?.roleRef?.roleName ?? (props.roleArn as IRoleRef)?.roleRef?.otherPrimaryId ?? props.roleArn'; diff --git a/tools/@aws-cdk/spec2cdk/test/resources.test.ts b/tools/@aws-cdk/spec2cdk/test/resources.test.ts index ae41761690096..6fa4881faffcf 100644 --- a/tools/@aws-cdk/spec2cdk/test/resources.test.ts +++ b/tools/@aws-cdk/spec2cdk/test/resources.test.ts @@ -1,6 +1,6 @@ -import { Service, SpecDatabase, emptyDatabase } from '@aws-cdk/service-spec-types'; +import { Resource, Service, SpecDatabase, emptyDatabase } from '@aws-cdk/service-spec-types'; import { TypeScriptRenderer } from '@cdklabs/typewriter'; -import { AstBuilder } from '../lib/cdk/ast'; +import { AstBuilder, AstBuilderProps } from '../lib/cdk/ast'; const renderer = new TypeScriptRenderer(); let db: SpecDatabase; @@ -36,9 +36,9 @@ test('resource interface when primaryIdentifier is a property', () => { // THEN const foundResource = db.lookup('resource', 'cloudFormationType', 'equals', 'AWS::Some::Resource').only(); - const ast = AstBuilder.forResource(foundResource, { db }); + const module = moduleForResource(foundResource, { db }); - const rendered = renderer.render(ast.module); + const rendered = renderer.render(module); expect(rendered).toMatchSnapshot(); }); @@ -63,9 +63,9 @@ test('resource with arnTemplate', () => { // THEN const foundResource = db.lookup('resource', 'cloudFormationType', 'equals', 'AWS::Some::Resource').only(); - const ast = AstBuilder.forResource(foundResource, { db }); + const module = moduleForResource(foundResource, { db }); - const rendered = renderer.render(ast.module); + const rendered = renderer.render(module); expect(rendered).toMatchSnapshot(); }); @@ -89,9 +89,9 @@ test('resource with optional primary identifier gets property from ref', () => { // THEN const foundResource = db.lookup('resource', 'cloudFormationType', 'equals', 'AWS::Some::Resource').only(); - const ast = AstBuilder.forResource(foundResource, { db }); + const module = moduleForResource(foundResource, { db }); - const rendered = renderer.render(ast.module); + const rendered = renderer.render(module); expect(rendered).toMatchSnapshot(); }); @@ -119,9 +119,9 @@ test('resource with multiple primaryIdentifiers as properties', () => { // THEN const foundResource = db.lookup('resource', 'cloudFormationType', 'equals', 'AWS::Some::Resource').only(); - const ast = AstBuilder.forResource(foundResource, { db }); + const module = moduleForResource(foundResource, { db }); - const rendered = renderer.render(ast.module); + const rendered = renderer.render(module); expect(rendered).toMatchSnapshot(); }); @@ -145,9 +145,9 @@ test('resource interface when primaryIdentifier is an attribute', () => { // THEN const foundResource = db.lookup('resource', 'cloudFormationType', 'equals', 'AWS::Some::Resource').only(); - const ast = AstBuilder.forResource(foundResource, { db }); + const module = moduleForResource(foundResource, { db }); - const rendered = renderer.render(ast.module); + const rendered = renderer.render(module); expect(rendered).toMatchSnapshot(); }); @@ -175,9 +175,9 @@ test('resource interface with multiple primaryIdentifiers', () => { // THEN const foundResource = db.lookup('resource', 'cloudFormationType', 'equals', 'AWS::Some::Resource').only(); - const ast = AstBuilder.forResource(foundResource, { db }); + const module = moduleForResource(foundResource, { db }); - const rendered = renderer.render(ast.module); + const rendered = renderer.render(module); expect(rendered).toMatchSnapshot(); }); @@ -205,9 +205,9 @@ test('resource interface with "Arn"', () => { // THEN const foundResource = db.lookup('resource', 'cloudFormationType', 'equals', 'AWS::Some::Resource').only(); - const ast = AstBuilder.forResource(foundResource, { db }); + const module = moduleForResource(foundResource, { db }); - const rendered = renderer.render(ast.module); + const rendered = renderer.render(module); expect(rendered).toMatchSnapshot(); }); @@ -235,9 +235,9 @@ test('resource interface with "Arn"', () => { // THEN const foundResource = db.lookup('resource', 'cloudFormationType', 'equals', 'AWS::Some::Something').only(); - const ast = AstBuilder.forResource(foundResource, { db }); + const module = moduleForResource(foundResource, { db }); - const rendered = renderer.render(ast.module); + const rendered = renderer.render(module); expect(rendered).toMatchSnapshot(); }); @@ -266,9 +266,9 @@ test('resource interface with Arn as a property and not a primaryIdentifier', () // THEN const foundResource = db.lookup('resource', 'cloudFormationType', 'equals', 'AWS::Some::Resource').only(); - const ast = AstBuilder.forResource(foundResource, { db }); + const module = moduleForResource(foundResource, { db }); - const rendered = renderer.render(ast.module); + const rendered = renderer.render(module); expect(rendered).toMatchSnapshot(); }); @@ -292,9 +292,15 @@ test('resource interface with Arn as primaryIdentifier', () => { // THEN const foundResource = db.lookup('resource', 'cloudFormationType', 'equals', 'AWS::Some::Resource').only(); - const ast = AstBuilder.forResource(foundResource, { db }); + const module = moduleForResource(foundResource, { db }); - const rendered = renderer.render(ast.module); + const rendered = renderer.render(module); expect(rendered).toMatchSnapshot(); }); + +function moduleForResource(resource: Resource, props: AstBuilderProps) { + const ast = new AstBuilder(props); + const info = ast.addResource(resource); + return info.resourcesMod.module; +} diff --git a/tools/@aws-cdk/spec2cdk/test/services.test.ts b/tools/@aws-cdk/spec2cdk/test/services.test.ts index 677a0cdc62eea..8633072e2820e 100644 --- a/tools/@aws-cdk/spec2cdk/test/services.test.ts +++ b/tools/@aws-cdk/spec2cdk/test/services.test.ts @@ -13,9 +13,9 @@ beforeAll(async () => { test('can codegen service with arbitrary suffix', () => { const service = db.lookup('service', 'name', 'equals', 'aws-kinesisanalyticsv2').only(); - const ast = AstBuilder.forService(service, { db, nameSuffix: 'V2' }); + const module = new AstBuilder({ db }).addService(service, { nameSuffix: 'V2' }).resourcesMod.module; - const rendered = renderer.render(ast.module); + const rendered = renderer.render(module); // Snapshot tests will fail every time the docs get updated // expect(rendered).toMatchSnapshot();