Skip to content

Commit 820bb99

Browse files
authored
chore(spec2cdk): fix cli not working for single services (#27892)
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*
1 parent f1bb801 commit 820bb99

File tree

9 files changed

+335
-136
lines changed

9 files changed

+335
-136
lines changed

tools/@aws-cdk/spec2cdk/README.md

Lines changed: 52 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,48 +2,70 @@
22

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

5-
```console
6-
Usage:
5+
## Usage
76

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

10+
declare const outputDir: string;
1011

11-
Options:
12+
// Generate all modules
13+
await generateAll(outputPath, { outputPath });
1214

13-
Note: Passing values to non-boolean options MUST use the = sign: --option=value
14-
15-
--augmentations [string] [default: "%moduleName%/%serviceShortName%-augmentations.generated.ts"]
16-
File and path pattern for generated augmentations files
17-
--augmentations-support [boolean] [default: false]
18-
Generates additional files required for augmentation files to compile. Use for testing only.
19-
--clear-output [boolean] [default: false]
20-
Completely delete the output path before generating new files
21-
--debug [boolean] [default: false]
22-
Show additional debug output
23-
--metrics [string] [default: "%moduleName%/%serviceShortName%-canned-metrics.generated.ts"]
24-
File and path pattern for generated canned metrics files
25-
--pattern [string] [default: "%moduleName%/%serviceShortName%.generated.ts"]
26-
File and path pattern for generated files
27-
--service [string] [default: all services]
28-
Generate files only for a specific service, e.g. aws-lambda
29-
30-
Path patterns can use the following variables:
15+
// Generate modules with specific instructions
16+
await generate({
17+
'aws-lambda': { services: ['AWS::Lambda'] },
18+
'aws-s3': { services: ['AWS::S3'] },
19+
}, { outputPath });
20+
```
3121

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

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

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

4029
## Temporary Schemas
4130

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

45-
## Use as @aws-cdk/cfn2ts replacement
34+
## CLI
4635

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

49-
At a code level, import `@aws-cdk/spec2cdk/lib/cfn2ts` for a drop-in replacement.
39+
```console
40+
Usage:
41+
spec2cdk <OUTPUT-PATH> [--option=value]
42+
43+
Arguments:
44+
OUTPUT-PATH The directory the generated code will be written to
45+
46+
Options:
47+
--augmentations [string] [default: %moduleName%/%serviceShortName%-augmentations.generated.ts]
48+
File and path pattern for generated augmentations files
49+
--augmentations-support [boolean]
50+
Generates additional files required for augmentation files to compile. Use for testing only
51+
--clear-output [boolean]
52+
Completely delete the output path before generating new files
53+
--debug [boolean]
54+
Show additional debug output
55+
-h, --help [boolean]
56+
Show this help
57+
--metrics [string] [default: %moduleName%/%serviceShortName%-canned-metrics.generated.ts]
58+
File and path pattern for generated canned metrics files
59+
--pattern [string] [default: %moduleName%/%serviceShortName%.generated.ts]
60+
File and path pattern for generated files
61+
-s, --service [array]
62+
Generate files only for a specific service, e.g. AWS::S3
63+
64+
Path patterns can use the following variables:
65+
66+
%moduleName% The name of the module, e.g. aws-lambda
67+
%serviceName% The full name of the service, e.g. aws-lambda
68+
%serviceShortName% The short name of the service, e.g. lambda
69+
70+
Note that %moduleName% and %serviceName% can be different if multiple services are generated into a single module.
71+
```

tools/@aws-cdk/spec2cdk/lib/cli/args.ts

Lines changed: 0 additions & 44 deletions
This file was deleted.
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import * as path from 'node:path';
2+
import { parseArgs } from 'node:util';
3+
import { PositionalArg, showHelp } from './help';
4+
import { GenerateModuleMap, PatternKeys, generate, generateAll } from '../generate';
5+
import { log, parsePattern } from '../util';
6+
7+
const command = 'spec2cdk';
8+
const args: PositionalArg[] = [{
9+
name: 'output-path',
10+
required: true,
11+
description: 'The directory the generated code will be written to',
12+
}];
13+
const config = {
14+
'help': {
15+
short: 'h',
16+
type: 'boolean',
17+
description: 'Show this help',
18+
},
19+
'debug': {
20+
type: 'boolean',
21+
description: 'Show additional debug output',
22+
},
23+
'pattern': {
24+
type: 'string',
25+
default: '%moduleName%/%serviceShortName%.generated.ts',
26+
description: 'File and path pattern for generated files',
27+
},
28+
'augmentations': {
29+
type: 'string',
30+
default: '%moduleName%/%serviceShortName%-augmentations.generated.ts',
31+
description: 'File and path pattern for generated augmentations files',
32+
},
33+
'metrics': {
34+
type: 'string',
35+
default: '%moduleName%/%serviceShortName%-canned-metrics.generated.ts',
36+
description: 'File and path pattern for generated canned metrics files ',
37+
},
38+
'service': {
39+
short: 's',
40+
type: 'string',
41+
description: 'Generate files only for a specific service, e.g. AWS::S3',
42+
multiple: true,
43+
},
44+
'clear-output': {
45+
type: 'boolean',
46+
default: false,
47+
description: 'Completely delete the output path before generating new files',
48+
},
49+
'augmentations-support': {
50+
type: 'boolean',
51+
default: false,
52+
description: 'Generates additional files required for augmentation files to compile. Use for testing only',
53+
},
54+
} as const;
55+
56+
const helpText = `Path patterns can use the following variables:
57+
58+
%moduleName% The name of the module, e.g. aws-lambda
59+
%serviceName% The full name of the service, e.g. aws-lambda
60+
%serviceShortName% The short name of the service, e.g. lambda
61+
62+
Note that %moduleName% and %serviceName% can be different if multiple services are generated into a single module.`;
63+
64+
const help = () => showHelp(command, args, config, helpText);
65+
export const shortHelp = () => showHelp(command, args);
66+
67+
export async function main(argv: string[]) {
68+
const {
69+
positionals,
70+
values: options,
71+
} = parseArgs({
72+
args: argv,
73+
allowPositionals: true,
74+
options: config,
75+
});
76+
77+
if (options.help) {
78+
help();
79+
return;
80+
}
81+
82+
if (options.debug) {
83+
process.env.DEBUG = '1';
84+
}
85+
log.debug('CLI args', positionals, options);
86+
87+
const outputDir = positionals[0];
88+
if (!outputDir) {
89+
throw new EvalError('Please specify the output-path');
90+
}
91+
92+
const pss: Record<PatternKeys, true> = { moduleName: true, serviceName: true, serviceShortName: true };
93+
94+
const outputPath = outputDir ?? path.join(__dirname, '..', 'services');
95+
const resourceFilePattern = parsePattern(
96+
stringOr(options.pattern, path.join('%moduleName%', '%serviceShortName%.generated.ts')),
97+
pss,
98+
);
99+
100+
const augmentationsFilePattern = parsePattern(
101+
stringOr(options.augmentations, path.join('%moduleName%', '%serviceShortName%-augmentations.generated.ts')),
102+
pss,
103+
);
104+
105+
const cannedMetricsFilePattern = parsePattern(
106+
stringOr(options.metrics, path.join('%moduleName%', '%serviceShortName%-canned-metrics.generated.ts')),
107+
pss,
108+
);
109+
110+
const generatorOptions = {
111+
outputPath,
112+
filePatterns: {
113+
resources: resourceFilePattern,
114+
augmentations: augmentationsFilePattern,
115+
cannedMetrics: cannedMetricsFilePattern,
116+
},
117+
clearOutput: options['clear-output'],
118+
augmentationsSupport: options['augmentations-support'],
119+
debug: options.debug as boolean,
120+
};
121+
122+
if (options.service?.length) {
123+
const moduleMap: GenerateModuleMap = {};
124+
for (const service of options.service) {
125+
if (!service.includes('::')) {
126+
throw new EvalError(`Each service must be in the form <Partition>::<Service>, e.g. AWS::S3. Got: ${service}`);
127+
}
128+
moduleMap[service.toLocaleLowerCase().split('::').join('-')] = { services: [service] };
129+
}
130+
await generate(moduleMap, generatorOptions);
131+
return;
132+
}
133+
134+
await generateAll(generatorOptions);
135+
}
136+
137+
function stringOr(pat: unknown, def: string) {
138+
if (!pat) {
139+
return def;
140+
}
141+
if (typeof pat !== 'string') {
142+
throw new Error(`Expected string, got: ${JSON.stringify(pat)}`);
143+
}
144+
return pat;
145+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/* eslint-disable no-console */
2+
3+
export interface PositionalArg {
4+
name: string;
5+
description?: string;
6+
required?: boolean
7+
}
8+
9+
export interface Option {
10+
type: 'string' | 'boolean',
11+
short?: string;
12+
default?: string | boolean;
13+
multiple?: boolean;
14+
description?: string;
15+
}
16+
17+
const TAB = ' '.repeat(4);
18+
19+
export function showHelp(command: string, args: PositionalArg[] = [], options: {
20+
[longOption: string]: Option
21+
} = {}, text?: string) {
22+
console.log('Usage:');
23+
console.log(`${TAB}${command} ${renderArgsList(args)} [--option=value]`);
24+
25+
const leftColSize = 6 + longest([
26+
...args.map(a => a.name),
27+
...Object.entries(options).map(([name, def]) => renderOptionName(name, def.short)),
28+
]);
29+
30+
if (args.length) {
31+
console.log('\nArguments:');
32+
for (const arg of args) {
33+
console.log(`${TAB}${arg.name.toLocaleUpperCase().padEnd(leftColSize)}\t${arg.description}`);
34+
}
35+
}
36+
37+
if (Object.keys(options).length) {
38+
console.log('\nOptions:');
39+
const ordered = Object.entries(options).sort(([a], [b]) => a.localeCompare(b));
40+
for (const [option, def] of ordered) {
41+
console.log(`${TAB}${renderOptionName(option, def.short).padEnd(leftColSize)}\t${renderOptionText(def)}`);
42+
}
43+
}
44+
console.log();
45+
46+
if (text) {
47+
console.log(text + '\n');
48+
}
49+
}
50+
51+
function renderArgsList(args: PositionalArg[] = []) {
52+
return args.map(arg => {
53+
const brackets = arg.required ? ['<', '>'] : ['[', ']'];
54+
return `${brackets[0]}${arg.name.toLocaleUpperCase()}${brackets[1]}`;
55+
}).join(' ');
56+
}
57+
58+
function renderOptionName(option: string, short?: string): string {
59+
if (short) {
60+
return `-${short}, --${option}`;
61+
}
62+
63+
return `${' '.repeat(4)}--${option}`;
64+
}
65+
66+
function renderOptionText(def: Option): string {
67+
const out = new Array<string>;
68+
69+
out.push(`[${def.multiple ? 'array' : def.type}]`);
70+
71+
if (def.default) {
72+
out.push(` [default: ${def.default}]`);
73+
}
74+
if (def.description) {
75+
out.push(`\n${TAB.repeat(2)} ${def.description}`);
76+
}
77+
78+
return out.join('');
79+
}
80+
81+
function longest(xs: string[]): number {
82+
return xs.sort((a, b) => b.length - a.length).at(0)?.length ?? 0;
83+
}

0 commit comments

Comments
 (0)