Skip to content

Commit

Permalink
chore(spec2cdk): fix cli not working for single services (#27892)
Browse files Browse the repository at this point in the history
Fixes an issue with the CLI version of `spec2cdk` not working for single services anymore.
This used to be implicitly tested by being included in the hot path. However after a refactor, we were not using it anymore and thus didn't notice the breaking. Added a test to ensure future functionality.

Replaced to custom options parser with Node's built-in parser for increased usability and support for list options (calling the same option multiple time).

----

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
  • Loading branch information
mrgrain authored Nov 9, 2023
1 parent f1bb801 commit 820bb99
Show file tree
Hide file tree
Showing 9 changed files with 335 additions and 136 deletions.
82 changes: 52 additions & 30 deletions tools/@aws-cdk/spec2cdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,48 +2,70 @@

Generates AWS CDK L1s in TypeScript from `@aws-cdk/aws-service-spec`.

```console
Usage:
## Usage

spec2cdk OUTPUT-PATH [--option=value]
```ts
import { generateAll } from '@aws-cdk/spec2cdk';

declare const outputDir: string;

Options:
// Generate all modules
await generateAll(outputPath, { outputPath });

Note: Passing values to non-boolean options MUST use the = sign: --option=value

--augmentations [string] [default: "%moduleName%/%serviceShortName%-augmentations.generated.ts"]
File and path pattern for generated augmentations files
--augmentations-support [boolean] [default: false]
Generates additional files required for augmentation files to compile. Use for testing only.
--clear-output [boolean] [default: false]
Completely delete the output path before generating new files
--debug [boolean] [default: false]
Show additional debug output
--metrics [string] [default: "%moduleName%/%serviceShortName%-canned-metrics.generated.ts"]
File and path pattern for generated canned metrics files
--pattern [string] [default: "%moduleName%/%serviceShortName%.generated.ts"]
File and path pattern for generated files
--service [string] [default: all services]
Generate files only for a specific service, e.g. aws-lambda

Path patterns can use the following variables:
// Generate modules with specific instructions
await generate({
'aws-lambda': { services: ['AWS::Lambda'] },
'aws-s3': { services: ['AWS::S3'] },
}, { outputPath });
```

%moduleName% The name of the module, e.g. aws-lambda
%serviceName% The full name of the service, e.g. aws-lambda
%serviceShortName% The short name of the service, e.g. lambda
Refer to code autocompletion for all options.

Note that %moduleName% and %serviceName% can be different if multiple services are generated into a single module.
### Use as @aws-cdk/cfn2ts replacement

```
The package provides a binary that can be used as a drop-in replacement of the legacy `@aws-cdk/cfn2ts` package.
At a code level, import `@aws-cdk/spec2cdk/lib/cfn2ts` for a drop-in replacement.

## Temporary Schemas

You can import additional, temporary CloudFormation Registry Schemas to test new functionality that is not yet published in `@aws-cdk/aws-service-spec`.
To do this, drop the schema file into `temporary-schemas/us-east-1` and it will be imported on top of the default model.

## Use as @aws-cdk/cfn2ts replacement
## CLI

You can use the `cfn2ts` binary as a drop-in replacement for the existing `@aws-cdk/cfn2ts` command.
A CLI is available for testing and ad-hoc usage.
However its API is limited and you should use the programmatic interface for implementations.

At a code level, import `@aws-cdk/spec2cdk/lib/cfn2ts` for a drop-in replacement.
```console
Usage:
spec2cdk <OUTPUT-PATH> [--option=value]

Arguments:
OUTPUT-PATH The directory the generated code will be written to

Options:
--augmentations [string] [default: %moduleName%/%serviceShortName%-augmentations.generated.ts]
File and path pattern for generated augmentations files
--augmentations-support [boolean]
Generates additional files required for augmentation files to compile. Use for testing only
--clear-output [boolean]
Completely delete the output path before generating new files
--debug [boolean]
Show additional debug output
-h, --help [boolean]
Show this help
--metrics [string] [default: %moduleName%/%serviceShortName%-canned-metrics.generated.ts]
File and path pattern for generated canned metrics files
--pattern [string] [default: %moduleName%/%serviceShortName%.generated.ts]
File and path pattern for generated files
-s, --service [array]
Generate files only for a specific service, e.g. AWS::S3

Path patterns can use the following variables:

%moduleName% The name of the module, e.g. aws-lambda
%serviceName% The full name of the service, e.g. aws-lambda
%serviceShortName% The short name of the service, e.g. lambda

Note that %moduleName% and %serviceName% can be different if multiple services are generated into a single module.
```
44 changes: 0 additions & 44 deletions tools/@aws-cdk/spec2cdk/lib/cli/args.ts

This file was deleted.

145 changes: 145 additions & 0 deletions tools/@aws-cdk/spec2cdk/lib/cli/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
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';

const command = 'spec2cdk';
const args: PositionalArg[] = [{
name: 'output-path',
required: true,
description: 'The directory the generated code will be written to',
}];
const config = {
'help': {
short: 'h',
type: 'boolean',
description: 'Show this help',
},
'debug': {
type: 'boolean',
description: 'Show additional debug output',
},
'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': {
short: 's',
type: 'string',
description: 'Generate files only for a specific service, e.g. AWS::S3',
multiple: true,
},
'clear-output': {
type: 'boolean',
default: false,
description: 'Completely delete the output path before generating new files',
},
'augmentations-support': {
type: 'boolean',
default: false,
description: 'Generates additional files required for augmentation files to compile. Use for testing only',
},
} as const;

const helpText = `Path patterns can use the following variables:
%moduleName% The name of the module, e.g. aws-lambda
%serviceName% The full name of the service, e.g. aws-lambda
%serviceShortName% The short name of the service, e.g. lambda
Note that %moduleName% and %serviceName% can be different if multiple services are generated into a single module.`;

const help = () => showHelp(command, args, config, helpText);
export const shortHelp = () => showHelp(command, args);

export async function main(argv: string[]) {
const {
positionals,
values: options,
} = parseArgs({
args: argv,
allowPositionals: true,
options: config,
});

if (options.help) {
help();
return;
}

if (options.debug) {
process.env.DEBUG = '1';
}
log.debug('CLI args', positionals, options);

const outputDir = positionals[0];
if (!outputDir) {
throw new EvalError('Please specify the output-path');
}

const pss: Record<PatternKeys, true> = { 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 = {
outputPath,
filePatterns: {
resources: resourceFilePattern,
augmentations: augmentationsFilePattern,
cannedMetrics: cannedMetricsFilePattern,
},
clearOutput: options['clear-output'],
augmentationsSupport: options['augmentations-support'],
debug: options.debug as boolean,
};

if (options.service?.length) {
const moduleMap: GenerateModuleMap = {};
for (const service of options.service) {
if (!service.includes('::')) {
throw new EvalError(`Each service must be in the form <Partition>::<Service>, e.g. AWS::S3. Got: ${service}`);
}
moduleMap[service.toLocaleLowerCase().split('::').join('-')] = { services: [service] };
}
await generate(moduleMap, generatorOptions);
return;
}

await generateAll(generatorOptions);
}

function stringOr(pat: unknown, def: string) {
if (!pat) {
return def;
}
if (typeof pat !== 'string') {
throw new Error(`Expected string, got: ${JSON.stringify(pat)}`);
}
return pat;
}
83 changes: 83 additions & 0 deletions tools/@aws-cdk/spec2cdk/lib/cli/help.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/* eslint-disable no-console */

export interface PositionalArg {
name: string;
description?: string;
required?: boolean
}

export interface Option {
type: 'string' | 'boolean',
short?: string;
default?: string | boolean;
multiple?: boolean;
description?: string;
}

const TAB = ' '.repeat(4);

export function showHelp(command: string, args: PositionalArg[] = [], options: {
[longOption: string]: Option
} = {}, text?: string) {
console.log('Usage:');
console.log(`${TAB}${command} ${renderArgsList(args)} [--option=value]`);

const leftColSize = 6 + longest([
...args.map(a => a.name),
...Object.entries(options).map(([name, def]) => renderOptionName(name, def.short)),
]);

if (args.length) {
console.log('\nArguments:');
for (const arg of args) {
console.log(`${TAB}${arg.name.toLocaleUpperCase().padEnd(leftColSize)}\t${arg.description}`);
}
}

if (Object.keys(options).length) {
console.log('\nOptions:');
const ordered = Object.entries(options).sort(([a], [b]) => a.localeCompare(b));
for (const [option, def] of ordered) {
console.log(`${TAB}${renderOptionName(option, def.short).padEnd(leftColSize)}\t${renderOptionText(def)}`);
}
}
console.log();

if (text) {
console.log(text + '\n');
}
}

function renderArgsList(args: PositionalArg[] = []) {
return args.map(arg => {
const brackets = arg.required ? ['<', '>'] : ['[', ']'];
return `${brackets[0]}${arg.name.toLocaleUpperCase()}${brackets[1]}`;
}).join(' ');
}

function renderOptionName(option: string, short?: string): string {
if (short) {
return `-${short}, --${option}`;
}

return `${' '.repeat(4)}--${option}`;
}

function renderOptionText(def: Option): string {
const out = new Array<string>;

out.push(`[${def.multiple ? 'array' : def.type}]`);

if (def.default) {
out.push(` [default: ${def.default}]`);
}
if (def.description) {
out.push(`\n${TAB.repeat(2)} ${def.description}`);
}

return out.join('');
}

function longest(xs: string[]): number {
return xs.sort((a, b) => b.length - a.length).at(0)?.length ?? 0;
}
Loading

0 comments on commit 820bb99

Please sign in to comment.