From 64c399ed86cfb2be43d0b18c2656d1cf330fb4ce Mon Sep 17 00:00:00 2001 From: Gasser Date: Thu, 20 Nov 2025 11:39:37 +0100 Subject: [PATCH] feat: add EventBridge importer --- .../aws-service-spec/build/full-database.ts | 11 +- .../schemas/EventBridge.schema.json | 95 +++ .../src/cli/import-db.ts | 1 + .../service-spec-importers/src/db-builder.ts | 22 + .../service-spec-importers/src/db-diff.ts | 106 +++ .../service-spec-importers/src/diff-fmt.ts | 209 ++++++ .../src/event-builder.ts | 229 ++++++ .../eventbridge/event-resource-matcher.ts | 174 +++++ .../importers/eventbridge/schema-converter.ts | 308 ++++++++ .../importers/eventbridge/schema-helpers.ts | 112 +++ .../importers/import-eventbridge-schema.ts | 61 ++ .../src/loaders/load-eventbridge-schema.ts | 69 ++ .../src/printable-tree.ts | 2 +- .../types/eventbridge/EventBridgeSchema.ts | 19 + .../service-spec-importers/src/types/index.ts | 1 + .../service-spec-importers/test/event.test.ts | 692 ++++++++++++++++++ .../service-spec-types/src/types/diff.ts | 22 + 17 files changed, 2127 insertions(+), 6 deletions(-) create mode 100644 packages/@aws-cdk/service-spec-importers/schemas/EventBridge.schema.json create mode 100644 packages/@aws-cdk/service-spec-importers/src/event-builder.ts create mode 100644 packages/@aws-cdk/service-spec-importers/src/importers/eventbridge/event-resource-matcher.ts create mode 100644 packages/@aws-cdk/service-spec-importers/src/importers/eventbridge/schema-converter.ts create mode 100644 packages/@aws-cdk/service-spec-importers/src/importers/eventbridge/schema-helpers.ts create mode 100644 packages/@aws-cdk/service-spec-importers/src/importers/import-eventbridge-schema.ts create mode 100644 packages/@aws-cdk/service-spec-importers/src/loaders/load-eventbridge-schema.ts create mode 100644 packages/@aws-cdk/service-spec-importers/src/types/eventbridge/EventBridgeSchema.ts create mode 100644 packages/@aws-cdk/service-spec-importers/test/event.test.ts diff --git a/packages/@aws-cdk/aws-service-spec/build/full-database.ts b/packages/@aws-cdk/aws-service-spec/build/full-database.ts index e1186bb02..75ef254fa 100644 --- a/packages/@aws-cdk/aws-service-spec/build/full-database.ts +++ b/packages/@aws-cdk/aws-service-spec/build/full-database.ts @@ -1,11 +1,11 @@ import * as path from 'node:path'; -import { SpecDatabase } from '@aws-cdk/service-spec-types'; import { DatabaseBuilder, DatabaseBuilderOptions, ReportAudience } from '@aws-cdk/service-spec-importers'; +import { patchOobRelationships } from '@aws-cdk/service-spec-importers/src/patches/oob-relationship-patches'; +import { SpecDatabase } from '@aws-cdk/service-spec-types'; import { Augmentations } from './augmentations'; -import { Scrutinies } from './scrutinies'; -import { patchSamTemplateSpec } from './patches/sam-patches'; import { patchCloudFormationRegistry } from './patches/registry-patches'; -import { patchOobRelationships } from '@aws-cdk/service-spec-importers/src/patches/oob-relationship-patches'; +import { patchSamTemplateSpec } from './patches/sam-patches'; +import { Scrutinies } from './scrutinies'; const SOURCES = path.join(__dirname, '../../../../sources'); @@ -29,7 +29,8 @@ export class FullDatabase extends DatabaseBuilder { ) .importLogSources(path.join(SOURCES, 'LogSources/log-source-resource.json')) .importScrutinies() - .importAugmentations(); + .importAugmentations() + .importEventBridgeSchema(path.join(SOURCES, 'EventBridgeSchema')); } /** diff --git a/packages/@aws-cdk/service-spec-importers/schemas/EventBridge.schema.json b/packages/@aws-cdk/service-spec-importers/schemas/EventBridge.schema.json new file mode 100644 index 000000000..6559dad1a --- /dev/null +++ b/packages/@aws-cdk/service-spec-importers/schemas/EventBridge.schema.json @@ -0,0 +1,95 @@ +{ + "$ref": "#/definitions/EventBridgeSchema", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "EventBridgeSchema": { + "additionalProperties": false, + "description": "Root class of an EventBridge Schema", + "properties": { + "SchemaName": { + "type": "string" + }, + "Content": { + "$ref": "#/definitions/EventBridgeContent" + }, + "Description": { + "type": "string" + } + }, + "required": [ + "SchemaName", + "Content", + "Description" + ], + "type": "object" + }, + "EventBridgeContent": { + "additionalProperties": false, + "properties": { + "components": { + "$ref": "#/definitions/EventBridgeComponents" + } + }, + "required": [ + "components" + ], + "type": "object" + }, + "EventBridgeComponents": { + "additionalProperties": false, + "properties": { + "schemas": { + "$ref": "#/definitions/EventBridgeSchemas" + } + }, + "required": [ + "schemas" + ], + "type": "object" + }, + "EventBridgeSchemas": { + "additionalProperties": false, + "properties": { + "AWSEvent": { + "$ref": "#/definitions/AWSEvent" + } + }, + "required": [ + "AWSEvent" + ], + "type": "object" + }, + "AWSEvent": { + "additionalProperties": false, + "properties": { + "x-amazon-events-detail-type": { + "type": "string" + }, + "x-amazon-events-source": { + "type": "string" + }, + "properties": { + "$ref": "#/definitions/AWSEventProperties" + } + }, + "required": [ + "x-amazon-events-detail-type", + "x-amazon-events-source", + "properties" + ], + "type": "object" + }, + "AWSEventProperties": { + "additionalProperties": false, + "properties": { + "detail": { + "type": "object" + } + }, + "required": [ + "detail" + ], + "type": "object" + } + } +} diff --git a/packages/@aws-cdk/service-spec-importers/src/cli/import-db.ts b/packages/@aws-cdk/service-spec-importers/src/cli/import-db.ts index c6ace218a..e8a751264 100644 --- a/packages/@aws-cdk/service-spec-importers/src/cli/import-db.ts +++ b/packages/@aws-cdk/service-spec-importers/src/cli/import-db.ts @@ -17,6 +17,7 @@ const AVAILABLE_SOURCES: Record = { arnTemplates: 'importArnTemplates', oobRelationships: 'importOobRelationships', logSources: 'importLogSources', + eventbridgeschema: 'importEventBridgeSchema', }; async function main() { diff --git a/packages/@aws-cdk/service-spec-importers/src/db-builder.ts b/packages/@aws-cdk/service-spec-importers/src/db-builder.ts index 4cb5f0afb..3a6fd67c6 100644 --- a/packages/@aws-cdk/service-spec-importers/src/db-builder.ts +++ b/packages/@aws-cdk/service-spec-importers/src/db-builder.ts @@ -5,6 +5,7 @@ import { importArnTemplates } from './importers/import-arn-templates'; import { importCannedMetrics } from './importers/import-canned-metrics'; import { importCloudFormationDocumentation } from './importers/import-cloudformation-docs'; import { importCloudFormationRegistryResource } from './importers/import-cloudformation-registry'; +import { importEventBridgeSchema } from './importers/import-eventbridge-schema'; import { importGetAttAllowList } from './importers/import-getatt-allowlist'; import { importLogSources } from './importers/import-log-source'; import { importOobRelationships } from './importers/import-oob-relationships'; @@ -23,6 +24,7 @@ import { loadSamSpec, loadOobRelationships, } from './loaders'; +import { loadDefaultEventBridgeSchema } from './loaders/load-eventbridge-schema'; import { JsonLensPatcher } from './patching'; import { ProblemReport, ReportAudience } from './report'; @@ -217,6 +219,26 @@ export class DatabaseBuilder { }); } + public importEventBridgeSchema(schemaDirectory: string) { + return this.addSourceImporter(async (db, report) => { + const regions = await loadDefaultEventBridgeSchema(schemaDirectory, { + ...this.options, + report, + failureAudience: this.defaultProblemGrouping, + }); + for (const region of regions) { + for (const event of region.events) { + importEventBridgeSchema({ + db, + event, + report, + region: region.regionName, + }); + } + } + }); + } + /** * Look at a load result and report problems */ diff --git a/packages/@aws-cdk/service-spec-importers/src/db-diff.ts b/packages/@aws-cdk/service-spec-importers/src/db-diff.ts index 8ea03aa5d..efa4f4163 100644 --- a/packages/@aws-cdk/service-spec-importers/src/db-diff.ts +++ b/packages/@aws-cdk/service-spec-importers/src/db-diff.ts @@ -16,6 +16,12 @@ import { UpdatedService, UpdatedTypeDefinition, MapDiff, + Event, + EventTypeDefinition, + EventProperty, + UpdatedEvent, + UpdatedEventTypeDefinition, + UpdatedEventProperty, } from '@aws-cdk/service-spec-types'; import { diffByKey, @@ -115,6 +121,7 @@ export class DbDiff { properties: collapseEmptyDiff(diffMap(a.properties, b.properties, (x, y) => this.diffProperty(x, y))), typeDefinitionDiff: this.diffResourceTypeDefinitions(a, b), metrics: this.diffResourceMetrics(a, b), + events: this.diffResourceEvents(a, b), } satisfies AllFieldsGiven); } @@ -177,6 +184,105 @@ export class DbDiff { } satisfies AllFieldsGiven); } + public diffResourceEvents(a: Resource, b: Resource): UpdatedResource['events'] { + const aEvents = this.db1.follow('resourceHasEvent', a).map((r) => r.entity); + const bEvents = this.db2.follow('resourceHasEvent', b).map((r) => r.entity); + + return collapseEmptyDiff( + diffByKey( + aEvents, + bEvents, + (event) => event.name, + (x, y) => this.diffEvent(x, y), + ), + ); + } + + public diffEvent(a: Event, b: Event): UpdatedEvent | undefined { + return collapseUndefined({ + name: diffScalar(a, b, 'name'), + description: diffScalar(a, b, 'description'), + source: diffScalar(a, b, 'source'), + detailType: diffScalar(a, b, 'detailType'), + resourcesField: diffField(a, b, 'resourcesField', jsonEq), + rootProperty: diffField(a, b, 'rootProperty', jsonEq), + typeDefinitionDiff: this.diffEventTypeDefinitions(a, b), + } satisfies AllFieldsGiven); + } + + public diffEventTypeDefinitions(a: Event, b: Event): UpdatedEvent['typeDefinitionDiff'] { + const aTypes = this.db1.follow('eventUsesType', a).map((r) => r.entity); + const bTypes = this.db2.follow('eventUsesType', b).map((r) => r.entity); + + return collapseEmptyDiff( + diffByKey( + aTypes, + bTypes, + (type) => type.name, + (x, y) => this.diffEventTypeDefinition(x, y), + ), + ); + } + + public diffEventTypeDefinition( + a: EventTypeDefinition, + b: EventTypeDefinition, + ): UpdatedEventTypeDefinition | undefined { + return collapseUndefined({ + name: diffScalar(a, b, 'name'), + properties: collapseEmptyDiff(diffMap(a.properties, b.properties, (x, y) => this.diffEventProperty(x, y))), + } satisfies AllFieldsGiven); + } + + public diffEventProperty(a: EventProperty, b: EventProperty): UpdatedEventProperty | undefined { + const anyDiffs = collapseUndefined({ + required: diffScalar(a, b, 'required', false), + type: diffField(a, b, 'type', (x, y) => this.eqEventPropertyType(x, y)), + } satisfies DontCareAboutTypes>); + + if (anyDiffs) { + return { old: a, new: b }; + } + return undefined; + } + + /** + * Compare event property types by stringifying them. + * Event property types reference EventTypeDefinition, not TypeDefinition. + */ + private eqEventPropertyType(a: EventProperty['type'], b: EventProperty['type']): boolean { + return this.stringifyGenericType(a, this.db1) === this.stringifyGenericType(b, this.db2); + } + + /** + * Stringify a generic property type for comparison. + */ + private stringifyGenericType(type: EventProperty['type'], db: SpecDatabase): string { + if (type.type === 'string') return 'string'; + if (type.type === 'number') return 'number'; + if (type.type === 'integer') return 'integer'; + if (type.type === 'boolean') return 'boolean'; + if (type.type === 'json') return 'json'; + if (type.type === 'date-time') return 'date-time'; + if (type.type === 'null') return 'null'; + if (type.type === 'tag') return 'tag'; + if (type.type === 'ref') { + const entity = db.get('eventTypeDefinition', type.reference.$ref); + return `ref:${entity.name}`; + } + if (type.type === 'array') { + return `array<${this.stringifyGenericType(type.element, db)}>`; + } + if (type.type === 'map') { + return `map<${this.stringifyGenericType(type.element, db)}>`; + } + if (type.type === 'union') { + const types = type.types.map((t) => this.stringifyGenericType(t, db)).sort(); + return `union<${types.join('|')}>`; + } + return 'unknown'; + } + /** * Tricky -- we have to deep-compare all the type references which will have different ids in * different databases. diff --git a/packages/@aws-cdk/service-spec-importers/src/diff-fmt.ts b/packages/@aws-cdk/service-spec-importers/src/diff-fmt.ts index b9bcb92b6..f4cd04f50 100644 --- a/packages/@aws-cdk/service-spec-importers/src/diff-fmt.ts +++ b/packages/@aws-cdk/service-spec-importers/src/diff-fmt.ts @@ -17,6 +17,12 @@ import { UpdatedService, UpdatedTypeDefinition, VendedLog, + Event, + EventTypeDefinition, + EventProperty, + UpdatedEvent, + UpdatedEventTypeDefinition, + UpdatedEventProperty, } from '@aws-cdk/service-spec-types'; import chalk from 'chalk'; import { PrintableTree } from './printable-tree'; @@ -125,6 +131,12 @@ export class DiffFormatter { .sort(sortByKey((e) => e.entity.name)) .map((e) => this.renderMetric(e.entity).prefix([' '])), ), + listWithCaption( + 'events', + [...this.dbs[db].follow('resourceHasEvent', r)] + .sort(sortByKey((e) => e.entity.name)) + .map((e) => this.renderEvent(e.entity, db).prefix([' '])), + ), ]); } @@ -161,6 +173,14 @@ export class DiffFormatter { (k, u) => this.renderUpdatedMetric(k, u).prefix([' ']), ), ), + listWithCaption( + 'events', + this.renderMapDiff( + r.events, + (e, db) => this.renderEvent(e, db).prefix([' ']), + (k, u) => this.renderUpdatedEvent(k, u).prefix([' ']), + ), + ), ]); } @@ -365,6 +385,195 @@ export class DiffFormatter { } } } + + private renderEvent(e: Event, db: number): PrintableTree { + const propsTree = new PrintableTree( + `description: ${render(e.description)}`, + `source: ${render(e.source)}`, + `detailType: ${render(e.detailType)}`, + ); + + const rootTypeDef = this.dbs[db].get('eventTypeDefinition', e.rootProperty.$ref); + propsTree.emit(`\nrootProperty: ${rootTypeDef.name}`); + + if (e.resourcesField && e.resourcesField.length > 0) { + const resourceFields = e.resourcesField + .map((rf) => { + const typeDef = this.dbs[db].get('eventTypeDefinition', rf.type.$ref); + return rf.fieldName ? `${typeDef.name}.${rf.fieldName}` : typeDef.name; + }) + .join(', '); + propsTree.emit(`\nresourcesField: [${resourceFields}]`); + } + + return new PrintableTree(`event ${e.name}`).addBullets([ + propsTree.indent(META_INDENT), + listWithCaption( + 'types', + [...this.dbs[db].follow('eventUsesType', e)] + .sort(sortByKey((t) => t.entity.name)) + .map((t) => this.renderEventTypeDefinition(t.entity, db).prefix([' '])), + ), + ]); + } + + private renderUpdatedEvent(key: string, e: UpdatedEvent): PrintableTree { + const bullets: PrintableTree[] = []; + + const simpleDiffs = pick(e, ['name', 'description', 'source', 'detailType']); + const simpleTree = new PrintableTree(...listFromDiffs(simpleDiffs)); + + if (e.rootProperty && e.rootProperty.old && e.rootProperty.new) { + const oldTypeDef = this.dbs[OLD_DB].get('eventTypeDefinition', e.rootProperty.old.$ref); + const newTypeDef = this.dbs[NEW_DB].get('eventTypeDefinition', e.rootProperty.new.$ref); + simpleTree.emit(`\n${chalk.red(`- rootProperty: ${oldTypeDef.name}`)}`); + simpleTree.emit(`\n${chalk.green(`+ rootProperty: ${newTypeDef.name}`)}`); + } + + if (e.resourcesField) { + if (e.resourcesField.old && e.resourcesField.old.length > 0) { + const oldFields = e.resourcesField.old + .map((rf) => { + const typeDef = this.dbs[OLD_DB].get('eventTypeDefinition', rf.type.$ref); + return rf.fieldName ? `${typeDef.name}.${rf.fieldName}` : typeDef.name; + }) + .join(', '); + simpleTree.emit(`\n${chalk.red(`- resourcesField: [${oldFields}]`)}`); + } + if (e.resourcesField.new && e.resourcesField.new.length > 0) { + const newFields = e.resourcesField.new + .map((rf) => { + const typeDef = this.dbs[NEW_DB].get('eventTypeDefinition', rf.type.$ref); + return rf.fieldName ? `${typeDef.name}.${rf.fieldName}` : typeDef.name; + }) + .join(', '); + simpleTree.emit(`\n${chalk.green(`+ resourcesField: [${newFields}]`)}`); + } + } + + bullets.push(simpleTree.indent(META_INDENT)); + + bullets.push( + listWithCaption( + 'types', + this.renderMapDiff( + e.typeDefinitionDiff, + (t, db) => this.renderEventTypeDefinition(t, db).prefix([' ']), + (k, u) => this.renderUpdatedEventTypeDefinition(k, u), + ), + ), + ); + + return new PrintableTree(`event ${key}`).addBullets(bullets); + } + + private renderEventTypeDefinition(t: EventTypeDefinition, db: number): PrintableTree { + return new PrintableTree(`type ${t.name}`).addBullets([ + listWithCaption('properties', this.renderEventProperties(t.properties, db)), + ]); + } + + private renderUpdatedEventTypeDefinition(key: string, t: UpdatedEventTypeDefinition): PrintableTree { + const d = pick(t, ['name']); + return new PrintableTree(`type ${key}`).addBullets([ + new PrintableTree(...listFromDiffs(d)).indent(META_INDENT), + listWithCaption('properties', this.renderEventPropertyDiff(t.properties)), + ]); + } + + private renderEventProperty(p: EventProperty, db: number): PrintableTree { + const ret = new PrintableTree(); + ret.emit(this.stringifyEventPropertyType(p.type, this.dbs[db])); + + const attributes = []; + if (p.required) { + attributes.push('required'); + } + + if (attributes.length) { + ret.emit(` (${attributes.join(', ')})`); + } + + return ret; + } + + private stringifyEventPropertyType(type: EventProperty['type'], db: SpecDatabase): string { + if (type.type === 'string') return 'string'; + if (type.type === 'number') return 'number'; + if (type.type === 'integer') return 'integer'; + if (type.type === 'boolean') return 'boolean'; + if (type.type === 'json') return 'json'; + if (type.type === 'date-time') return 'date-time'; + if (type.type === 'null') return 'null'; + if (type.type === 'tag') return 'tag'; + if (type.type === 'ref') { + const entity = db.get('eventTypeDefinition', type.reference.$ref); + return entity.name; + } + if (type.type === 'array') { + return `${this.stringifyEventPropertyType(type.element, db)}[]`; + } + if (type.type === 'map') { + return `Map<${this.stringifyEventPropertyType(type.element, db)}>`; + } + if (type.type === 'union') { + const types = type.types.map((t) => this.stringifyEventPropertyType(t, db)); + return types.join(' | '); + } + return 'unknown'; + } + + private renderEventProperties(ps: Record, db: number): PrintableTree[] { + return Object.entries(ps).map(([name, p]) => this.renderEventProperty(p, db).prefix([' ', `${name}: `])); + } + + private renderEventPropertyDiff(diff?: MapDiff): PrintableTree[] { + if (!diff) { + return []; + } + + const keys = Array.from( + new Set([ + ...Object.keys(diff.added ?? {}), + ...Object.keys(diff.removed ?? {}), + ...Object.keys(diff.updated ?? {}), + ]), + ); + keys.sort((a, b) => a.localeCompare(b)); + + return keys.flatMap((key) => { + const header = [' ', key, ': ']; + const rest = [' ', ' '.repeat(key.length), ' ']; + + if (diff.added?.[key]) { + return [ + this.renderEventProperty(diff.added?.[key]!, NEW_DB).prefix( + [chalk.green(ADDITION), ...header], + [' ', ...rest], + ), + ]; + } else if (diff.removed?.[key]) { + return [ + this.renderEventProperty(diff.removed?.[key]!, OLD_DB).prefix( + [chalk.red(REMOVAL), ...header], + [' ', ...rest], + ), + ]; + } else if (diff.updated?.[key]) { + const pu = diff.updated?.[key]!; + const old = this.renderEventProperty(pu.old, OLD_DB); + const noo = this.renderEventProperty(pu.new, NEW_DB); + + const ret = new PrintableTree(); + if (old.toString() !== noo.toString()) { + ret.addTree(old.prefix(['- ']).colorize(chalk.red)); + ret.addTree(noo.prefix(['+ ']).colorize(chalk.green)); + } + return ret.prefix([` ${key}: `]); + } + return new PrintableTree(); + }); + } } function listFromProps(a: A, ks: K[]) { diff --git a/packages/@aws-cdk/service-spec-importers/src/event-builder.ts b/packages/@aws-cdk/service-spec-importers/src/event-builder.ts new file mode 100644 index 000000000..8ef0b2e2d --- /dev/null +++ b/packages/@aws-cdk/service-spec-importers/src/event-builder.ts @@ -0,0 +1,229 @@ +import { + EventProperties, + EventProperty, + SpecDatabase, + Event, + EventTypeDefinition, + ResourceField, + Resource, +} from '@aws-cdk/service-spec-types'; +import { jsonschema } from './types'; + +export interface EventBuilderOptions { + readonly source: string; + readonly detailType: string; + readonly description: string; +} + +interface ObjectWithProperties { + properties: EventProperties; +} + +export class PropertyBagBuilder { + protected candidateProperties: EventProperties = {}; + protected resourcesField: Array = []; + + constructor(private readonly _propertyBag: ObjectWithProperties) {} + + public setProperty(name: string, prop: EventProperty) { + this.candidateProperties[name] = prop; + } + + protected addResourceField(resourceField: ResourceField) { + this.resourcesField.push(resourceField); + } + + /** + * Commit the property and attribute changes to the underlying property bag. + */ + protected commitProperties(): ObjectWithProperties { + for (const [name, prop] of Object.entries(this.candidateProperties)) { + this.commitProperty(name, prop); + } + + if ('resourcesField' in this._propertyBag && this.resourcesField.length > 0) { + (this._propertyBag as any).resourcesField = this.resourcesField; + } + + return this._propertyBag; + } + + private commitProperty(name: string, prop: EventProperty) { + if (this._propertyBag.properties[name]) { + this.mergeProperty(this._propertyBag.properties[name], prop); + } else { + this._propertyBag.properties[name] = prop; + } + this.simplifyProperty(this._propertyBag.properties[name]); + } + + protected mergeProperty(prop: EventProperty, updates: EventProperty) { + if (updates.required !== undefined) { + prop.required = updates.required; + } + + // Update type - EventProperty doesn't support previousTypes + if (updates.type) { + prop.type = updates.type; + } + } + + /** + * Remove settings that are equal to their defaults + */ + protected simplifyProperty(prop: EventProperty) { + if (!prop.required) { + delete prop.required; + } + } +} + +export class EventBuilder extends PropertyBagBuilder { + public static allocateEvent( + db: SpecDatabase, + schemaName: string, + options: EventBuilderOptions & { rootProperty: EventTypeDefinition }, + ): EventBuilder { + const existing = db.lookup('event', 'name', 'equals', schemaName); + + if (existing.length > 0) { + throw new Error('Two events has the same exact name'); + } + + const event = db.allocate('event', { + name: schemaName, + source: options.source, + detailType: options.detailType, + description: options.description, + rootProperty: { $ref: options.rootProperty.$id }, + resourcesField: [], + }); + + return new EventBuilder(db, schemaName, options, event); + } + + public static createBuilder(db: SpecDatabase, schemaName: string, options: EventBuilderOptions): EventBuilder { + const existing = db.lookup('event', 'name', 'equals', schemaName); + + if (existing.length > 0) { + throw new Error('Two events has the same exact name'); + } + + return new EventBuilder(db, schemaName, options, null); + } + + private eventTypeDefinitions = new Map(); + private typesCreatedHere = new Set(); + private typesToLink: EventTypeDefinition[] = []; + private resourcesToLink: Array = []; + + private constructor( + public readonly db: SpecDatabase, + private readonly schemaName: string, + private readonly options: EventBuilderOptions, + private event: Event | null, + ) { + super((event || { properties: {} }) as any); + } + + public allocateEvent(rootProperty: EventTypeDefinition): void { + if (this.event) { + throw new Error('Event already allocated'); + } + + this.event = this.db.allocate('event', { + name: this.schemaName, + source: this.options.source, + detailType: this.options.detailType, + description: this.options.description, + rootProperty: { $ref: rootProperty.$id }, + resourcesField: [], + }); + + (this as any)._propertyBag = this.event; + } + + public linkTypesToEvent(typeDef: EventTypeDefinition) { + this.typesToLink.push(typeDef); + this.eventTypeDefinitions.set(typeDef.name, typeDef); + } + + public linkResourceToEvent(resource: Resource, resourceField: ResourceField) { + this.resourcesToLink.push(resource); + this.addResourceField(resourceField); + } + + public eventTypeDefinitionBuilder( + typeName: string, + options?: { description?: string; schema?: jsonschema.RecordLikeObject }, + ) { + const existing = this.eventTypeDefinitions.get(typeName); + const freshInSession = !this.typesCreatedHere.has(typeName); + this.typesCreatedHere.add(typeName); + + if (existing) { + const properties = options?.schema?.properties ?? {}; + // If db already contains typeName's type definition, we want to additionally + // check if the schema matches the type definition. If the schema includes new + // properties, we want to add them to the type definition. + if (!Object.keys(properties).every((element) => Object.keys(existing.properties).includes(element))) { + return { + eventTypeDefinitionBuilder: new EventTypeDefinitionBuilder(this.db, existing), + freshInDb: true, + freshInSession: true, + }; + } + return { + eventTypeDefinitionBuilder: new EventTypeDefinitionBuilder(this.db, existing), + freshInDb: false, + freshInSession, + }; + } + + const typeDef = this.db.allocate('eventTypeDefinition', { + name: typeName, + properties: {}, + }); + + // Link to event if it's already allocated + if (this.event) { + this.db.link('eventUsesType', this.event, typeDef); + } + + this.eventTypeDefinitions.set(typeName, typeDef); + + const builder = new EventTypeDefinitionBuilder(this.db, typeDef); + return { eventTypeDefinitionBuilder: builder, freshInDb: true, freshInSession }; + } + + public commit(): Event { + if (!this.event) { + throw new Error('Cannot commit before event is allocated'); + } + + if (this.resourcesField.length > 0) { + (this.event as any).resourcesField = this.resourcesField; + } + + for (const typeDef of this.typesToLink) { + this.db.link('eventUsesType', this.event, typeDef); + } + + for (const resource of this.resourcesToLink) { + this.db.link('resourceHasEvent', resource, this.event); + } + + return this.event; + } +} + +export class EventTypeDefinitionBuilder extends PropertyBagBuilder { + constructor(public readonly db: SpecDatabase, private readonly typeDef: EventTypeDefinition) { + super(typeDef); + } + + public commit(): EventTypeDefinition { + this.commitProperties(); + return this.typeDef; + } +} diff --git a/packages/@aws-cdk/service-spec-importers/src/importers/eventbridge/event-resource-matcher.ts b/packages/@aws-cdk/service-spec-importers/src/importers/eventbridge/event-resource-matcher.ts new file mode 100644 index 000000000..4eef66859 --- /dev/null +++ b/packages/@aws-cdk/service-spec-importers/src/importers/eventbridge/event-resource-matcher.ts @@ -0,0 +1,174 @@ +import { + Service, + SpecDatabase, + Event, + Resource, + EventTypeDefinition, + ResourceField, +} from '@aws-cdk/service-spec-types'; +import { ref } from '@cdklabs/tskb'; + +/** + * Find the CloudFormation resource that matches an event + */ +export function lookupResource({ + db, + service, + eventSchemaName, +}: { + db: SpecDatabase; + service: Service; + eventSchemaName: string; +}): ResourceMatch | undefined { + const event = db.lookup('event', 'name', 'equals', eventSchemaName).only(); + return eventDecider({ service, db, event }); +} + +/** + * Find the CloudFormation service that matches an event + */ +export function lookupService({ + eventSchemaName, + eventNameSeparator = '@', + db, +}: { + eventSchemaName: string; + eventNameSeparator?: string; + db: SpecDatabase; +}): Service | undefined { + const serviceName = parseServiceName({ eventSchemaName, eventNameSeparator }); + + const services = db.lookup('service', 'name', 'equals', serviceName); + + if (services.length == 0) { + // TODO: we need to report here + return; + } + + return services.only(); +} + +/** + * Decide which CloudFormation resource matches an event + */ +function eventDecider({ + db, + service, + event, +}: { + db: SpecDatabase; + service: Service; + event: Event; +}): ResourceMatch | undefined { + const typeInfos = extractRequiredEventFields(db, event); + + const resources = db.follow('hasResource', service).map((resource) => resource.entity); + + const resourceMatches = matchTypeFieldsToResources(resources, typeInfos); + + if (resourceMatches.length > 0) { + // TODO: remove this + console.log(`Resources Matches = ${resourceMatches.length}`); + return { resource: resourceMatches[0].resource, matches: resourceMatches[0].matches }; + } else if (resourceMatches.length == 0) { + // TODO: change this to report + console.log(`Event schema name: ${event.name}, doesn't match any resource in cloudformation`); + } + + return undefined; +} + +/** + * Match event fields name and CloudFormation resources name + */ +function matchTypeFieldsToResources(resources: Resource[], typeInfos: EventTypeDefinition[]): ResourceMatch[] { + const resourceMatches: ResourceMatch[] = []; + + for (const resource of resources) { + const matches: Array = []; + const resourceName = resource.name.toLowerCase(); + + for (const typeFieldName of typeInfos) { + const typeSegment = normalizeName(typeFieldName.name); + + if (typeSegment == resourceName) { + matches.push({ + type: ref(typeFieldName), + }); + } + + for (const propertiesFieldName of Object.keys(typeFieldName.properties)) { + const fieldSegment = normalizeName(propertiesFieldName); + + if (fieldSegment == resourceName) { + matches.push({ + type: ref(typeFieldName), + fieldName: propertiesFieldName, + }); + } + } + } + + if (matches.length > 0) { + if (matches.length > 1) { + //TODO: 17 events affected by this, some of them has resourceId & resourceName, some has in multiple levels the resource + console.log('here we are', { resource, matches: JSON.stringify(matches, null, 2) }); + } + resourceMatches.push({ + resource, + matches, + }); + } + } + + return resourceMatches; +} + +function extractRequiredEventFields(db: SpecDatabase, event: Event): EventTypeDefinition[] { + const typeDefinitions = db.follow('eventUsesType', event); + + return typeDefinitions + .map((x) => { + return { + ...x.entity, + properties: Object.fromEntries( + Object.entries(x.entity.properties).filter(([_key, value]) => value.required === true), + ), + }; + }) + .filter((a) => Object.keys(a.properties).length != 0); +} + +/** + * Service schema name e.g. "aws.s3@ObjectCreated" returns "aws-s3" + */ +function parseServiceName({ + eventSchemaName, + eventNameSeparator = '@', +}: { + eventSchemaName: string; + eventNameSeparator: string; +}) { + const schemaNameParts = eventSchemaName.split(eventNameSeparator); + const serviceName = schemaNameParts[0].replace('.', '-').toLowerCase(); + return serviceName; +} + +/** + * Filters out generic identifiers (name, id, arn) from name + */ +function normalizeName(name: string): string { + const segments = convertToSnakeCase(name).split(/[-_]/); + + const genericIds = new Set(['name', 'id', 'arn']); + return segments.filter((s) => s.length > 0 && !genericIds.has(s)).join(''); +} + +function convertToSnakeCase(str: string): string { + return str.replace(/([A-Z])/g, '_$1').toLowerCase(); +} + +export interface ResourceMatch { + readonly resource: Resource; + readonly matches: Array; +} diff --git a/packages/@aws-cdk/service-spec-importers/src/importers/eventbridge/schema-converter.ts b/packages/@aws-cdk/service-spec-importers/src/importers/eventbridge/schema-converter.ts new file mode 100644 index 000000000..b08113cee --- /dev/null +++ b/packages/@aws-cdk/service-spec-importers/src/importers/eventbridge/schema-converter.ts @@ -0,0 +1,308 @@ +import { PropertyType, EventTypeDefinition } from '@aws-cdk/service-spec-types'; +import { locateFailure, Fail, isFailure, Result, tryCatch, using, ref, isSuccess } from '@cdklabs/tskb'; +import { + calculateDefinitelyRequired, + collectionNameHint, + isEmptyObjectType, + removeUnionDuplicates, +} from './schema-helpers'; +import { PropertyBagBuilder } from '../../event-builder'; +import { BoundProblemReport } from '../../report'; +import { unionSchemas } from '../../schema-manipulation/unify-schemas'; +import { maybeUnion } from '../../type-manipulation'; +import { ImplicitJsonSchemaRecord, jsonschema } from '../../types'; + +export interface SchemaConverterContext { + eventBuilder: import('../../event-builder').EventBuilder; + report: BoundProblemReport; + resolve: (schema: any) => any; + eventFailure: Fail; + createdTypes?: EventTypeDefinition[]; +} + +/** + * Create all type definitions from an EventBridge detail schema + * Handles both empty object types and types with properties + * + * @returns Object containing the root type definition and all created types + */ +export function createTypeDefinitionsFromSchema( + detailSchema: any, + detailTypeName: string, + ctx: SchemaConverterContext, +): { rootTypeDef: EventTypeDefinition; createdTypes: EventTypeDefinition[] } { + const createdTypes: EventTypeDefinition[] = []; + const ctxWithTracking = { ...ctx, createdTypes }; + + let rootTypeDef: EventTypeDefinition; + + if (isEmptyObjectType(detailSchema)) { + // For empty object types, create a simple type definition + const { eventTypeDefinitionBuilder } = ctx.eventBuilder.eventTypeDefinitionBuilder(detailTypeName); + rootTypeDef = eventTypeDefinitionBuilder.commit(); + createdTypes.push(rootTypeDef); + } else if (detailSchema.properties) { + // Create the root type definition and all nested types + const { eventTypeDefinitionBuilder } = ctx.eventBuilder.eventTypeDefinitionBuilder(detailTypeName, { + schema: detailSchema, + }); + + // Recurse into the detail type's properties to build ALL type definitions + recurseProperties(detailSchema, eventTypeDefinitionBuilder, ctx.eventFailure, ctxWithTracking); + + // Commit the root type definition + rootTypeDef = eventTypeDefinitionBuilder.commit(); + createdTypes.push(rootTypeDef); + } else { + // Unexpected case: not an object with properties and not an empty object + ctx.report.reportFailure( + 'interpreting', + ctx.eventFailure(`Detail type has unexpected structure: ${JSON.stringify(detailSchema)}`), + ); + throw new Error('Invalid detail schema structure'); + } + + return { rootTypeDef, createdTypes }; +} + +/** + * Recursively process schema properties and create type definitions + */ +export function recurseProperties( + source: ImplicitJsonSchemaRecord, + target: PropertyBagBuilder, + fail: Fail, + ctx: SchemaConverterContext, +) { + if (!source.properties) { + throw new Error(`Not an object type with properties: ${JSON.stringify(source)}`); + } + + const required = calculateDefinitelyRequired(source); + + for (const [name, property] of Object.entries(source.properties)) { + try { + let resolvedSchema = ctx.resolve(property); + withResult(ctx, schemaTypeToModelType(name, resolvedSchema, fail.in(`property ${name}`), ctx), (type) => { + target.setProperty(name, { + type, + required: required.has(name), + }); + }); + } catch (e) { + ctx.report.reportFailure('interpreting', fail(`Skip generating property ${name} because of ${e}`)); + } + } +} + +/** + * Convert schema type to model type, creating all nested type definitions + */ +export function schemaTypeToModelType( + propertyName: string, + resolvedSchema: jsonschema.ResolvedSchema, + fail: Fail, + ctx: SchemaConverterContext, +): Result { + return tryCatch(fail, (): Result => { + const reference = jsonschema.resolvedReference(resolvedSchema); + const referenceName = jsonschema.resolvedReferenceName(resolvedSchema); + const nameHint = referenceName ? referenceName : propertyName; + + if (jsonschema.isAnyType(resolvedSchema)) { + return { type: 'json' }; + } else if (jsonschema.isOneOf(resolvedSchema) || jsonschema.isAnyOf(resolvedSchema)) { + const inner = jsonschema.innerSchemas(resolvedSchema); + + if (reference && inner.every((s) => jsonschema.isObject(s))) { + ctx.report.reportFailure( + 'interpreting', + fail(`Ref ${referenceName} is a union of objects. Merging into a single type.`), + ); + const combinedType = unionSchemas(...inner) as jsonschema.ConcreteSchema; + if (isFailure(combinedType)) { + return combinedType; + } + return schemaTypeToModelType(nameHint, jsonschema.setResolvedReference(combinedType, reference), fail, ctx); + } + + validateCombiningSchemaType(inner, fail, ctx); + + const convertedTypes = inner.map((t) => { + if (jsonschema.isObject(t) && jsonschema.isRecordLikeObject(t)) { + const refName = jsonschema.resolvedReferenceName(t); + if ((t.title && t.required?.includes(t.title)) || (refName && t.required?.includes(refName))) { + ctx.report.reportFailure( + 'interpreting', + fail( + `${propertyName} is a union of objects. Merging into a single type and removing required fields for oneOf and anyOf.`, + ), + ); + return schemaTypeToModelType(nameHint, ctx.resolve({ ...t, required: undefined }), fail, ctx); + } + } + return schemaTypeToModelType(nameHint, ctx.resolve(t), fail, ctx); + }); + ctx.report.reportFailure('interpreting', ...convertedTypes.filter(isFailure)); + + const types = convertedTypes.filter(isSuccess); + removeUnionDuplicates(types); + + return maybeUnion(types); + } else if (jsonschema.isAllOf(resolvedSchema)) { + const firstResolved = resolvedSchema.allOf[0]; + return schemaTypeToModelType(nameHint, ctx.resolve(firstResolved), fail, ctx); + } else if (jsonschema.containsRelationship(resolvedSchema)) { + return { type: 'string' }; + } else { + switch (resolvedSchema.type) { + case 'string': + if (resolvedSchema.format === 'timestamp') { + return { type: 'date-time' }; + } + return { type: 'string' }; + + case 'array': + return using( + schemaTypeToModelType(collectionNameHint(nameHint), ctx.resolve(resolvedSchema.items ?? true), fail, ctx), + (element) => ({ + type: 'array', + element, + }), + ); + + case 'boolean': + return { type: 'boolean' }; + + case 'object': + return schemaObjectToModelType(nameHint, resolvedSchema, fail, ctx); + + case 'number': + return { type: 'number' }; + + case 'integer': + return { type: 'integer' }; + + case 'null': + return { type: 'null' }; + } + } + + throw new Error('Unable to produce type'); + }); +} + +function schemaObjectToModelType( + nameHint: string, + schema: jsonschema.Object, + fail: Fail, + ctx: SchemaConverterContext, +): Result { + if (jsonschema.isMapLikeObject(schema)) { + return mapLikeSchemaToModelType(nameHint, schema, fail, ctx); + } else { + return objectLikeSchemaToModelType(nameHint, schema, fail, ctx); + } +} + +function mapLikeSchemaToModelType( + nameHint: string, + schema: jsonschema.MapLikeObject, + fail: Fail, + ctx: SchemaConverterContext, +): Result { + const innerNameHint = collectionNameHint(nameHint); + + if (schema.patternProperties) { + if (schema.additionalProperties === true) { + ctx.report.reportFailure( + 'interpreting', + fail('additionalProperties: true is probably a mistake if patternProperties is also present'), + ); + } + + const unifiedPatternProps = fail.locate( + locateFailure('patternProperties')( + unionSchemas( + ...Object.values(schema.patternProperties), + ...(schema.additionalProperties && schema.additionalProperties !== true ? [schema.additionalProperties] : []), + ), + ), + ); + + return using(unifiedPatternProps, (unifiedType) => + using(schemaTypeToModelType(innerNameHint, ctx.resolve(unifiedType), fail, ctx), (element) => ({ + type: 'map', + element, + })), + ); + } else if (schema.additionalProperties) { + return using( + schemaTypeToModelType(innerNameHint, ctx.resolve(schema.additionalProperties), fail, ctx), + (element) => ({ + type: 'map', + element, + }), + ); + } + + return { type: 'json' }; +} + +function objectLikeSchemaToModelType( + nameHint: string, + schema: jsonschema.RecordLikeObject, + fail: Fail, + ctx: SchemaConverterContext, +): Result { + const { eventTypeDefinitionBuilder, freshInSession } = ctx.eventBuilder.eventTypeDefinitionBuilder(nameHint, { + schema, + }); + + if (freshInSession) { + if (jsonschema.isRecordLikeObject(schema)) { + recurseProperties(schema, eventTypeDefinitionBuilder, fail.in(`typedef ${nameHint}`), ctx); + } + } + + const typeDef = eventTypeDefinitionBuilder.commit(); + + if (ctx.createdTypes) { + ctx.createdTypes.push(typeDef); + } + + return { type: 'ref', reference: ref(typeDef) }; +} + +function validateCombiningSchemaType(schema: jsonschema.ConcreteSchema[], fail: Fail, ctx: SchemaConverterContext) { + schema.forEach((element, index) => { + if (!jsonschema.isAnyType(element) && !jsonschema.isCombining(element)) { + schema.slice(index + 1).forEach((next) => { + if (!jsonschema.isAnyType(next) && !jsonschema.isCombining(next)) { + if (element.title === next.title && element.type !== next.type) { + ctx.report.reportFailure( + 'interpreting', + fail(`Invalid schema with property name ${element.title} but types ${element.type} and ${next.type}`), + ); + } + const elementName = jsonschema.resolvedReferenceName(element); + const nextName = jsonschema.resolvedReferenceName(next); + if (elementName && nextName && elementName === nextName && element.type !== next.type) { + ctx.report.reportFailure( + 'interpreting', + fail(`Invalid schema with property name ${elementName} but types ${element.type} and ${next.type}`), + ); + } + } + }); + } + }); +} + +function withResult(ctx: SchemaConverterContext, x: Result, cb: (x: A) => void): void { + if (isFailure(x)) { + ctx.report.reportFailure('interpreting', x); + } else { + cb(x); + } +} diff --git a/packages/@aws-cdk/service-spec-importers/src/importers/eventbridge/schema-helpers.ts b/packages/@aws-cdk/service-spec-importers/src/importers/eventbridge/schema-helpers.ts new file mode 100644 index 000000000..92a77e8e0 --- /dev/null +++ b/packages/@aws-cdk/service-spec-importers/src/importers/eventbridge/schema-helpers.ts @@ -0,0 +1,112 @@ +import { PropertyType, RichPropertyType } from '@aws-cdk/service-spec-types'; + +/** + * Check if a schema represents an empty object type + * An empty object type has type: "object" but no properties field + */ +export function isEmptyObjectType(schema: any): boolean { + return schema.type === 'object' && !schema.properties; +} + +/** + * Navigate to the detail type definition in an EventBridge schema + * Follows the $ref path from AWSEvent.properties.detail to find the actual detail type schema + * + * @returns Object containing the detail type schema and its name + */ +export function extractDetailTypeFromSchema(eventContent: any): { schema: any; typeName: string } { + const parts = eventContent.components.schemas.AWSEvent.properties.detail.$ref.substring(2).split('/'); + let current = eventContent; + let lastKey: string | undefined; + + while (parts.length > 0) { + lastKey = parts.shift()!; + // @ts-ignore + current = current[lastKey]; + } + + return { + schema: current, + typeName: lastKey!, + }; +} + +/** + * Derive a 'required' array from the oneOfs/anyOfs/allOfs in this source + */ +export function calculateDefinitelyRequired(source: RequiredContainer): Set { + const ret = new Set([...(source.required ?? [])]); + + if (source.oneOf) { + setExtend(ret, setIntersect(...source.oneOf.map(calculateDefinitelyRequired))); + } + if (source.anyOf) { + setExtend(ret, setIntersect(...source.anyOf.map(calculateDefinitelyRequired))); + } + if (source.allOf) { + setExtend(ret, ...source.allOf.map(calculateDefinitelyRequired)); + } + + return ret; +} + +export function lastWord(x: string): string { + return x.match(/([a-zA-Z0-9]+)$/)?.[1] ?? x; +} + +export function collectionNameHint(nameHint: string) { + return `${nameHint}Items`; +} + +export function setIntersect(...xs: Set[]): Set { + if (xs.length === 0) { + return new Set(); + } + const ret = new Set(xs[0]); + for (const x of xs) { + for (const e of ret) { + if (!x.has(e)) { + ret.delete(e); + } + } + } + return ret; +} + +export function setExtend(ss: Set, ...xs: Set[]): void { + for (const e of xs.flatMap((x) => Array.from(x))) { + ss.add(e); + } +} + +export function removeUnionDuplicates(types: PropertyType[]) { + if (types.length === 0) { + throw new Error('Union cannot be empty'); + } + + for (let i = 0; i < types.length; ) { + const type = new RichPropertyType(types[i]); + + let dupe = false; + for (let j = i + 1; j < types.length; j++) { + dupe ||= type.javascriptEquals(types[j]); + } + + if (dupe) { + types.splice(i, 1); + } else { + i += 1; + } + } + + if (types.length === 0) { + throw new Error('Whoopsie, union ended up empty'); + } +} + +export interface RequiredContainer { + readonly required?: string[]; + readonly oneOf?: RequiredContainer[]; + readonly anyOf?: RequiredContainer[]; + readonly allOf?: RequiredContainer[]; +} diff --git a/packages/@aws-cdk/service-spec-importers/src/importers/import-eventbridge-schema.ts b/packages/@aws-cdk/service-spec-importers/src/importers/import-eventbridge-schema.ts new file mode 100644 index 000000000..56770e575 --- /dev/null +++ b/packages/@aws-cdk/service-spec-importers/src/importers/import-eventbridge-schema.ts @@ -0,0 +1,61 @@ +import { SpecDatabase } from '@aws-cdk/service-spec-types'; +import { failure } from '@cdklabs/tskb'; +import { EventBuilder } from '../event-builder'; +import { ProblemReport, ReportAudience } from '../report'; +import { EventBridgeSchema, jsonschema } from '../types'; +import { lookupService, lookupResource } from './eventbridge/event-resource-matcher'; +import { createTypeDefinitionsFromSchema } from './eventbridge/schema-converter'; +import { extractDetailTypeFromSchema } from './eventbridge/schema-helpers'; + +export interface LoadEventBridgeSchmemaOptions { + readonly db: SpecDatabase; + readonly event: EventBridgeSchema; + readonly report: ProblemReport; + readonly region?: string; +} + +export function importEventBridgeSchema(options: LoadEventBridgeSchmemaOptions) { + const { db, event, report: originalReport } = options; + const report = originalReport.forAudience(ReportAudience.fromCloudFormationResource(event.SchemaName)); + + const eventFailure = failure.in(event.SchemaName); + const resolve = jsonschema.makeResolver(event.Content); + + const { schema: current, typeName: detailTypeName } = extractDetailTypeFromSchema(event.Content); + + const eventBuilder = EventBuilder.createBuilder(db, event.SchemaName, { + source: event.Content.components.schemas.AWSEvent['x-amazon-events-source'], + detailType: event.Content.components.schemas.AWSEvent['x-amazon-events-detail-type'], + description: event.Description, + }); + + const { rootTypeDef, createdTypes } = createTypeDefinitionsFromSchema(current, detailTypeName, { + eventBuilder, + report, + resolve, + eventFailure, + }); + + eventBuilder.allocateEvent(rootTypeDef); + + for (const typeDef of createdTypes) { + eventBuilder.linkTypesToEvent(typeDef); + } + + const eventRet = eventBuilder.commit(); + + const service = lookupService({ eventSchemaName: event.SchemaName, db }); + if (service == undefined) { + // TODO: change this to report + console.log(`The service related to this event schema name ${event.SchemaName} doesn't exist in CF`); + return eventRet; + } + + const resource = lookupResource({ service, db, eventSchemaName: event.SchemaName }); + + if (resource) { + eventBuilder.linkResourceToEvent(resource.resource, resource.matches[0]); + } + + return eventBuilder.commit(); +} diff --git a/packages/@aws-cdk/service-spec-importers/src/loaders/load-eventbridge-schema.ts b/packages/@aws-cdk/service-spec-importers/src/loaders/load-eventbridge-schema.ts new file mode 100644 index 000000000..fc933420f --- /dev/null +++ b/packages/@aws-cdk/service-spec-importers/src/loaders/load-eventbridge-schema.ts @@ -0,0 +1,69 @@ +import * as path from 'path'; +import * as util from 'util'; +import { isSuccess, Result } from '@cdklabs/tskb'; +import * as _glob from 'glob'; +import { Loader, LoadResult, LoadSourceOptions } from './loader'; +import { ProblemReport, ReportAudience } from '../report'; +import { EventBridgeSchema } from '../types'; + +const glob = util.promisify(_glob.glob); + +export interface EventBridgeSchemas { + readonly regionName: string; + readonly events: Array; +} + +export async function loadDefaultEventBridgeSchema( + schemaDir: string, + options: EventBridgeSchemaSourceOptions, +): Promise { + const files = await glob(`${schemaDir}/*`); + return Promise.all( + files.map(async (directoryName) => { + const regionName = path.basename(directoryName); + const events = await loadEventBridgeSchemaDirectory(schemaDir)(directoryName, options); + + return { regionName, events }; + }), + ); +} + +function loadEventBridgeSchemaDirectory( + baseDir: string, +): (directory: string, options: EventBridgeSchemaSourceOptions) => Promise { + return async (directory, options: EventBridgeSchemaSourceOptions) => { + const loader = await Loader.fromSchemaFile('EventBridge.schema.json', { + mustValidate: options.validate, + errorRootDirectory: baseDir, + }); + + const files = await glob(path.join(directory, '*.json')); + return loader.loadFiles(files, problemReportCombiner(options.report, options.failureAudience)); + }; +} + +export interface EventBridgeSchemas { + readonly regionName: string; + readonly events: Array; +} + +interface EventBridgeSchemaSourceOptions extends LoadSourceOptions { + readonly report: ProblemReport; + readonly failureAudience: ReportAudience; + // FIX: ReportAudience directing to cloudformation +} + +function problemReportCombiner(report: ProblemReport, failureAudience: ReportAudience) { + return (results: Result>[]): EventBridgeSchema[] => { + for (const r of results) { + if (isSuccess(r)) { + // const audience = ReportAudience.fromCloudFormationResource(r.value.typeName); + // report.reportFailure(audience, 'loading', ...r.warnings); + } else { + report.reportFailure(failureAudience, 'loading', r); + } + } + + return results.filter(isSuccess).map((r) => r.value); + }; +} diff --git a/packages/@aws-cdk/service-spec-importers/src/printable-tree.ts b/packages/@aws-cdk/service-spec-importers/src/printable-tree.ts index d2931863a..d8e31d619 100644 --- a/packages/@aws-cdk/service-spec-importers/src/printable-tree.ts +++ b/packages/@aws-cdk/service-spec-importers/src/printable-tree.ts @@ -101,7 +101,7 @@ export class PrintableTree { } public newline() { - if (this.lines[this.lines.length - 1].length > 0) { + if (this.lines.length === 0 || this.lines[this.lines.length - 1].length > 0) { this.lines.push([]); } return this; diff --git a/packages/@aws-cdk/service-spec-importers/src/types/eventbridge/EventBridgeSchema.ts b/packages/@aws-cdk/service-spec-importers/src/types/eventbridge/EventBridgeSchema.ts new file mode 100644 index 000000000..88a8f27a2 --- /dev/null +++ b/packages/@aws-cdk/service-spec-importers/src/types/eventbridge/EventBridgeSchema.ts @@ -0,0 +1,19 @@ +export interface EventBridgeSchema { + readonly SchemaName: string; + readonly Description: string; + readonly Content: { + components: { + schemas: { + AWSEvent: { + 'x-amazon-events-detail-type': string; + 'x-amazon-events-source': string; + properties: { + detail: { + $ref: string; + }; + }; + }; + }; + }; + }; +} diff --git a/packages/@aws-cdk/service-spec-importers/src/types/index.ts b/packages/@aws-cdk/service-spec-importers/src/types/index.ts index 07fc81987..16a988753 100644 --- a/packages/@aws-cdk/service-spec-importers/src/types/index.ts +++ b/packages/@aws-cdk/service-spec-importers/src/types/index.ts @@ -7,3 +7,4 @@ export * from './stateful-resources/StatefulResources'; export * from './cloudwatch-console-service-directory/CloudWatchConsoleServiceDirectory'; export * from './getatt-allowlist/getatt-allowlist'; export * from './oob-relationships/OobRelationships'; +export * from './eventbridge/EventBridgeSchema'; diff --git a/packages/@aws-cdk/service-spec-importers/test/event.test.ts b/packages/@aws-cdk/service-spec-importers/test/event.test.ts new file mode 100644 index 000000000..5c1bccb49 --- /dev/null +++ b/packages/@aws-cdk/service-spec-importers/test/event.test.ts @@ -0,0 +1,692 @@ +import { emptyDatabase } from '@aws-cdk/service-spec-types'; +import { importEventBridgeSchema } from '../src/importers/import-eventbridge-schema'; +import { ProblemReport } from '../src/report'; +import { EventBridgeSchema } from '../src/types'; + +let db: ReturnType; +let report: ProblemReport; +beforeEach(() => { + db = emptyDatabase(); + report = new ProblemReport(); + + const service = db.allocate('service', { + name: 'aws-test', + shortName: 'test', + capitalized: 'Test', + cloudFormationNamespace: 'AWS::Test', + }); + + const resource = db.allocate('resource', { + cloudFormationType: 'AWS::Test::HookVersion', + name: 'HookVersion', + properties: { + SimpleProperty: { type: { type: 'string' } }, + RoleArn: { type: { type: 'string' } }, + }, + attributes: {}, + }); + + db.link('hasResource', service, resource); +}); + +test('EventBridge event with matching resource', () => { + importEventBridgeSchema({ + db, + report, + event: { + SchemaName: 'aws.test@ObjectCreated', + Description: 'Schema for event type ObjectCreated, published by AWS service aws.s3', + Content: { + components: { + schemas: { + AWSEvent: { + 'x-amazon-events-detail-type': 'Object Created', + 'x-amazon-events-source': 'aws.test', + properties: { + detail: { + $ref: '#/components/schemas/ObjectCreated', + }, + }, + }, + ObjectCreated: { + type: 'object', + required: ['HookVersion'], + properties: { + hookVersion: { + $ref: '#/components/schemas/HookVersion', + }, + 'request-id': { + type: 'string', + }, + }, + }, + HookVersion: { + type: 'object', + required: ['name'], + properties: { + name: { + type: 'string', + }, + }, + }, + }, + }, + }, + } as EventBridgeSchema, + }); + const event = db.lookup('event', 'name', 'equals', 'aws.test@ObjectCreated').only(); + + // check types + expect(db.all('eventTypeDefinition')).toHaveLength(2); + + const rootType = db.get('eventTypeDefinition', event.rootProperty.$ref); + expect(rootType).toMatchObject({ + name: 'ObjectCreated', + properties: { + hookVersion: { type: { type: 'ref' } }, + 'request-id': { type: { type: 'string' } }, + }, + }); + + // @ts-ignore + const referenceType = db.get('eventTypeDefinition', rootType.properties.hookVersion.type.reference); + expect(referenceType).toMatchObject({ + name: 'HookVersion', + properties: { + name: { type: { type: 'string' } }, + }, + }); + + const eventTypes = db.follow('eventUsesType', event).map((x) => x.entity); + expect(eventTypes).toHaveLength(2); + expect(eventTypes).toContain(referenceType); + expect(eventTypes).toContain(rootType); + + // check the event + expect(event).toMatchObject({ + name: 'aws.test@ObjectCreated', + source: 'aws.test', + detailType: 'Object Created', + description: 'Schema for event type ObjectCreated, published by AWS service aws.s3', + rootProperty: { $ref: rootType.$id }, + resourcesField: [ + { + type: { + $ref: referenceType.$id, + }, + }, + ], + }); + + // Check the relationship between resource and events + const resource = db.lookup('resource', 'cloudFormationType', 'equals', 'AWS::Test::HookVersion').only(); + const resourceHasEvent = db.follow('resourceHasEvent', resource).only(); + expect(resourceHasEvent.entity).toMatchObject(event); +}); + +test('EventBridge event have hyphens field name with matching resource', () => { + importEventBridgeSchema({ + db, + report, + event: { + SchemaName: 'aws.test@ObjectCreated', + Description: 'Schema for event type ObjectCreated, published by AWS service aws.s3', + Content: { + components: { + schemas: { + AWSEvent: { + 'x-amazon-events-detail-type': 'Object Created', + 'x-amazon-events-source': 'aws.test', + properties: { + detail: { + $ref: '#/components/schemas/ObjectCreated', + }, + }, + }, + ObjectCreated: { + type: 'object', + required: ['Hook-Version'], + properties: { + 'Hook-Version': { + $ref: '#/components/schemas/Hook-Version', + }, + 'request-id': { + type: 'string', + }, + }, + }, + 'Hook-Version': { + type: 'object', + required: ['name'], + properties: { + name: { + type: 'string', + }, + }, + }, + }, + }, + }, + } as EventBridgeSchema, + }); + const event = db.lookup('event', 'name', 'equals', 'aws.test@ObjectCreated').only(); + + // check types + expect(db.all('eventTypeDefinition')).toHaveLength(2); + + const rootType = db.get('eventTypeDefinition', event.rootProperty.$ref); + expect(rootType).toMatchObject({ + name: 'ObjectCreated', + properties: { + 'Hook-Version': { type: { type: 'ref' } }, + 'request-id': { type: { type: 'string' } }, + }, + }); + + // @ts-ignore + const referenceType = db.get('eventTypeDefinition', rootType.properties['Hook-Version'].type.reference); + expect(referenceType).toMatchObject({ + name: 'Hook-Version', + properties: { + name: { type: { type: 'string' } }, + }, + }); + + // check the event + expect(event).toMatchObject({ + name: 'aws.test@ObjectCreated', + source: 'aws.test', + detailType: 'Object Created', + description: 'Schema for event type ObjectCreated, published by AWS service aws.s3', + rootProperty: { $ref: rootType.$id }, + resourcesField: [ + { + type: { + $ref: referenceType.$id, + }, + }, + ], + }); + + const eventTypes = db.follow('eventUsesType', event).map((x) => x.entity); + expect(eventTypes).toHaveLength(2); + expect(eventTypes).toContain(referenceType); + expect(eventTypes).toContain(rootType); + + // Check the relationship between resource and events + const resource = db.lookup('resource', 'cloudFormationType', 'equals', 'AWS::Test::HookVersion').only(); + const resourceHasEvent = db.follow('resourceHasEvent', resource).only(); + expect(resourceHasEvent.entity).toMatchObject(event); +}); + +test('EventBridge event with no matching event field', () => { + importEventBridgeSchema({ + db, + report, + event: { + SchemaName: 'aws.test@ObjectCreated', + Description: 'Schema for event type ObjectCreated, published by AWS service aws.s3', + Content: { + components: { + schemas: { + AWSEvent: { + 'x-amazon-events-detail-type': 'Object Created', + 'x-amazon-events-source': 'aws.test', + properties: { + detail: { + $ref: '#/components/schemas/ObjectCreated', + }, + }, + }, + ObjectCreated: { + type: 'object', + properties: { + hookVersion: { + $ref: '#/components/schemas/HookVersion', + }, + 'request-id': { + type: 'string', + }, + }, + }, + HookVersion: { + type: 'object', + properties: { + name: { + type: 'string', + }, + }, + }, + }, + }, + }, + } as EventBridgeSchema, + }); + const event = db.lookup('event', 'name', 'equals', 'aws.test@ObjectCreated').only(); + + expect(db.all('eventTypeDefinition')).toHaveLength(2); + + const rootType = db.get('eventTypeDefinition', event.rootProperty.$ref); + expect(rootType).toMatchObject({ + name: 'ObjectCreated', + properties: { + hookVersion: { type: { type: 'ref' } }, + 'request-id': { type: { type: 'string' } }, + }, + }); + + // @ts-ignore + const referenceType = db.get('eventTypeDefinition', rootType.properties.hookVersion.type.reference); + expect(referenceType).toMatchObject({ + name: 'HookVersion', + properties: { + name: { type: { type: 'string' } }, + }, + }); + + // Check the event + + expect(event).toMatchObject({ + name: 'aws.test@ObjectCreated', + source: 'aws.test', + detailType: 'Object Created', + description: 'Schema for event type ObjectCreated, published by AWS service aws.s3', + rootProperty: { $ref: rootType.$id }, + resourcesField: [], + }); + + // check types + const eventTypes = db.follow('eventUsesType', event).map((x) => x.entity); + expect(eventTypes).toHaveLength(2); + expect(eventTypes).toContain(referenceType); + expect(eventTypes).toContain(rootType); + + // Check the relationship between resource and events + const resource = db.lookup('resource', 'cloudFormationType', 'equals', 'AWS::Test::HookVersion').only(); + expect(db.follow('resourceHasEvent', resource)).toHaveLength(0); +}); + +test('EventBridge event with non existing service', () => { + const serviceName = 'nonExistingService'; + importEventBridgeSchema({ + db, + report, + event: { + SchemaName: `aws.${serviceName}@ObjectCreated`, + Description: `Schema for event type ObjectCreated, published by AWS service aws.${serviceName}`, + Content: { + components: { + schemas: { + AWSEvent: { + 'x-amazon-events-detail-type': 'Object Created', + 'x-amazon-events-source': `aws.${serviceName}`, + properties: { + detail: { + $ref: '#/components/schemas/ObjectCreated', + }, + }, + }, + ObjectCreated: { + type: 'object', + required: ['HookVersion'], + properties: { + hookVersion: { + $ref: '#/components/schemas/HookVersion', + }, + 'request-id': { + type: 'string', + }, + }, + }, + HookVersion: { + type: 'object', + required: ['name'], + properties: { + name: { + type: 'string', + }, + }, + }, + }, + }, + }, + } as EventBridgeSchema, + }); + + const event = db.lookup('event', 'name', 'equals', `aws.${serviceName}@ObjectCreated`).only(); + + expect(db.all('eventTypeDefinition')).toHaveLength(2); + + const rootType = db.get('eventTypeDefinition', event.rootProperty.$ref); + expect(rootType).toMatchObject({ + name: 'ObjectCreated', + properties: { + hookVersion: { type: { type: 'ref' } }, + 'request-id': { type: { type: 'string' } }, + }, + }); + + // @ts-ignore + const referenceType = db.get('eventTypeDefinition', rootType.properties.hookVersion.type.reference); + expect(referenceType).toMatchObject({ + name: 'HookVersion', + properties: { + name: { type: { type: 'string' } }, + }, + }); + + expect(event).toMatchObject({ + name: `aws.${serviceName}@ObjectCreated`, + source: `aws.${serviceName}`, + detailType: 'Object Created', + description: `Schema for event type ObjectCreated, published by AWS service aws.${serviceName}`, + rootProperty: { $ref: rootType.$id }, + resourcesField: [], + }); + + // check types + const eventTypes = db.follow('eventUsesType', event).map((x) => x.entity); + expect(eventTypes).toHaveLength(2); + expect(eventTypes).toContain(referenceType); + expect(eventTypes).toContain(rootType); + + // Check the relationship between resource and events + const resource = db.lookup('resource', 'cloudFormationType', 'equals', 'AWS::Test::HookVersion').only(); + expect(db.follow('resourceHasEvent', resource)).toHaveLength(0); +}); + +test('EventBridge event with nested fields without reference', () => { + importEventBridgeSchema({ + db, + report, + event: { + SchemaName: 'aws.test@ObjectCreated', + Description: 'Schema for event type ObjectCreated, published by AWS service aws.s3', + Content: { + components: { + schemas: { + AWSEvent: { + 'x-amazon-events-detail-type': 'Object Created', + 'x-amazon-events-source': 'aws.test', + properties: { + detail: { + $ref: '#/components/schemas/ObjectCreated', + }, + }, + }, + ObjectCreated: { + type: 'object', + required: ['HookVersion'], + properties: { + HookVersion: { + type: 'object', + required: ['name'], + properties: { + name: { + type: 'string', + }, + nested: { + type: 'object', + field: { + type: 'string', + }, + }, + }, + }, + 'request-id': { + type: 'string', + }, + }, + }, + }, + }, + }, + } as EventBridgeSchema, + }); + const event = db.lookup('event', 'name', 'equals', 'aws.test@ObjectCreated').only(); + + // check types + expect(db.all('eventTypeDefinition')).toHaveLength(2); + const rootType = db.get('eventTypeDefinition', event.rootProperty.$ref); + expect(rootType).toMatchObject({ + name: 'ObjectCreated', + properties: { + HookVersion: { + type: { + type: 'ref', + }, + }, + 'request-id': { type: { type: 'string' } }, + }, + }); + + // @ts-ignore + const referenceType = db.get('eventTypeDefinition', rootType.properties.HookVersion.type.reference); + expect(referenceType).toMatchObject({ + name: 'HookVersion', + properties: { + name: { type: { type: 'string' } }, + // FIX: why the nested fields aren't presented? + nested: { + type: { type: 'json' }, + }, + }, + }); + + // check the event + expect(event).toMatchObject({ + name: 'aws.test@ObjectCreated', + source: 'aws.test', + detailType: 'Object Created', + description: 'Schema for event type ObjectCreated, published by AWS service aws.s3', + rootProperty: { $ref: rootType.$id }, + resourcesField: [ + { + type: { + $ref: referenceType.$id, + }, + }, + ], + }); + + const eventTypes = db.follow('eventUsesType', event).map((x) => x.entity); + expect(eventTypes).toHaveLength(2); + expect(eventTypes).toContain(referenceType); + expect(eventTypes).toContain(rootType); + + // Check the relationship between resource and events + const resource = db.lookup('resource', 'cloudFormationType', 'equals', 'AWS::Test::HookVersion').only(); + const resourceHasEvent = db.follow('resourceHasEvent', resource).only(); + expect(resourceHasEvent.entity).toMatchObject(event); +}); + +test('EventBridge event with two fields reference same type', () => { + importEventBridgeSchema({ + db, + report, + event: { + SchemaName: 'aws.test@StateChanged', + Description: 'Schema for event type StateChanged, published by AWS service aws.test', + Content: { + components: { + schemas: { + AWSEvent: { + 'x-amazon-events-detail-type': 'State Changed', + 'x-amazon-events-source': 'aws.test', + properties: { + detail: { + $ref: '#/components/schemas/StateChanged', + }, + }, + }, + StateChanged: { + type: 'object', + required: ['currentState'], + properties: { + currentState: { + $ref: '#/components/schemas/State', + }, + previousState: { + $ref: '#/components/schemas/State', + }, + }, + }, + State: { + type: 'object', + required: ['name'], + properties: { + name: { + type: 'string', + }, + }, + }, + }, + }, + }, + } as EventBridgeSchema, + }); + const event = db.lookup('event', 'name', 'equals', 'aws.test@StateChanged').only(); + + // check types + const rootType = db.get('eventTypeDefinition', event.rootProperty.$ref); + expect(rootType).toMatchObject({ + name: 'StateChanged', + properties: { + currentState: { type: { type: 'ref' } }, + previousState: { type: { type: 'ref' } }, + }, + }); + + // @ts-ignore + const currentStateReferenceType = db.get('eventTypeDefinition', rootType.properties.currentState.type.reference); + expect(currentStateReferenceType).toMatchObject({ + name: 'State', + properties: { + name: { type: { type: 'string' } }, + }, + }); + // @ts-ignore + const previousStateReferenceType = db.get('eventTypeDefinition', rootType.properties.previousState.type.reference); + expect(currentStateReferenceType).toMatchObject(previousStateReferenceType); + + const eventTypes = db.follow('eventUsesType', event).map((x) => x.entity); + expect(eventTypes).toHaveLength(2); + expect(eventTypes).toContain(currentStateReferenceType); + expect(eventTypes).toContain(rootType); + + // check the event + expect(event).toMatchObject({ + name: 'aws.test@StateChanged', + source: 'aws.test', + detailType: 'State Changed', + description: 'Schema for event type StateChanged, published by AWS service aws.test', + rootProperty: { $ref: rootType.$id }, + resourcesField: [], + }); + + // Check the relationship between resource and events + const resource = db.lookup('resource', 'cloudFormationType', 'equals', 'AWS::Test::HookVersion').only(); + const resourceHasEvent = db.follow('resourceHasEvent', resource); + expect(resourceHasEvent).toHaveLength(0); +}); + +test('EventBridge two events have same type name should have different references', () => { + const schemaName = 'aws.test@StateChanged'; + const schemaName2 = 'aws.test2@StateChanged'; + createEventAndCheck(schemaName); + createEventAndCheck(schemaName2); + + expect(db.all('eventTypeDefinition')).toHaveLength(4); + + const event = db.lookup('event', 'name', 'equals', schemaName).only(); + const event2 = db.lookup('event', 'name', 'equals', schemaName2).only(); + + const eventTypes = db.follow('eventUsesType', event).map((x) => x.entity.$id); + const eventTypes2 = db.follow('eventUsesType', event2).map((x) => x.entity.$id); + + expect(eventTypes.every((x) => !eventTypes2.includes(x))).toBe(true); +}); + +function createEventAndCheck(schemaName: string) { + const serviceName = schemaName.split('@')[0]; + importEventBridgeSchema({ + db, + report, + event: { + SchemaName: schemaName, + Description: `Schema for event type StateChanged, published by AWS service ${serviceName}`, + Content: { + components: { + schemas: { + AWSEvent: { + 'x-amazon-events-detail-type': 'State Changed', + 'x-amazon-events-source': serviceName, + properties: { + detail: { + $ref: '#/components/schemas/StateChanged', + }, + }, + }, + StateChanged: { + type: 'object', + required: ['currentState'], + properties: { + currentState: { + $ref: '#/components/schemas/State', + }, + }, + }, + State: { + type: 'object', + required: ['name'], + properties: { + name: { + type: 'string', + }, + }, + }, + }, + }, + }, + } as EventBridgeSchema, + }); + const event = db.lookup('event', 'name', 'equals', schemaName).only(); + + // check types + const rootType = db.get('eventTypeDefinition', event.rootProperty.$ref); + expect(rootType).toMatchObject({ + name: 'StateChanged', + properties: { + currentState: { type: { type: 'ref' } }, + }, + }); + + // @ts-ignore + const currentStateReferenceType = db.get('eventTypeDefinition', rootType.properties.currentState.type.reference); + expect(currentStateReferenceType).toMatchObject({ + name: 'State', + properties: { + name: { type: { type: 'string' } }, + }, + }); + + const eventTypes = db.follow('eventUsesType', event).map((x) => x.entity); + expect(eventTypes).toHaveLength(2); + expect(eventTypes).toContain(currentStateReferenceType); + expect(eventTypes).toContain(rootType); + + // check the event + expect(event).toMatchObject({ + name: schemaName, + source: serviceName, + detailType: 'State Changed', + description: `Schema for event type StateChanged, published by AWS service ${serviceName}`, + rootProperty: { $ref: rootType.$id }, + resourcesField: [], + }); + + // Check the relationship between resource and events + const resource = db.lookup('resource', 'cloudFormationType', 'equals', 'AWS::Test::HookVersion').only(); + const resourceHasEvent = db.follow('resourceHasEvent', resource); + expect(resourceHasEvent).toHaveLength(0); +} diff --git a/packages/@aws-cdk/service-spec-types/src/types/diff.ts b/packages/@aws-cdk/service-spec-types/src/types/diff.ts index c6b28aa3c..691688853 100644 --- a/packages/@aws-cdk/service-spec-types/src/types/diff.ts +++ b/packages/@aws-cdk/service-spec-types/src/types/diff.ts @@ -1,3 +1,4 @@ +import { Event, EventTypeDefinition, EventProperty } from './event'; import { Metric } from './metrics'; import { Attribute, Property, Resource, Service, TypeDefinition } from './resource'; @@ -45,6 +46,7 @@ export interface UpdatedResource { readonly typeDefinitionDiff?: MapDiff; readonly primaryIdentifier?: ListDiff; readonly metrics?: MapDiff; + readonly events?: MapDiff; } export interface UpdatedProperty { @@ -64,6 +66,26 @@ export interface UpdatedTypeDefinition { readonly mustRenderForBwCompat?: ScalarDiff; } +export interface UpdatedEvent { + readonly name?: ScalarDiff; + readonly description?: ScalarDiff; + readonly source?: ScalarDiff; + readonly detailType?: ScalarDiff; + readonly resourcesField?: ScalarDiff; + readonly rootProperty?: ScalarDiff; + readonly typeDefinitionDiff?: MapDiff; +} + +export interface UpdatedEventTypeDefinition { + readonly name?: ScalarDiff; + readonly properties?: MapDiff; +} + +export interface UpdatedEventProperty { + readonly old: EventProperty; + readonly new: EventProperty; +} + export interface ScalarDiff { readonly old?: A; readonly new?: A;