Skip to content

Commit 305a9cc

Browse files
authored
fix(sam): CfnFunction events are not rendered (#26679)
Because of a mistake introduced into the SAM schema, the `AlexaSkill` event type doesn't have any required properties anymore. When the `CfnFunction` is trying all the different event types in the type union that it supports, it will go through every type in alphabetical order and pick the first type that doesn't fail its validation. After the schema change, the first type (`Alexa` which starts with an `A`) would therefore accept all types: no required fields, and for JavaScript compatibility purposes we allow superfluous fields, and so we pick a type that doesn't render anything. This change reorders the alternatives in the union such that stronger types are tried first. `HttpApiEvent` and `AlexaSkillEvent` both have no required properties, and this now reverses the problem: `AlexaSkillEvent` can no longer be specified because `HttpApiEvent` will match first. But that's the more common use case, so better for now, while we wait for the spec fix to come in, to prefer the HTTP API. Relates to #26637. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent b551af0 commit 305a9cc

File tree

9 files changed

+264
-32
lines changed

9 files changed

+264
-32
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import * as assertions from '../../../assertions';
2+
import * as cdk from '../../../core';
3+
import * as sam from '../../lib';
4+
5+
test('generation of alts from CfnFunction', () => {
6+
const app = new cdk.App();
7+
const stack = new cdk.Stack(app, 'Stack');
8+
new sam.CfnFunction(stack, 'MyAPI', {
9+
codeUri: 'build/',
10+
events: {
11+
GetResource: {
12+
type: 'Api',
13+
properties: {
14+
restApiId: '12345',
15+
path: '/myPath',
16+
method: 'POST',
17+
},
18+
},
19+
},
20+
});
21+
22+
const template = assertions.Template.fromStack(stack);
23+
template.hasResourceProperties('AWS::Serverless::Function', {
24+
Events: {
25+
GetResource: {
26+
Properties: {
27+
Method: 'POST',
28+
Path: '/myPath',
29+
RestApiId: '12345',
30+
},
31+
},
32+
},
33+
});
34+
});

tools/@aws-cdk/spec2cdk/jest.config.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ module.exports = {
99
coverageThreshold: {
1010
global: {
1111
// Pretty bad but we disabled snapshots
12-
branches: 40,
12+
branches: 30,
1313
},
1414
},
1515
};

tools/@aws-cdk/spec2cdk/lib/cdk/cloudformation-mapping.ts

+7-2
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import {
1515
} from '@cdklabs/typewriter';
1616
import { CDK_CORE } from './cdk';
1717
import { PropertyValidator } from './property-validator';
18+
import { TypeConverter } from './type-converter';
19+
import { UnionOrdering } from './union-ordering';
1820
import { cfnParserNameFromType, cfnProducerNameFromType, cfnPropsValidatorNameFromType } from '../naming';
1921

2022
export interface PropertyMapping {
@@ -34,7 +36,7 @@ export class CloudFormationMapping {
3436
private readonly cfn2ts: Record<string, string> = {};
3537
private readonly cfn2Prop: Record<string, PropertyMapping> = {};
3638

37-
constructor(private readonly mapperFunctionsScope: IScope) {}
39+
constructor(private readonly mapperFunctionsScope: IScope, private readonly converter: TypeConverter) {}
3840

3941
public add(mapping: PropertyMapping) {
4042
this.cfn2ts[mapping.cfnName] = mapping.propName;
@@ -181,7 +183,10 @@ export class CloudFormationMapping {
181183
}
182184

183185
if (type.unionOfTypes) {
184-
const innerProducers = type.unionOfTypes.map((t) => this.typeHandlers(t));
186+
// Need access to the PropertyTypes to order these
187+
const originalTypes = type.unionOfTypes.map((t) => this.converter.originalType(t));
188+
const orderedTypes = new UnionOrdering(this.converter.db).orderTypewriterTypes(type.unionOfTypes, originalTypes);
189+
const innerProducers = orderedTypes.map((t) => this.typeHandlers(t));
185190
const validators = innerProducers.map((p) => p.validate);
186191

187192
return {

tools/@aws-cdk/spec2cdk/lib/cdk/resource-class.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ export class ResourceClass extends ClassType {
9797
*/
9898
public build() {
9999
// Build the props type
100-
const cfnMapping = new CloudFormationMapping(this.module);
100+
const cfnMapping = new CloudFormationMapping(this.module, this.converter);
101101

102102
for (const prop of this.decider.propsProperties) {
103103
this.propsType.addProperty(prop.propertySpec);

tools/@aws-cdk/spec2cdk/lib/cdk/type-converter.ts

+45-27
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@ export class TypeConverter {
7979
private readonly typeDefinitionConverter: TypeDefinitionConverter;
8080
private readonly typeDefCache = new Map<TypeDefinition, StructType>();
8181

82+
/** Reverse mapping so we can find the original type back for every generated Type */
83+
private readonly originalTypes = new WeakMap<Type, PropertyType>();
84+
8285
constructor(options: TypeConverterOptions) {
8386
this.db = options.db;
8487
this.typeDefinitionConverter = options.typeDefinitionConverter;
@@ -101,35 +104,50 @@ export class TypeConverter {
101104
return new RichProperty(property).types();
102105
}
103106

107+
/**
108+
* Convert a spec Type to a typewriter Type
109+
*/
104110
public typeFromSpecType(type: PropertyType): Type {
105-
switch (type?.type) {
106-
case 'string':
107-
return Type.STRING;
108-
case 'number':
109-
case 'integer':
110-
return Type.NUMBER;
111-
case 'boolean':
112-
return Type.BOOLEAN;
113-
case 'date-time':
114-
return Type.DATE_TIME;
115-
case 'array':
116-
return Type.arrayOf(this.typeFromSpecType(type.element));
117-
case 'map':
118-
return Type.mapOf(this.typeFromSpecType(type.element));
119-
case 'ref':
120-
const ref = this.db.get('typeDefinition', type.reference.$ref);
121-
return this.convertTypeDefinitionType(ref).type;
122-
case 'tag':
123-
return CDK_CORE.CfnTag;
124-
case 'union':
125-
return Type.unionOf(...type.types.map((t) => this.typeFromSpecType(t)));
126-
case 'null':
127-
return Type.UNDEFINED;
128-
case 'tag':
129-
return CDK_CORE.CfnTag;
130-
case 'json':
131-
return Type.ANY;
111+
const converted = ((): Type => {
112+
switch (type?.type) {
113+
case 'string':
114+
return Type.STRING;
115+
case 'number':
116+
case 'integer':
117+
return Type.NUMBER;
118+
case 'boolean':
119+
return Type.BOOLEAN;
120+
case 'date-time':
121+
return Type.DATE_TIME;
122+
case 'array':
123+
return Type.arrayOf(this.typeFromSpecType(type.element));
124+
case 'map':
125+
return Type.mapOf(this.typeFromSpecType(type.element));
126+
case 'ref':
127+
const ref = this.db.get('typeDefinition', type.reference.$ref);
128+
return this.convertTypeDefinitionType(ref).type;
129+
case 'tag':
130+
return CDK_CORE.CfnTag;
131+
case 'union':
132+
return Type.unionOf(...type.types.map((t) => this.typeFromSpecType(t)));
133+
case 'null':
134+
return Type.UNDEFINED;
135+
case 'tag':
136+
return CDK_CORE.CfnTag;
137+
case 'json':
138+
return Type.ANY;
139+
}
140+
})();
141+
this.originalTypes.set(converted, type);
142+
return converted;
143+
}
144+
145+
public originalType(type: Type): PropertyType {
146+
const ret = this.originalTypes.get(type);
147+
if (!ret) {
148+
throw new Error(`Don't know original type for ${type}`);
132149
}
150+
return ret;
133151
}
134152

135153
public convertTypeDefinitionType(ref: TypeDefinition): TypeDeclaration {

tools/@aws-cdk/spec2cdk/lib/cdk/typedefinition-struct.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export class TypeDefinitionStruct extends StructType {
4545
}
4646

4747
public build() {
48-
const cfnMapping = new CloudFormationMapping(this.module);
48+
const cfnMapping = new CloudFormationMapping(this.module, this.converter);
4949

5050
const decider = new TypeDefinitionDecider(this.resource, this.typeDefinition, this.converter);
5151

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { PropertyType, RichPropertyType, SpecDatabase, TypeDefinition } from '@aws-cdk/service-spec-types';
2+
import { Type } from '@cdklabs/typewriter';
3+
import { isSubsetOf } from '../util/sets';
4+
import { topologicalSort } from '../util/toposort';
5+
6+
/**
7+
* Order types for use in a union
8+
* Order the types such that the types with the most strength (i.e., excluding the most values from the type) are checked first
9+
*
10+
* This is necessary because at runtime, the union checker will iterate
11+
* through the types one-by-one to check whether a value inhabits a type, and
12+
* it will stop at the first one that matches.
13+
*
14+
* We therefore shouldn't have the weakest type up front, because we'd pick the wrong type.
15+
*/
16+
export class UnionOrdering {
17+
constructor(private readonly db: SpecDatabase) {}
18+
19+
/**
20+
* Order typewriter Types based on the strength of the associated PropertyTypes
21+
*/
22+
public orderTypewriterTypes(writerTypes: Type[], propTypes: PropertyType[]): Type[] {
23+
if (writerTypes.length !== propTypes.length) {
24+
throw new Error('Arrays need to be the same length');
25+
}
26+
27+
const correspondence = new Map<PropertyType, Type>();
28+
for (let i = 0; i < writerTypes.length; i++) {
29+
correspondence.set(propTypes[i], writerTypes[i]);
30+
}
31+
32+
return this.orderPropertyTypes(propTypes).map((t) => assertTruthy(correspondence.get(t)));
33+
}
34+
35+
/**
36+
* Order PropertyTypes, strongest first
37+
*/
38+
public orderPropertyTypes(types: PropertyType[]): PropertyType[] {
39+
// Map { X -> [Y] }, indicating that X is weaker than each of Y
40+
const afterMap = new Map<PropertyType, PropertyType[]>(types.map((type) => [
41+
type,
42+
types.filter((other) => !new RichPropertyType(type).equals(other) && this.strongerThan(other, type)),
43+
]));
44+
return topologicalSort(types, (t) => t, (t) => afterMap.get(t) ?? []);
45+
}
46+
47+
/**
48+
* Whether type A is strictly stronger than type B (and hence should be tried before B)
49+
*
50+
* Currently only specialized if both types are type declarations, otherwise we default
51+
* to the general kind of the type.
52+
*/
53+
private strongerThan(a: PropertyType, b: PropertyType) {
54+
const aType = a.type === 'ref' ? this.db.get('typeDefinition', a.reference.$ref) : undefined;
55+
const bType = b.type === 'ref' ? this.db.get('typeDefinition', b.reference.$ref) : undefined;
56+
if (aType && bType) {
57+
const aReq = requiredPropertyNames(aType);
58+
const bReq = requiredPropertyNames(bType);
59+
60+
// If the required properties of A are a proper supserset of B, A goes first (== B is a proper subset of A)
61+
const [aSubB, bSubA] = [isSubsetOf(aReq, bReq), isSubsetOf(bReq, aReq)];
62+
if (aSubB !== bSubA) {
63+
return bSubA;
64+
}
65+
66+
// Otherwise, the one with more required properties goes first
67+
if (aReq.size !== bReq.size) {
68+
return aReq.size > bReq.size;
69+
}
70+
71+
// Otherwise the one with the most total properties goes first
72+
return Object.keys(aType.properties).length > Object.keys(bType.properties).length;
73+
}
74+
return basicKindStrength(a) < basicKindStrength(b);
75+
76+
/**
77+
* Return an order for the kind of the type, lower is stronger.
78+
*/
79+
function basicKindStrength(x: PropertyType): number {
80+
switch (x.type) {
81+
case 'array':
82+
return 0;
83+
case 'date-time':
84+
return 1;
85+
case 'string':
86+
return 2;
87+
case 'number':
88+
case 'integer':
89+
return 3;
90+
case 'null':
91+
return 4;
92+
case 'boolean':
93+
return 5;
94+
case 'ref':
95+
return 6;
96+
case 'tag':
97+
return 7;
98+
case 'map':
99+
// Must be higher than type declaration, because they will look the same in JS
100+
return 8;
101+
case 'union':
102+
return 9;
103+
case 'json':
104+
// Must have the highest number of all
105+
return 100;
106+
}
107+
}
108+
}
109+
}
110+
111+
function requiredPropertyNames(t: TypeDefinition): Set<string> {
112+
return new Set(Object.entries(t.properties).filter(([_, p]) => p.required).map(([n, _]) => n));
113+
}
114+
115+
function assertTruthy<T>(x: T): NonNullable<T> {
116+
if (x == null) {
117+
throw new Error('Expected truhty value');
118+
}
119+
return x;
120+
}
+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/**
2+
* Whether A is a subset of B
3+
*/
4+
export function isSubsetOf<T>(as: Set<T>, bs: Set<T>) {
5+
for (const a of as) {
6+
if (!bs.has(a)) {
7+
return false;
8+
}
9+
}
10+
return true;
11+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
export type KeyFunc<T, K> = (x: T) => K;
2+
export type DepFunc<T, K> = (x: T) => K[];
3+
4+
/**
5+
* Return a topological sort of all elements of xs, according to the given dependency functions
6+
*
7+
* Dependencies outside the referenced set are ignored.
8+
*
9+
* Not a stable sort, but in order to keep the order as stable as possible, we'll sort by key
10+
* among elements of equal precedence.
11+
*/
12+
export function topologicalSort<T, K=string>(xs: Iterable<T>, keyFn: KeyFunc<T, K>, depFn: DepFunc<T, K>): T[] {
13+
const remaining = new Map<K, TopoElement<T, K>>();
14+
for (const element of xs) {
15+
const key = keyFn(element);
16+
remaining.set(key, { key, element, dependencies: depFn(element) });
17+
}
18+
19+
const ret = new Array<T>();
20+
while (remaining.size > 0) {
21+
// All elements with no more deps in the set can be ordered
22+
const selectable = Array.from(remaining.values()).filter(e => e.dependencies.every(d => !remaining.has(d)));
23+
24+
selectable.sort((a, b) => a.key < b.key ? -1 : b.key < a.key ? 1 : 0);
25+
26+
for (const selected of selectable) {
27+
ret.push(selected.element);
28+
remaining.delete(selected.key);
29+
}
30+
31+
// If we didn't make any progress, we got stuck
32+
if (selectable.length === 0) {
33+
throw new Error(`Could not determine ordering between: ${Array.from(remaining.keys()).join(', ')}`);
34+
}
35+
}
36+
37+
return ret;
38+
}
39+
40+
interface TopoElement<T, K> {
41+
key: K;
42+
dependencies: K[];
43+
element: T;
44+
}

0 commit comments

Comments
 (0)