Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
296 changes: 219 additions & 77 deletions tools/@aws-cdk/spec2cdk/lib/cdk/ast.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,14 @@
/* 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';
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.
*/
Expand All @@ -42,82 +20,133 @@ export interface AstBuilderProps {
* @default - not deprecated
*/
readonly deprecated?: string;
}

export class AstBuilder<T extends Module> {
/**
* 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<ServiceModule> {
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<GenerateFilePatterns>;
}

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<ResourceModule> {
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<string, string> = {};
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<string, Module>();

public readonly selectiveImports = new Array<SelectiveImport>();

public readonly serviceModules = new Map<string, SubmoduleInfo>();
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) {
Expand All @@ -140,11 +169,124 @@ export class AstBuilder<T extends Module> {
}
}

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<M extends Module>(
module: M,
filePath: string,
): LocatedModule<M> {
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<Module>;
readonly augmentations: LocatedModule<AugmentationsModule>;
readonly cannedMetrics: LocatedModule<CannedMetricsModule>;

/**
* Map of CloudFormation resource name to generated class name
*/
readonly resources: Record<string, string>;
}

interface LocatedModule<T extends Module> {
readonly module: T;
readonly filePath: string;
}

function noUndefined<A extends object>(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;
}
17 changes: 14 additions & 3 deletions tools/@aws-cdk/spec2cdk/lib/cdk/canned-metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,13 @@ export class CannedMetricsModule extends Module {

private metrics: Record<string, MetricsClass> = {};
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());
}
}

Expand All @@ -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 {
Expand Down
Loading
Loading