@intentius/chant-lexicon-aws 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +438 -0
- package/package.json +30 -0
- package/src/codegen/__snapshots__/snapshot.test.ts.snap +197 -0
- package/src/codegen/docs-cli.ts +3 -0
- package/src/codegen/docs.ts +1206 -0
- package/src/codegen/extensions.ts +171 -0
- package/src/codegen/fallback.ts +33 -0
- package/src/codegen/generate-cli.ts +17 -0
- package/src/codegen/generate-lexicon.ts +98 -0
- package/src/codegen/generate-typescript.ts +257 -0
- package/src/codegen/generate.test.ts +125 -0
- package/src/codegen/generate.ts +226 -0
- package/src/codegen/idempotency.test.ts +28 -0
- package/src/codegen/naming.ts +120 -0
- package/src/codegen/package.test.ts +60 -0
- package/src/codegen/package.ts +84 -0
- package/src/codegen/patches.ts +98 -0
- package/src/codegen/rollback.test.ts +80 -0
- package/src/codegen/rollback.ts +20 -0
- package/src/codegen/sam.ts +387 -0
- package/src/codegen/snapshot.test.ts +84 -0
- package/src/codegen/typecheck.test.ts +50 -0
- package/src/codegen/typecheck.ts +4 -0
- package/src/codegen/versions.ts +37 -0
- package/src/coverage.ts +14 -0
- package/src/generated/index.d.ts +160753 -0
- package/src/generated/index.ts +14396 -0
- package/src/generated/lexicon-aws.json +114563 -0
- package/src/generated/runtime.ts +4 -0
- package/src/import/generator.test.ts +181 -0
- package/src/import/generator.ts +349 -0
- package/src/import/parser.test.ts +200 -0
- package/src/import/parser.ts +350 -0
- package/src/import/roundtrip-fixtures.test.ts +78 -0
- package/src/import/roundtrip.test.ts +195 -0
- package/src/index.ts +63 -0
- package/src/integration.test.ts +129 -0
- package/src/intrinsics.test.ts +167 -0
- package/src/intrinsics.ts +223 -0
- package/src/lint/post-synth/cf-refs.ts +91 -0
- package/src/lint/post-synth/cor020.ts +72 -0
- package/src/lint/post-synth/ext001.test.ts +68 -0
- package/src/lint/post-synth/ext001.ts +222 -0
- package/src/lint/post-synth/post-synth.test.ts +280 -0
- package/src/lint/post-synth/waw010.ts +49 -0
- package/src/lint/post-synth/waw011.ts +49 -0
- package/src/lint/post-synth/waw013.ts +45 -0
- package/src/lint/post-synth/waw014.ts +50 -0
- package/src/lint/post-synth/waw015.ts +100 -0
- package/src/lint/rules/hardcoded-region.ts +43 -0
- package/src/lint/rules/iam-wildcard.ts +66 -0
- package/src/lint/rules/index.ts +7 -0
- package/src/lint/rules/rules.test.ts +175 -0
- package/src/lint/rules/s3-encryption.ts +69 -0
- package/src/lsp/completions.test.ts +72 -0
- package/src/lsp/completions.ts +18 -0
- package/src/lsp/hover.test.ts +53 -0
- package/src/lsp/hover.ts +53 -0
- package/src/nested-stack.test.ts +83 -0
- package/src/nested-stack.ts +125 -0
- package/src/plugin.test.ts +316 -0
- package/src/plugin.ts +514 -0
- package/src/pseudo.test.ts +55 -0
- package/src/pseudo.ts +29 -0
- package/src/serializer.test.ts +507 -0
- package/src/serializer.ts +333 -0
- package/src/spec/fetch.test.ts +27 -0
- package/src/spec/fetch.ts +107 -0
- package/src/spec/parse.test.ts +153 -0
- package/src/spec/parse.ts +202 -0
- package/src/testdata/load-fixtures.ts +17 -0
- package/src/testdata/roundtrip/conditions.json +21 -0
- package/src/testdata/roundtrip/intrinsic-calls.json +31 -0
- package/src/testdata/roundtrip/intrinsics.json +18 -0
- package/src/testdata/roundtrip/multi-resource.json +37 -0
- package/src/testdata/roundtrip/parameters.json +23 -0
- package/src/testdata/roundtrip/simple.json +12 -0
- package/src/testdata/sam-fixtures/api.yaml +14 -0
- package/src/testdata/sam-fixtures/application.yaml +13 -0
- package/src/testdata/sam-fixtures/function.yaml +22 -0
- package/src/testdata/sam-fixtures/graphql-api.yaml +13 -0
- package/src/testdata/sam-fixtures/http-api.yaml +15 -0
- package/src/testdata/sam-fixtures/layer-version.yaml +15 -0
- package/src/testdata/sam-fixtures/multi-type-a.yaml +23 -0
- package/src/testdata/sam-fixtures/multi-type-b.yaml +29 -0
- package/src/testdata/sam-fixtures/simple-table.yaml +12 -0
- package/src/testdata/sam-fixtures/state-machine.yaml +14 -0
- package/src/testdata/schemas/aws-dynamodb-table.json +126 -0
- package/src/testdata/schemas/aws-iam-role.json +85 -0
- package/src/testdata/schemas/aws-lambda-function.json +90 -0
- package/src/testdata/schemas/aws-s3-bucket.json +83 -0
- package/src/testdata/schemas/aws-sns-topic.json +71 -0
- package/src/validate-cli.ts +19 -0
- package/src/validate.ts +34 -0
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import type { Declarable, CoreParameter } from "@intentius/chant/declarable";
|
|
2
|
+
import { isPropertyDeclarable } from "@intentius/chant/declarable";
|
|
3
|
+
import type { Serializer, SerializerResult } from "@intentius/chant/serializer";
|
|
4
|
+
import type { LexiconOutput } from "@intentius/chant/lexicon-output";
|
|
5
|
+
import { walkValue, type SerializerVisitor } from "@intentius/chant/serializer-walker";
|
|
6
|
+
import { toPascalCase } from "@intentius/chant/codegen/case";
|
|
7
|
+
import { isChildProject, type ChildProjectInstance } from "@intentius/chant/child-project";
|
|
8
|
+
import { isStackOutput, type StackOutput } from "@intentius/chant/stack-output";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Check if a declarable is a CoreParameter
|
|
12
|
+
*/
|
|
13
|
+
function isCoreParameter(entity: Declarable): entity is CoreParameter {
|
|
14
|
+
return "parameterType" in entity;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* CloudFormation template structure
|
|
19
|
+
*/
|
|
20
|
+
interface CFTemplate {
|
|
21
|
+
AWSTemplateFormatVersion: "2010-09-09";
|
|
22
|
+
Description?: string;
|
|
23
|
+
Parameters?: Record<string, CFParameter>;
|
|
24
|
+
Resources: Record<string, CFResource>;
|
|
25
|
+
Outputs?: Record<string, CFOutput>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* CloudFormation parameter
|
|
30
|
+
*/
|
|
31
|
+
interface CFParameter {
|
|
32
|
+
Type: string;
|
|
33
|
+
Description?: string;
|
|
34
|
+
Default?: unknown;
|
|
35
|
+
AllowedValues?: unknown[];
|
|
36
|
+
AllowedPattern?: string;
|
|
37
|
+
ConstraintDescription?: string;
|
|
38
|
+
MaxLength?: number;
|
|
39
|
+
MaxValue?: number;
|
|
40
|
+
MinLength?: number;
|
|
41
|
+
MinValue?: number;
|
|
42
|
+
NoEcho?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* CloudFormation resource
|
|
47
|
+
*/
|
|
48
|
+
interface CFResource {
|
|
49
|
+
Type: string;
|
|
50
|
+
Properties?: Record<string, unknown>;
|
|
51
|
+
DependsOn?: string | string[];
|
|
52
|
+
Condition?: string;
|
|
53
|
+
Metadata?: Record<string, unknown>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* CloudFormation output
|
|
58
|
+
*/
|
|
59
|
+
interface CFOutput {
|
|
60
|
+
Value: unknown;
|
|
61
|
+
Description?: string;
|
|
62
|
+
Export?: { Name: unknown };
|
|
63
|
+
Condition?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* CloudFormation-specific visitor for the generic serializer walker.
|
|
68
|
+
*/
|
|
69
|
+
function cfnVisitor(entityNames: Map<Declarable, string>): SerializerVisitor {
|
|
70
|
+
return {
|
|
71
|
+
attrRef: (name, attr) => ({ "Fn::GetAttr": [name, attr] }),
|
|
72
|
+
resourceRef: (name) => ({ Ref: name }),
|
|
73
|
+
propertyDeclarable: (entity, walk) => {
|
|
74
|
+
if (!("props" in entity) || typeof entity.props !== "object" || entity.props === null) {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
const props = entity.props as Record<string, unknown>;
|
|
78
|
+
const cfProps: Record<string, unknown> = {};
|
|
79
|
+
for (const [key, value] of Object.entries(props)) {
|
|
80
|
+
if (value !== undefined) {
|
|
81
|
+
const cfKey = toPascalCase(key);
|
|
82
|
+
cfProps[cfKey] = walk(value);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return Object.keys(cfProps).length > 0 ? cfProps : undefined;
|
|
86
|
+
},
|
|
87
|
+
transformKey: toPascalCase,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Convert a value to CF-compatible JSON using the generic walker.
|
|
93
|
+
*/
|
|
94
|
+
function toCFValue(value: unknown, entityNames: Map<Declarable, string>, convertKeys = false): unknown {
|
|
95
|
+
const visitor = cfnVisitor(entityNames);
|
|
96
|
+
if (!convertKeys) {
|
|
97
|
+
// When not converting keys, use a visitor without transformKey
|
|
98
|
+
return walkValue(value, entityNames, { ...visitor, transformKey: undefined });
|
|
99
|
+
}
|
|
100
|
+
return walkValue(value, entityNames, visitor);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Convert entity props to CF properties
|
|
105
|
+
*/
|
|
106
|
+
function toProperties(
|
|
107
|
+
entity: Declarable,
|
|
108
|
+
entityNames: Map<Declarable, string>
|
|
109
|
+
): Record<string, unknown> | undefined {
|
|
110
|
+
if (!("props" in entity) || typeof entity.props !== "object" || entity.props === null) {
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const props = entity.props as Record<string, unknown>;
|
|
115
|
+
const cfProps: Record<string, unknown> = {};
|
|
116
|
+
|
|
117
|
+
for (const [key, value] of Object.entries(props)) {
|
|
118
|
+
if (value !== undefined) {
|
|
119
|
+
const cfKey = toPascalCase(key);
|
|
120
|
+
cfProps[cfKey] = toCFValue(value, entityNames, true);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return Object.keys(cfProps).length > 0 ? cfProps : undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Serialize a set of entities into a CFTemplate object (without JSON.stringify).
|
|
130
|
+
*/
|
|
131
|
+
function serializeToTemplate(
|
|
132
|
+
entities: Map<string, Declarable>,
|
|
133
|
+
outputs?: LexiconOutput[],
|
|
134
|
+
extraParameters?: Record<string, CFParameter>,
|
|
135
|
+
extraOutputs?: Record<string, CFOutput>,
|
|
136
|
+
): CFTemplate {
|
|
137
|
+
const template: CFTemplate = {
|
|
138
|
+
AWSTemplateFormatVersion: "2010-09-09",
|
|
139
|
+
Resources: {},
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// Add extra parameters (e.g. TemplateBasePath)
|
|
143
|
+
if (extraParameters && Object.keys(extraParameters).length > 0) {
|
|
144
|
+
template.Parameters = { ...extraParameters };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Build reverse map: entity -> name
|
|
148
|
+
const entityNames = new Map<Declarable, string>();
|
|
149
|
+
for (const [name, entity] of entities) {
|
|
150
|
+
entityNames.set(entity, name);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Process entities
|
|
154
|
+
for (const [name, entity] of entities) {
|
|
155
|
+
// Skip StackOutput entities — they go in the Outputs section
|
|
156
|
+
if (isStackOutput(entity)) {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (isCoreParameter(entity)) {
|
|
161
|
+
if (!template.Parameters) {
|
|
162
|
+
template.Parameters = {};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const param: CFParameter = {
|
|
166
|
+
Type: entity.parameterType,
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
if ("description" in entity && typeof entity.description === "string") {
|
|
170
|
+
param.Description = entity.description;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if ("defaultValue" in entity && entity.defaultValue !== undefined) {
|
|
174
|
+
param.Default = entity.defaultValue;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
template.Parameters[name] = param;
|
|
178
|
+
} else if (isChildProject(entity)) {
|
|
179
|
+
// ChildProjectInstance → AWS::CloudFormation::Stack resource
|
|
180
|
+
const childProject = entity as ChildProjectInstance;
|
|
181
|
+
const childName = childProject.logicalName;
|
|
182
|
+
const filename = `${childName}.template.json`;
|
|
183
|
+
|
|
184
|
+
const properties: Record<string, unknown> = {
|
|
185
|
+
TemplateURL: {
|
|
186
|
+
"Fn::Sub": `\${TemplateBasePath}/${filename}`,
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// Build parameters: always pass TemplateBasePath down
|
|
191
|
+
const parameters: Record<string, unknown> = {
|
|
192
|
+
TemplateBasePath: { Ref: "TemplateBasePath" },
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// Add user-specified parameters
|
|
196
|
+
const opts = childProject.options as { parameters?: Record<string, unknown> };
|
|
197
|
+
if (opts.parameters) {
|
|
198
|
+
for (const [key, value] of Object.entries(opts.parameters)) {
|
|
199
|
+
parameters[key] = value;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
properties.Parameters = parameters;
|
|
204
|
+
|
|
205
|
+
template.Resources[name] = {
|
|
206
|
+
Type: "AWS::CloudFormation::Stack",
|
|
207
|
+
Properties: properties,
|
|
208
|
+
};
|
|
209
|
+
} else if (!isPropertyDeclarable(entity)) {
|
|
210
|
+
const resource: CFResource = {
|
|
211
|
+
Type: entity.entityType,
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const properties = toProperties(entity, entityNames);
|
|
215
|
+
if (properties) {
|
|
216
|
+
resource.Properties = properties;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
template.Resources[name] = resource;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Emit StackOutput entities as CF Outputs
|
|
224
|
+
for (const [name, entity] of entities) {
|
|
225
|
+
if (isStackOutput(entity)) {
|
|
226
|
+
if (!template.Outputs) {
|
|
227
|
+
template.Outputs = {};
|
|
228
|
+
}
|
|
229
|
+
const stackOutput = entity as StackOutput;
|
|
230
|
+
const ref = stackOutput.sourceRef;
|
|
231
|
+
const logicalName = ref.getLogicalName();
|
|
232
|
+
if (logicalName) {
|
|
233
|
+
const output: CFOutput = {
|
|
234
|
+
Value: { "Fn::GetAttr": [logicalName, ref.attribute] },
|
|
235
|
+
};
|
|
236
|
+
if (stackOutput.description) {
|
|
237
|
+
output.Description = stackOutput.description;
|
|
238
|
+
}
|
|
239
|
+
template.Outputs[name] = output;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Add CF Outputs for LexiconOutputs produced by this lexicon
|
|
245
|
+
if (outputs && outputs.length > 0) {
|
|
246
|
+
template.Outputs = template.Outputs ?? {};
|
|
247
|
+
for (const output of outputs) {
|
|
248
|
+
template.Outputs[output.outputName] = {
|
|
249
|
+
Value: {
|
|
250
|
+
"Fn::GetAttr": [output.sourceEntity, output.sourceAttribute],
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Add extra outputs (e.g. auto-wired cross-stack refs)
|
|
257
|
+
if (extraOutputs && Object.keys(extraOutputs).length > 0) {
|
|
258
|
+
template.Outputs = { ...template.Outputs, ...extraOutputs };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return template;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* AWS CloudFormation serializer implementation
|
|
266
|
+
*/
|
|
267
|
+
export const awsSerializer: Serializer = {
|
|
268
|
+
name: "aws",
|
|
269
|
+
rulePrefix: "WAW",
|
|
270
|
+
|
|
271
|
+
serialize(entities: Map<string, Declarable>, outputs?: LexiconOutput[]): string | SerializerResult {
|
|
272
|
+
// Check if any entities are child projects (nested stacks)
|
|
273
|
+
const childProjects = new Map<string, ChildProjectInstance>();
|
|
274
|
+
let hasChildProjects = false;
|
|
275
|
+
|
|
276
|
+
for (const [name, entity] of entities) {
|
|
277
|
+
if (isChildProject(entity)) {
|
|
278
|
+
childProjects.set(name, entity as ChildProjectInstance);
|
|
279
|
+
hasChildProjects = true;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// No nested stacks — use the simple path
|
|
284
|
+
if (!hasChildProjects) {
|
|
285
|
+
const template = serializeToTemplate(entities, outputs);
|
|
286
|
+
return JSON.stringify(template, null, 2);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Has child projects — produce multi-file output
|
|
290
|
+
const allFiles: Record<string, string> = {};
|
|
291
|
+
|
|
292
|
+
// Add TemplateBasePath parameter to the parent template
|
|
293
|
+
const parentParams: Record<string, CFParameter> = {
|
|
294
|
+
TemplateBasePath: {
|
|
295
|
+
Type: "String",
|
|
296
|
+
Default: ".",
|
|
297
|
+
Description: "Base URL/path for nested stack templates",
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
// Collect child template files from build results
|
|
302
|
+
for (const [, childProject] of childProjects) {
|
|
303
|
+
if (childProject.buildResult) {
|
|
304
|
+
const childOutput = childProject.buildResult.outputs.get("aws");
|
|
305
|
+
if (childOutput) {
|
|
306
|
+
const childName = childProject.logicalName;
|
|
307
|
+
const filename = `${childName}.template.json`;
|
|
308
|
+
|
|
309
|
+
if (typeof childOutput === "string") {
|
|
310
|
+
allFiles[filename] = childOutput;
|
|
311
|
+
} else {
|
|
312
|
+
// SerializerResult — the child itself has child templates
|
|
313
|
+
allFiles[filename] = childOutput.primary;
|
|
314
|
+
if (childOutput.files) {
|
|
315
|
+
for (const [childFile, content] of Object.entries(childOutput.files)) {
|
|
316
|
+
allFiles[childFile] = content;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Serialize the parent template (ChildProjectInstance entities become CF::Stack resources)
|
|
325
|
+
const parentTemplate = serializeToTemplate(entities, outputs, parentParams);
|
|
326
|
+
const primary = JSON.stringify(parentTemplate, null, 2);
|
|
327
|
+
|
|
328
|
+
return {
|
|
329
|
+
primary,
|
|
330
|
+
files: allFiles,
|
|
331
|
+
};
|
|
332
|
+
},
|
|
333
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { fetchSchemaZip } from "./fetch";
|
|
3
|
+
|
|
4
|
+
describe("fetchSchemaZip", () => {
|
|
5
|
+
test("exports fetchSchemaZip function", () => {
|
|
6
|
+
expect(typeof fetchSchemaZip).toBe("function");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
// Integration test - requires network, skip by default
|
|
10
|
+
test.skip("fetches schema zip from AWS (integration)", async () => {
|
|
11
|
+
const schemas = await fetchSchemaZip();
|
|
12
|
+
|
|
13
|
+
// Should have many resource schemas
|
|
14
|
+
expect(schemas.size).toBeGreaterThan(100);
|
|
15
|
+
|
|
16
|
+
// Should contain common resource types
|
|
17
|
+
expect(schemas.has("aws-s3-bucket.json")).toBe(true);
|
|
18
|
+
expect(schemas.has("aws-lambda-function.json")).toBe(true);
|
|
19
|
+
|
|
20
|
+
// Each schema should be valid JSON
|
|
21
|
+
for (const [name, buffer] of schemas) {
|
|
22
|
+
const text = new TextDecoder().decode(buffer);
|
|
23
|
+
const parsed = JSON.parse(text);
|
|
24
|
+
expect(parsed.typeName).toBeDefined();
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { homedir } from "os";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { fetchWithCache, extractFromZip, clearCacheFile } from "@intentius/chant/codegen/fetch";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Top-level CloudFormation Registry JSON Schema for a single resource type.
|
|
7
|
+
*/
|
|
8
|
+
export interface CFNSchema {
|
|
9
|
+
typeName: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
properties?: Record<string, SchemaProperty>;
|
|
12
|
+
definitions?: Record<string, SchemaDefinition>;
|
|
13
|
+
required?: string[];
|
|
14
|
+
readOnlyProperties?: string[];
|
|
15
|
+
createOnlyProperties?: string[];
|
|
16
|
+
writeOnlyProperties?: string[];
|
|
17
|
+
primaryIdentifier?: string[];
|
|
18
|
+
additionalProperties?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A single property in a CloudFormation Registry schema.
|
|
23
|
+
*/
|
|
24
|
+
export interface SchemaProperty {
|
|
25
|
+
type?: string | string[];
|
|
26
|
+
description?: string;
|
|
27
|
+
enum?: string[];
|
|
28
|
+
$ref?: string;
|
|
29
|
+
items?: SchemaProperty;
|
|
30
|
+
properties?: Record<string, SchemaProperty>;
|
|
31
|
+
oneOf?: unknown[];
|
|
32
|
+
anyOf?: unknown[];
|
|
33
|
+
required?: string[];
|
|
34
|
+
pattern?: string;
|
|
35
|
+
minLength?: number;
|
|
36
|
+
maxLength?: number;
|
|
37
|
+
minimum?: number;
|
|
38
|
+
maximum?: number;
|
|
39
|
+
format?: string;
|
|
40
|
+
const?: unknown;
|
|
41
|
+
default?: unknown;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A named type within the definitions section.
|
|
46
|
+
*/
|
|
47
|
+
export interface SchemaDefinition {
|
|
48
|
+
type?: string | string[];
|
|
49
|
+
description?: string;
|
|
50
|
+
enum?: string[];
|
|
51
|
+
properties?: Record<string, SchemaProperty>;
|
|
52
|
+
required?: string[];
|
|
53
|
+
items?: SchemaProperty;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const SCHEMA_ZIP_URL = "https://schema.cloudformation.us-east-1.amazonaws.com/CloudformationSchema.zip";
|
|
57
|
+
const CACHE_DIR = join(homedir(), ".chant");
|
|
58
|
+
const CACHE_FILE = join(CACHE_DIR, "CloudformationSchema.zip");
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Fetch the CloudFormation Registry schema zip and extract per-resource JSON schemas.
|
|
62
|
+
* Returns a Map keyed by typeName (e.g. "AWS::S3::Bucket") to raw JSON bytes.
|
|
63
|
+
*
|
|
64
|
+
* Uses a local cache with 24h TTL.
|
|
65
|
+
*/
|
|
66
|
+
export async function fetchSchemaZip(force = false): Promise<Map<string, Buffer>> {
|
|
67
|
+
const zipData = await fetchWithCache(
|
|
68
|
+
{ url: SCHEMA_ZIP_URL, cacheFile: CACHE_FILE },
|
|
69
|
+
force,
|
|
70
|
+
);
|
|
71
|
+
return extractRawSchemas(zipData);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Extract raw JSON schema bytes from the zip, keyed by typeName.
|
|
76
|
+
*/
|
|
77
|
+
async function extractRawSchemas(zipData: Buffer): Promise<Map<string, Buffer>> {
|
|
78
|
+
const files = await extractFromZip(zipData, (name) => name.endsWith(".json"));
|
|
79
|
+
|
|
80
|
+
const schemas = new Map<string, Buffer>();
|
|
81
|
+
for (const [_name, data] of files) {
|
|
82
|
+
try {
|
|
83
|
+
const text = data.toString("utf-8");
|
|
84
|
+
const partial = JSON.parse(text) as { typeName?: string };
|
|
85
|
+
if (!partial.typeName) continue;
|
|
86
|
+
schemas.set(partial.typeName, data);
|
|
87
|
+
} catch {
|
|
88
|
+
// Skip files that can't be parsed
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return schemas;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Get the cache file path (for testing)
|
|
97
|
+
*/
|
|
98
|
+
export function getCachePath(): string {
|
|
99
|
+
return CACHE_FILE;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Clear the cache (for testing)
|
|
104
|
+
*/
|
|
105
|
+
export function clearCache(): void {
|
|
106
|
+
clearCacheFile(CACHE_FILE);
|
|
107
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
parseCFNSchema,
|
|
4
|
+
cfnShortName,
|
|
5
|
+
cfnServiceName,
|
|
6
|
+
} from "./parse";
|
|
7
|
+
|
|
8
|
+
// Sample Registry JSON Schema for testing
|
|
9
|
+
const sampleBucketSchema = JSON.stringify({
|
|
10
|
+
typeName: "AWS::S3::Bucket",
|
|
11
|
+
description: "Creates an S3 bucket",
|
|
12
|
+
properties: {
|
|
13
|
+
BucketName: { type: "string", description: "Name of the bucket" },
|
|
14
|
+
Tags: {
|
|
15
|
+
type: "array",
|
|
16
|
+
items: { $ref: "#/definitions/Tag" },
|
|
17
|
+
},
|
|
18
|
+
VersioningConfiguration: {
|
|
19
|
+
$ref: "#/definitions/VersioningConfiguration",
|
|
20
|
+
},
|
|
21
|
+
AccessControl: {
|
|
22
|
+
type: "string",
|
|
23
|
+
enum: ["Private", "PublicRead", "PublicReadWrite"],
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
definitions: {
|
|
27
|
+
Tag: {
|
|
28
|
+
type: "object",
|
|
29
|
+
properties: {
|
|
30
|
+
Key: { type: "string" },
|
|
31
|
+
Value: { type: "string" },
|
|
32
|
+
},
|
|
33
|
+
required: ["Key", "Value"],
|
|
34
|
+
additionalProperties: false,
|
|
35
|
+
},
|
|
36
|
+
VersioningConfiguration: {
|
|
37
|
+
type: "object",
|
|
38
|
+
properties: {
|
|
39
|
+
Status: { type: "string", enum: ["Enabled", "Suspended"] },
|
|
40
|
+
},
|
|
41
|
+
required: ["Status"],
|
|
42
|
+
additionalProperties: false,
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
readOnlyProperties: [
|
|
46
|
+
"/properties/Arn",
|
|
47
|
+
"/properties/DomainName",
|
|
48
|
+
"/properties/RegionalDomainName",
|
|
49
|
+
"/properties/WebsiteURL",
|
|
50
|
+
],
|
|
51
|
+
required: [],
|
|
52
|
+
primaryIdentifier: ["/properties/BucketName"],
|
|
53
|
+
additionalProperties: false,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("parseCFNSchema", () => {
|
|
57
|
+
test("parses resource type", () => {
|
|
58
|
+
const result = parseCFNSchema(sampleBucketSchema);
|
|
59
|
+
|
|
60
|
+
expect(result.resource.typeName).toBe("AWS::S3::Bucket");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("parses properties", () => {
|
|
64
|
+
const result = parseCFNSchema(sampleBucketSchema);
|
|
65
|
+
|
|
66
|
+
expect(result.resource.properties.length).toBeGreaterThan(0);
|
|
67
|
+
|
|
68
|
+
const bucketName = result.resource.properties.find((p) => p.name === "BucketName");
|
|
69
|
+
expect(bucketName).toBeDefined();
|
|
70
|
+
expect(bucketName!.tsType).toBe("string");
|
|
71
|
+
expect(bucketName!.required).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("parses attributes from readOnlyProperties", () => {
|
|
75
|
+
const result = parseCFNSchema(sampleBucketSchema);
|
|
76
|
+
|
|
77
|
+
expect(result.resource.attributes.length).toBe(4);
|
|
78
|
+
const arn = result.resource.attributes.find((a) => a.name === "Arn");
|
|
79
|
+
expect(arn).toBeDefined();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("parses property types from definitions", () => {
|
|
83
|
+
const result = parseCFNSchema(sampleBucketSchema);
|
|
84
|
+
|
|
85
|
+
expect(result.propertyTypes.length).toBeGreaterThan(0);
|
|
86
|
+
|
|
87
|
+
const tag = result.propertyTypes.find((pt) => pt.name === "Bucket_Tag");
|
|
88
|
+
expect(tag).toBeDefined();
|
|
89
|
+
expect(tag!.properties.length).toBe(2);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("parses enum values", () => {
|
|
93
|
+
const result = parseCFNSchema(sampleBucketSchema);
|
|
94
|
+
|
|
95
|
+
// AccessControl enum should be extracted from the property
|
|
96
|
+
const accessControl = result.resource.properties.find((p) => p.name === "AccessControl");
|
|
97
|
+
expect(accessControl).toBeDefined();
|
|
98
|
+
expect(accessControl!.enum).toContain("Private");
|
|
99
|
+
expect(accessControl!.enum).toContain("PublicRead");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("parses array types", () => {
|
|
103
|
+
const result = parseCFNSchema(sampleBucketSchema);
|
|
104
|
+
|
|
105
|
+
const tags = result.resource.properties.find((p) => p.name === "Tags");
|
|
106
|
+
expect(tags).toBeDefined();
|
|
107
|
+
expect(tags!.tsType).toContain("[]");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("parses required properties", () => {
|
|
111
|
+
const result = parseCFNSchema(JSON.stringify({
|
|
112
|
+
typeName: "AWS::Test::Resource",
|
|
113
|
+
properties: {
|
|
114
|
+
Name: { type: "string" },
|
|
115
|
+
Optional: { type: "string" },
|
|
116
|
+
},
|
|
117
|
+
required: ["Name"],
|
|
118
|
+
additionalProperties: false,
|
|
119
|
+
}));
|
|
120
|
+
|
|
121
|
+
const name = result.resource.properties.find((p) => p.name === "Name");
|
|
122
|
+
const optional = result.resource.properties.find((p) => p.name === "Optional");
|
|
123
|
+
expect(name!.required).toBe(true);
|
|
124
|
+
expect(optional!.required).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("handles schema without properties", () => {
|
|
128
|
+
const result = parseCFNSchema(JSON.stringify({
|
|
129
|
+
typeName: "AWS::Test::Empty",
|
|
130
|
+
additionalProperties: false,
|
|
131
|
+
}));
|
|
132
|
+
|
|
133
|
+
expect(result.resource.typeName).toBe("AWS::Test::Empty");
|
|
134
|
+
expect(result.resource.properties).toEqual([]);
|
|
135
|
+
expect(result.resource.attributes).toEqual([]);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("cfnShortName", () => {
|
|
140
|
+
test("extracts short name from full type", () => {
|
|
141
|
+
expect(cfnShortName("AWS::S3::Bucket")).toBe("Bucket");
|
|
142
|
+
expect(cfnShortName("AWS::Lambda::Function")).toBe("Function");
|
|
143
|
+
expect(cfnShortName("AWS::IAM::Role")).toBe("Role");
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("cfnServiceName", () => {
|
|
148
|
+
test("extracts service name from full type", () => {
|
|
149
|
+
expect(cfnServiceName("AWS::S3::Bucket")).toBe("S3");
|
|
150
|
+
expect(cfnServiceName("AWS::Lambda::Function")).toBe("Lambda");
|
|
151
|
+
expect(cfnServiceName("AWS::IAM::Role")).toBe("IAM");
|
|
152
|
+
});
|
|
153
|
+
});
|