Repository: pulumi/pulumi-cdk
Pull request #192 (2024-11-01 13:05:52)

Fix property normalization for properties with `additionalProperties`
Some resources properties are an object type with additionalProperties.
These properties should not have their keys transformed, but should have
their values.


Patch:
diff --git a/README.md b/README.md
index 3502fbd..faf3b30 100644
--- a/README.md
+++ b/README.md
@@ -167,7 +167,7 @@ CustomResources, commonly by using the AWS Classic provider to implement the
 missing resource.
 
 ```ts
-remapCloudControlResource(logicalId: string, typeName: string, props: any, options: pulumi.ResourceOptions): { [key: string]: pulumi.CustomResource } | undefined
+remapCloudControlResource(logicalId: string, typeName: string, props: any, options: pulumi.ResourceOptions): ResourceMapping | undefined;
 ```
 
 Parameters:
diff --git a/examples/cron-lambda/adapter.ts b/examples/cron-lambda/adapter.ts
deleted file mode 100644
index 5732cc4..0000000
--- a/examples/cron-lambda/adapter.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-import { CfnElement } from 'aws-cdk-lib';
-import * as pulumi from '@pulumi/pulumi';
-import * as pulumicdk from '@pulumi/cdk';
-import * as aws from '@pulumi/aws';
-
-interface target {
-    arn: pulumi.Input<string>;
-    id: string;
-}
-
-export function remapCloudControlResource(
-    _element: CfnElement,
-    logicalId: string,
-    typeName: string,
-    rawProps: any,
-    options: pulumi.ResourceOptions,
-): pulumi.CustomResource | undefined {
-    const props = pulumicdk.interop.normalize(rawProps);
-    switch (typeName) {
-        case 'AWS::Events::Rule': {
-            const rule = new aws.cloudwatch.EventRule(
-                logicalId,
-                {
-                    scheduleExpression: props['scheduleExpression'],
-                    isEnabled: props['state'] == 'ENABLED' ? true : props.State === 'DISABLED' ? false : undefined,
-                    description: props.Description,
-                    eventBusName: props['eventBusName'] ?? undefined,
-                    eventPattern: props['eventPattern'] ?? undefined,
-                    roleArn: props['roleArn'] ?? undefined,
-                },
-                options,
-            );
-            const targets: target[] = props['targets'] ?? [];
-            for (const t of targets) {
-                new aws.cloudwatch.EventTarget(
-                    t.id,
-                    {
-                        arn: t.arn,
-                        rule: rule.name,
-                    },
-                    options,
-                );
-            }
-            return rule;
-        }
-        case 'AWS::Lambda::Permission':
-            return new aws.lambda.Permission(
-                logicalId,
-                {
-                    action: props['action'],
-                    function: props['functionName'],
-                    principal: props['principal'],
-                    sourceArn: props['sourceArn'] ?? undefined,
-                },
-                options,
-            );
-    }
-
-    return undefined;
-}
diff --git a/examples/lookups/index.ts b/examples/lookups/index.ts
index 281df63..33a8653 100644
--- a/examples/lookups/index.ts
+++ b/examples/lookups/index.ts
@@ -127,25 +127,23 @@ const app = new pulumicdk.App(
             remapCloudControlResource(logicalId, typeName, props, options) {
                 switch (typeName) {
                     case 'AWS::Route53::RecordSet':
-                        return [
-                            new aws.route53.Record(
-                                logicalId,
-                                {
-                                    zoneId: props.HostedZoneId,
-                                    aliases: [
-                                        {
-                                            name: props.AliasTarget.DNSName,
-                                            zoneId: props.AliasTarget.HostedZoneId,
-                                            evaluateTargetHealth: props.AliasTarget.EvaluateTargetHealth ?? false,
-                                        },
-                                    ],
-                                    name: props.Name,
-                                    type: props.Type,
-                                    records: props.ResourceRecords,
-                                },
-                                options,
-                            ),
-                        ];
+                        return new aws.route53.Record(
+                            logicalId,
+                            {
+                                zoneId: props.HostedZoneId,
+                                aliases: [
+                                    {
+                                        name: props.AliasTarget.DNSName,
+                                        zoneId: props.AliasTarget.HostedZoneId,
+                                        evaluateTargetHealth: props.AliasTarget.EvaluateTargetHealth ?? false,
+                                    },
+                                ],
+                                name: props.Name,
+                                type: props.Type,
+                                records: props.ResourceRecords,
+                            },
+                            options,
+                        );
                     default:
                         return undefined;
                 }
diff --git a/src/aws-resource-mappings.ts b/src/aws-resource-mappings.ts
index 9c076e0..496b4fa 100644
--- a/src/aws-resource-mappings.ts
+++ b/src/aws-resource-mappings.ts
@@ -14,7 +14,7 @@
 
 import * as pulumi from '@pulumi/pulumi';
 import * as aws from '@pulumi/aws';
-import { ResourceMapping, normalize } from './interop';
+import { ResourceAttributeMappingArray, ResourceMapping, normalize } from './interop';
 
 function maybe<T, U>(v: T | undefined, fn: (t: T) => U): U | undefined {
     if (v === undefined) {
@@ -46,44 +46,40 @@ export function mapToAwsResource(
     typeName: string,
     rawProps: any,
     options: pulumi.ResourceOptions,
-): ResourceMapping[] | undefined {
+): ResourceMapping | undefined {
     const props = normalize(rawProps);
     switch (typeName) {
         // ApiGatewayV2
         case 'AWS::ApiGatewayV2::Integration':
-            return [
-                new aws.apigatewayv2.Integration(
-                    logicalId,
-                    {
-                        ...props,
-                        requestParameters: rawProps.RequestParameters,
-                        requestTemplates: rawProps.RequestTemplates,
-                        responseParameters: rawProps.ResponseParameters,
-                        tlsConfig: maybe(props.tlsConfig, () => ({ insecureSkipVerification: true })),
-                    },
-                    options,
-                ),
-            ];
+            return new aws.apigatewayv2.Integration(
+                logicalId,
+                {
+                    ...props,
+                    requestParameters: rawProps.RequestParameters,
+                    requestTemplates: rawProps.RequestTemplates,
+                    responseParameters: rawProps.ResponseParameters,
+                    tlsConfig: maybe(props.tlsConfig, () => ({ insecureSkipVerification: true })),
+                },
+                options,
+            );
         case 'AWS::ApiGatewayV2::Stage':
-            return [
-                new aws.apigatewayv2.Stage(
-                    logicalId,
-                    {
-                        accessLogSettings: props.accessLogSettings,
-                        apiId: props.apiId,
-                        autoDeploy: props.autoDeploy,
-                        clientCertificateId: props.clientCertificateId,
-                        defaultRouteSettings: props.defaultRouteSettings,
-                        deploymentId: props.deploymentId,
-                        description: props.description,
-                        name: props.stageName,
-                        routeSettings: props.routeSettings,
-                        stageVariables: rawProps.StageVariables,
-                        tags: tags(props.tags),
-                    },
-                    options,
-                ),
-            ];
+            return new aws.apigatewayv2.Stage(
+                logicalId,
+                {
+                    accessLogSettings: props.accessLogSettings,
+                    apiId: props.apiId,
+                    autoDeploy: props.autoDeploy,
+                    clientCertificateId: props.clientCertificateId,
+                    defaultRouteSettings: props.defaultRouteSettings,
+                    deploymentId: props.deploymentId,
+                    description: props.description,
+                    name: props.stageName,
+                    routeSettings: props.routeSettings,
+                    stageVariables: rawProps.StageVariables,
+                    tags: tags(props.tags),
+                },
+                options,
+            );
 
         // SQS
         // https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-sqs-queuepolicy.html
@@ -92,31 +88,42 @@ export function mapToAwsResource(
                 throw new Error('QueuePolicy has an invalid value for `queues` property');
             }
 
-            return (props.queues || []).flatMap((q: string) => {
-                return new aws.sqs.QueuePolicy(logicalId, {
-                    policy: rawProps.PolicyDocument,
-                    queueUrl: q,
-                });
+            const queues: string[] = props.queues ?? [];
+            return queues.flatMap((q: string, i: number) => {
+                const id = i === 0 ? logicalId : `${logicalId}-policy-${i}`;
+                return {
+                    logicalId: id,
+                    resource: new aws.sqs.QueuePolicy(id, {
+                        policy: rawProps.PolicyDocument,
+                        queueUrl: q,
+                    }),
+                };
             });
         }
 
         // SNS
         // https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-sns-topicpolicy.html
-        case 'AWS::SNS::TopicPolicy':
+        case 'AWS::SNS::TopicPolicy': {
             if (!Array.isArray(props.topics)) {
                 throw new Error('TopicPolicy has an invalid value for `topics` property');
             }
 
-            return (props.topics || []).flatMap((arn: string) => {
-                return new aws.sns.TopicPolicy(logicalId, {
-                    policy: rawProps.PolicyDocument,
-                    arn,
-                });
+            const topics: string[] = props.topics ?? [];
+            return topics.flatMap((arn: string, i: number) => {
+                const id = i === 0 ? logicalId : `${logicalId}-policy-${i}`;
+                return {
+                    logicalId: id,
+                    resource: new aws.sns.TopicPolicy(id, {
+                        policy: rawProps.PolicyDocument,
+                        arn,
+                    }),
+                };
             });
+        }
 
         // IAM
         case 'AWS::IAM::Policy': {
-            const resources: ResourceMapping[] = [];
+            const resources: ResourceAttributeMappingArray = [];
             const policy = new aws.iam.Policy(
                 logicalId,
                 {
@@ -124,44 +131,64 @@ export function mapToAwsResource(
                 },
                 options,
             );
-            resources.push(policy);
-
-            for (let i = 0; i < (props.groups || []).length; i++) {
-                resources.push(
-                    new aws.iam.GroupPolicyAttachment(
-                        `${logicalId}-${i}`,
-                        {
-                            group: props.groups[i],
-                            policyArn: policy.arn,
-                        },
-                        options,
-                    ),
-                );
-            }
-            for (let i = 0; i < (props.roles || []).length; i++) {
-                resources.push(
-                    new aws.iam.RolePolicyAttachment(
-                        `${logicalId}-${i}`,
-                        {
-                            role: props.roles[i],
-                            policyArn: policy.arn,
-                        },
-                        options,
-                    ),
-                );
-            }
-            for (let i = 0; i < (props.users || []).length; i++) {
-                resources.push(
-                    new aws.iam.UserPolicyAttachment(
-                        `${logicalId}-${i}`,
-                        {
-                            user: props.users[i],
-                            policyArn: policy.arn,
-                        },
-                        options,
-                    ),
-                );
-            }
+            resources.push({
+                resource: policy,
+                logicalId,
+            });
+
+            const groups: string[] = props.groups ?? [];
+            resources.push(
+                ...groups.flatMap((group: string, i: number) => {
+                    const id = `${logicalId}-group-${i}`;
+                    return {
+                        logicalId: id,
+                        resource: new aws.iam.GroupPolicyAttachment(
+                            id,
+                            {
+                                group,
+                                policyArn: policy.arn,
+                            },
+                            options,
+                        ),
+                    };
+                }),
+            );
+
+            const roles: string[] = props.roles ?? [];
+            resources.push(
+                ...roles.flatMap((role: string, i: number) => {
+                    const id = `${logicalId}-role-${i}`;
+                    return {
+                        logicalId: id,
+                        resource: new aws.iam.RolePolicyAttachment(
+                            id,
+                            {
+                                role,
+                                policyArn: policy.arn,
+                            },
+                            options,
+                        ),
+                    };
+                }),
+            );
+
+            const users: string[] = props.users ?? [];
+            resources.push(
+                ...users.flatMap((user: string, i: number) => {
+                    const id = `${logicalId}-user-${i}`;
+                    return {
+                        logicalId: id,
+                        resource: new aws.iam.UserPolicyAttachment(
+                            id,
+                            {
+                                user,
+                                policyArn: policy.arn,
+                            },
+                            options,
+                        ),
+                    };
+                }),
+            );
 
             return resources;
         }
diff --git a/src/cfn-resource-mappings.ts b/src/cfn-resource-mappings.ts
index 6dfd565..cf9be5a 100644
--- a/src/cfn-resource-mappings.ts
+++ b/src/cfn-resource-mappings.ts
@@ -25,14 +25,14 @@ export function mapToCfnResource(
     typeName: string,
     rawProps: any,
     options: pulumi.ResourceOptions,
-): ResourceMapping[] {
+): ResourceMapping {
     const props = normalize(rawProps, typeName);
     debug(`mapToCfnResource typeName: ${typeName} props: ${JSON.stringify(props)}`);
     switch (typeName) {
         case 'AWS::S3::Bucket':
             // Lowercase the bucket name to comply with the Bucket resource's naming constraints, which only allow
             // lowercase letters.
-            return [new aws.s3.Bucket(logicalId.toLowerCase(), props, options)];
+            return new aws.s3.Bucket(logicalId.toLowerCase(), props, options);
 
         // A couple of ApiGateway resources suffer from https://github.com/pulumi/pulumi-cdk/issues/173
         // These are very popular resources so handling the workaround here since we can remove these
@@ -41,60 +41,52 @@ export function mapToCfnResource(
             const res = new aws.apigateway.Model(logicalId, props, options);
             const attributes = Object.getOwnPropertyDescriptors(res);
 
-            return [
-                {
-                    resource: res,
-                    attributes: {
-                        ...attributes,
-                        id: res.name,
-                    },
+            return {
+                resource: res,
+                attributes: {
+                    ...attributes,
+                    id: res.name,
                 },
-            ];
+            };
         }
 
         case 'AWS::ApiGateway::Resource': {
             const res = new aws.apigateway.Resource(logicalId, props, options);
             const attributes = Object.getOwnPropertyDescriptors(res);
 
-            return [
-                {
-                    resource: res,
-                    attributes: {
-                        ...attributes,
-                        id: res.resourceId,
-                    },
+            return {
+                resource: res,
+                attributes: {
+                    ...attributes,
+                    id: res.resourceId,
                 },
-            ];
+            };
         }
 
         case 'AWS::ApiGateway::Deployment': {
             const res = new aws.apigateway.Deployment(logicalId, props, options);
             const attributes = Object.getOwnPropertyDescriptors(res);
 
-            return [
-                {
-                    attributes: {
-                        ...attributes,
-                        id: res.deploymentId,
-                    },
-                    resource: res,
+            return {
+                attributes: {
+                    ...attributes,
+                    id: res.deploymentId,
                 },
-            ];
+                resource: res,
+            };
         }
 
         case 'AWS::ApiGateway::Stage': {
             const res = new aws.apigateway.Stage(logicalId, props, options);
             const attributes = Object.getOwnPropertyDescriptors(res);
 
-            return [
-                {
-                    attributes: {
-                        ...attributes,
-                        id: res.stageName,
-                    },
-                    resource: res,
+            return {
+                attributes: {
+                    ...attributes,
+                    id: res.stageName,
                 },
-            ];
+                resource: res,
+            };
         }
         default: {
             // When creating a generic `CfnResource` we don't have any information on the
@@ -104,7 +96,7 @@ export function mapToCfnResource(
             const resource = metadata.findResource(typeName);
             const attributes = Object.keys(resource.outputs);
 
-            return [new CfnResource(logicalId, typeName, props, attributes, options)];
+            return new CfnResource(logicalId, typeName, props, attributes, options);
         }
     }
 }
diff --git a/src/converters/app-converter.ts b/src/converters/app-converter.ts
index c426bdd..21cd611 100644
--- a/src/converters/app-converter.ts
+++ b/src/converters/app-converter.ts
@@ -1,10 +1,10 @@
 import * as cdk from 'aws-cdk-lib/core';
 import * as pulumi from '@pulumi/pulumi';
 import { AssemblyManifestReader, StackManifest } from '../assembly';
-import { ConstructInfo, GraphBuilder } from '../graph';
+import { ConstructInfo, GraphBuilder, GraphNode } from '../graph';
 import { ArtifactConverter } from './artifact-converter';
 import { lift, Mapping, AppComponent } from '../types';
-import { CdkConstruct, ResourceMapping } from '../interop';
+import { CdkConstruct, ResourceAttributeMapping, ResourceMapping } from '../interop';
 import { debug } from '@pulumi/pulumi/log';
 import {
     cidr,
@@ -141,12 +141,7 @@ export class StackConverter extends ArtifactConverter {
                 const options = this.processOptions(cfn, parent);
 
                 const mapped = this.mapResource(n.logicalId, cfn.Type, props, options);
-                mapped.forEach((m) => {
-                    const resource = pulumi.Resource.isInstance(m) ? m : m.resource;
-                    const attributes = pulumi.Resource.isInstance(m) ? undefined : m.attributes;
-                    this.resources.set(n.logicalId!, { resource, attributes, resourceType: cfn.Type });
-                    this.constructs.set(n.construct, resource);
-                });
+                this.registerResource(mapped, n);
 
                 debug(`Done creating resource for ${n.logicalId}`);
                 // TODO: process template conditions
@@ -169,6 +164,72 @@ export class StackConverter extends ArtifactConverter {
         }
     }
 
+    /**
+     * This function takes a Pulumi resource that was mapped from a CFN resource and
+     * "registers" it to the StackConverter. The StackConverter keeps track of all of the
+     * resources that were mapped from the CFN resources in the stack. It uses this information
+     * to resolve references to other resources in the stack.
+     *
+     * Typically there will be a couple of scenarios for how a CFN resource maps to Pulumi resources:
+     *
+     * 1. The CFN resource maps to a single Pulumi aws-native resource
+     *    This is the most straightforward case because everything maps directly, including the attributes
+     *
+     * 2. The CFN resource maps to single Pulumi aws resources
+     *    In this case the mapping is to a single resource, but some times the attributes available
+     *    on the CFN resource do not map to the attributes on the Pulumi resource. In these cases
+     *    the mapping can return custom attributes that are later available when references are resolved
+     *
+     * 3. The CFN resource maps to multiple Pulumi aws resources
+     *    One example would be the AWS::IAM::Policy resource which in CFN includes Role, Group, and User
+     *    Polices, but in Pulumi aws these are broken out into separate resources. In that case the "main"
+     *    resource would be the Policy and the other resources would be added to the resources array.
+     *    The critical point here is that the "main" resource needs to have a logicalId that matches the
+     *    logicalId of the CFN resource, while the other supporting resources must have different logicalIds.
+     *
+     * @param mapped - The Pulumi Resource that was mapped from the CFN resource
+     * @param node - The GraphNode that represents the CFN Resource
+     */
+    private registerResource(mapped: ResourceMapping, node: GraphNode): void {
+        const cfn = node.resource;
+        // This should always be set because we only call this function when it is, but
+        // TypeScript doesn't know that.
+        if (!cfn) {
+            throw new Error('Cannot map a resource without a CloudFormation resource');
+        }
+        const mainResource: ResourceAttributeMapping | undefined = Array.isArray(mapped)
+            ? mapped.find((res) => res.logicalId === node.logicalId)
+            : pulumi.Resource.isInstance(mapped)
+            ? { resource: mapped }
+            : mapped;
+        if (!mainResource) {
+            throw new Error(
+                `Resource mapping for ${node.logicalId} of type ${cfn.Type} did not return a primary resource. \n` +
+                    'Examine your code in "remapCloudControlResource"',
+            );
+        }
+        const otherResources: pulumi.Resource[] | undefined = Array.isArray(mapped)
+            ? mapped
+                  .filter((map) => map.logicalId !== node.logicalId)
+                  .flatMap((m) => {
+                      this.resources.set(m.logicalId, {
+                          resource: m.resource,
+                          attributes: m.attributes,
+                          resourceType: cfn.Type,
+                      });
+                      return m.resource;
+                  })
+            : undefined;
+        const resourceMapping: Mapping<pulumi.Resource> = {
+            resource: mainResource.resource,
+            attributes: mainResource.attributes,
+            resourceType: cfn.Type,
+            otherResources,
+        };
+        this.constructs.set(node.construct, mainResource.resource);
+        this.resources.set(node.logicalId!, resourceMapping);
+    }
+
     private stackDependsOn(dependencies: Set<ArtifactConverter>): pulumi.Resource[] {
         const dependsOn: pulumi.Resource[] = [];
         dependsOn.push(...this.app.dependencies);
@@ -211,7 +272,7 @@ export class StackConverter extends ArtifactConverter {
         typeName: string,
         props: any,
         options: pulumi.ResourceOptions,
-    ): ResourceMapping[] {
+    ): ResourceMapping {
         if (this.app.appOptions?.remapCloudControlResource !== undefined) {
             const res = this.app.appOptions.remapCloudControlResource(logicalId, typeName, props, options);
             if (res !== undefined) {
@@ -235,7 +296,16 @@ export class StackConverter extends ArtifactConverter {
         const dependsOn = getDependsOn(resource);
         return {
             parent: parent,
-            dependsOn: dependsOn !== undefined ? dependsOn.map((id) => this.resources.get(id)!.resource) : undefined,
+            dependsOn: dependsOn?.flatMap((id) => {
+                const resource = this.resources.get(id);
+                if (resource === undefined) {
+                    throw new Error(`Something went wrong, resource with logicalId '${id}' not found`);
+                }
+                if (resource.otherResources && resource.otherResources.length > 0) {
+                    return resource.otherResources;
+                }
+                return resource!.resource;
+            }),
         };
     }
 
diff --git a/src/interop.ts b/src/interop.ts
index b903259..69c9913 100644
--- a/src/interop.ts
+++ b/src/interop.ts
@@ -58,12 +58,31 @@ export function normalize(value: any, cfnType?: string, pulumiProvider?: PulumiP
     return result;
 }
 
-export type ResourceMapping =
-    | {
-          resource: pulumi.Resource;
-          attributes: { [name: string]: pulumi.Input<any> };
-      }
-    | pulumi.Resource;
+/**
+ * Use this type if you need to control the attributes that are available on the
+ * mapped resource. For example if the CFN resource has an attribute called `resourceArn` and
+ * the mapped resource only has an attribute called `arn` you can return the extra `resourceArn`
+ * attribute
+ *
+ * @example
+ * return {
+ *   resource: mappedResource,
+ *   attributes: {
+ *     resourceArn: mappedResource.arn,
+ *   }
+ * }
+ */
+export type ResourceAttributeMapping = {
+    resource: pulumi.Resource;
+    attributes?: { [name: string]: pulumi.Input<any> };
+};
+
+/**
+ * Use this type if a single CFN resource maps to multiple AWS resources
+ */
+export type ResourceAttributeMappingArray = (ResourceAttributeMapping & { logicalId: string })[];
+
+export type ResourceMapping = ResourceAttributeMapping | pulumi.Resource | ResourceAttributeMappingArray;
 
 export class CfnResource extends pulumi.CustomResource {
     constructor(
diff --git a/src/pulumi-metadata.ts b/src/pulumi-metadata.ts
index 38093e9..284f32c 100644
--- a/src/pulumi-metadata.ts
+++ b/src/pulumi-metadata.ts
@@ -72,6 +72,7 @@ export interface PulumiType {
 
 /**
  * The schema for the array items property
+ * A property will typically have either $ref, properties or additionalProperties
  */
 export interface PulumiPropertyItems {
     /**
@@ -79,10 +80,20 @@ export interface PulumiPropertyItems {
      */
     $ref?: string;
 
+    /**
+     * The properties of an object type
+     */
+    properties?: { [key: string]: PulumiPropertyItems };
+
     /**
      * The simple type (i.e. string, number, etc)
      */
     type?: string;
+
+    /**
+     * A type with additional properties
+     */
+    additionalProperties?: PulumiPropertyItems;
 }
 
 /**
@@ -113,6 +124,12 @@ export enum NativeType {
      * The type is not a json type and should be processed
      */
     NON_JSON = 'NON_JSON',
+
+    /**
+     * The type an additional properties type which means the keys
+     * should not be processed but the values should be
+     */
+    ADDITIONAL_PROPERTIES = 'ADDITIONAL_PROPERTIES',
 }
 
 /**
@@ -136,7 +153,49 @@ export function processMetadataProperty(
     meta?: { [key: string]: PulumiProperty };
 } {
     switch (true) {
-        case property.type !== undefined && property.$ref === undefined:
+        // Objects with `additionalProperties` can have arbitrary keys that should not be transformed
+        // for example
+        //
+        // Case 1: additionalProperties that is a JSON type should return `NativeType.JSON` and treat the
+        // entire object as a JSON object
+        //   {
+        //     "targetResources": {
+        //       "additionalProperties": {
+        //         "$ref": "pulumi.json#/Any"
+        //       }
+        //     }
+        //   }
+        //
+        // Case 2: additionalProperties that is a non-JSON type should return `NativeType.ADDITIONAL_PROPERTIES`
+        // so that the keys are not transformed but the values are
+        //   {
+        //     "throttle": {
+        //       "additionalProperties": {
+        //         "$ref": "#/types/pulumi:aws-native/aws:apigateway/UsagePlanThrottleSettings"
+        //       }
+        //     }
+        //   }
+        //   {
+        //     "aws-native:aws:apigateway/UsagePlanThrottleSettings": {
+        //       "type": "object",
+        //       "properties": {
+        //         "burstLimit": {
+        //           "type": "integer"
+        //         }
+        //       }
+        //     }
+        //   }
+        case property.type === 'object' && property.additionalProperties !== undefined: {
+            const props = processMetadataProperty(property.additionalProperties, types, pulumiProvider);
+            return {
+                meta: props.meta,
+                nativeType: props.nativeType === NativeType.JSON ? props.nativeType : NativeType.ADDITIONAL_PROPERTIES,
+            };
+        }
+        case property.type !== undefined &&
+            property.$ref === undefined &&
+            property.properties === undefined &&
+            property.additionalProperties === undefined:
             if (property.type === 'object') {
                 return { nativeType: NativeType.JSON };
             }
@@ -160,7 +219,8 @@ export function processMetadataProperty(
 }
 
 /**
- * Determines whether or not the property in question is a JSON type of not.
+ * Determines the type of the provided property.
+ *
  * If the property is a nested property then the `propName` will contain all the parent
  * properties so that the correct nested type can be found
  *
@@ -168,27 +228,32 @@ export function processMetadataProperty(
  * @param propName the property name as a list containing parent property names
  * @param types The pulumi types
  * @param pulumiProvider The pulumi provider to read the schema from.
- * @returns true if the property is a JSON type and should not be normalized
+ * @returns the NativeType of the property
  */
-export function isJsonType(
+export function getNativeType(
     propName: string[],
     properties: { [key: string]: PulumiProperty },
     types: { [key: string]: PulumiType },
     pulumiProvider: PulumiProvider,
-): boolean {
+): NativeType {
     let props = properties;
+    let typ: NativeType = NativeType.NON_JSON;
     for (let i = 0; i < propName.length; i++) {
         const prop = toSdkName(propName[i]);
         if (prop in props) {
             const metaProp = props[prop];
+
             const { nativeType, meta } = processMetadataProperty(metaProp, types, pulumiProvider);
             if (nativeType === NativeType.JSON) {
-                return true;
+                return nativeType;
             }
+            typ = nativeType ?? NativeType.NON_JSON;
             props = meta!;
+        } else {
+            return NativeType.NON_JSON;
         }
     }
-    return false;
+    return typ;
 }
 
 /**
@@ -222,12 +287,14 @@ export function normalizeObject(key: string[], value: any, cfnType?: string, pul
             pulumiProvider = pulumiProvider ?? PulumiProvider.AWS_NATIVE;
             const metadata = new Metadata(pulumiProvider);
             const resource = metadata.findResource(cfnType);
-            if (isJsonType(key, resource.inputs, metadata.types(), pulumiProvider)) {
+            const nativeType = getNativeType(key, resource.inputs, metadata.types(), pulumiProvider);
+            if (nativeType === NativeType.JSON) {
                 return value;
             }
 
             Object.entries(value).forEach(([k, v]) => {
-                result[toSdkName(k)] = normalizeObject([...key, k], v, cfnType);
+                k = nativeType === NativeType.ADDITIONAL_PROPERTIES ? k : toSdkName(k);
+                result[k] = normalizeObject([...key, k], v, cfnType);
             });
             return result;
         } catch (e) {
diff --git a/src/types.ts b/src/types.ts
index de58d1a..977c006 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -44,7 +44,7 @@ export interface AppOptions {
         typeName: string,
         props: any,
         options: pulumi.ResourceOptions,
-    ): ResourceMapping[] | undefined;
+    ): ResourceMapping | undefined;
 }
 
 /**
@@ -104,9 +104,37 @@ export interface AppComponent {
     readonly dependencies: CdkConstruct[];
 }
 
+/**
+ * Represents a mapping from a CloudFormation resource to a Pulumi resource
+ */
 export type Mapping<T extends pulumi.Resource> = {
+    /**
+     * The main resource that is being mapped.
+     * This Pulumi resource will have a `name` that matches the `logicalId`
+     * of the CloudFormation resource
+     */
     resource: T;
+
+    /**
+     * The CFN Resource type that is being mapped
+     */
     resourceType: string;
+
+    /**
+     * Other resources that are created as part of the mapping.
+     * For example, the AWS::IAM::Policy resource will also create
+     * aws.RolePolicyAttachment, aws.GroupPolicyAttachment, and aws.UserPolicyAttachment
+     * resources
+     */
+    otherResources?: T[];
+
+    /**
+     * Override the attributes that are available on the mapped resource.
+     * This is useful for when the attributes of the CFN resource do not match
+     * the attributes of the Pulumi resource, e.g. the CFN resource has an attribute
+     * called `resourceArn` and the Pulumi resource has an attribute called `arn`
+     * you would provide { attributes: { resourceArn: mappedResource.arn } }
+     */
     attributes?: { [name: string]: pulumi.Input<any> };
 };
 


Test patch:
diff --git a/tests/aws-resource-mappings.test.ts b/tests/aws-resource-mappings.test.ts
index 5f0f4ba..0a5add7 100644
--- a/tests/aws-resource-mappings.test.ts
+++ b/tests/aws-resource-mappings.test.ts
@@ -82,7 +82,7 @@ describe('AWS Resource Mappings', () => {
             {},
         );
         expect(aws.iam.GroupPolicyAttachment).toHaveBeenCalledWith(
-            `${logicalId}-0`,
+            `${logicalId}-group-0`,
             expect.objectContaining({
                 group: 'my-group',
                 policyArn: undefined,
@@ -90,7 +90,7 @@ describe('AWS Resource Mappings', () => {
             {},
         );
         expect(aws.iam.RolePolicyAttachment).toHaveBeenCalledWith(
-            `${logicalId}-0`,
+            `${logicalId}-role-0`,
             expect.objectContaining({
                 role: 'my-role',
                 policyArn: undefined,
@@ -98,7 +98,7 @@ describe('AWS Resource Mappings', () => {
             {},
         );
         expect(aws.iam.UserPolicyAttachment).toHaveBeenCalledWith(
-            `${logicalId}-0`,
+            `${logicalId}-user-0`,
             expect.objectContaining({
                 user: 'my-user',
                 policyArn: undefined,
@@ -147,7 +147,7 @@ describe('AWS Resource Mappings', () => {
             }),
         );
         expect(aws.sns.TopicPolicy).toHaveBeenCalledWith(
-            logicalId,
+            `${logicalId}-policy-1`,
             expect.objectContaining({
                 arn: 'my-other-topic',
                 policy: {
@@ -205,7 +205,7 @@ describe('AWS Resource Mappings', () => {
         );
 
         expect(aws.sqs.QueuePolicy).toHaveBeenCalledWith(
-            logicalId,
+            `${logicalId}-policy-1`,
             expect.objectContaining({
                 queueUrl: 'my-other-queue',
                 policy: {
diff --git a/tests/interop.test.ts b/tests/interop.test.ts
index dd6d951..9609de3 100644
--- a/tests/interop.test.ts
+++ b/tests/interop.test.ts
@@ -1,296 +1,340 @@
 import { normalize } from '../src/interop';
-import {
-    isJsonType,
-    Metadata,
-    NativeType,
-    processMetadataProperty,
-    PulumiProperty,
-    PulumiType,
-    UnknownCfnType,
-} from '../src/pulumi-metadata';
-import { PulumiProvider } from '../src/types';
 
-jest.mock(
-    '../schemas/aws-native-metadata.json',
-    () => {
-        return {
-            types: {
-                'aws-native:lambda:FunctionEnvironment': {
-                    type: 'object',
-                    properties: {
-                        variables: {
-                            type: 'object',
-                            additionalProperties: {
-                                type: 'string',
-                            },
-                            description:
-                                'Environment variable key-value pairs. For more information, see [Using Lambda environment variables](https://docs.aws.amazon.com/lambda/latest/dg/configuration-envvars.html).',
-                        },
-                    },
-                },
-            },
-            resources: {
-                'aws-native:lambda:Function': {
-                    cf: 'AWS::Lambda::Function',
-                    inputs: {
-                        environment: {
-                            $ref: '#/types/aws-native:lambda:FunctionEnvironment',
-                            description:
-                                'Environment variables that are accessible from function code during execution.',
-                        },
-                    },
-                },
-                'aws-native:s3:Bucket': {
-                    inputs: {
-                        bucketName: {
-                            type: 'string',
-                        },
-                    },
-                },
-            },
-        };
-    },
-    { virtual: true },
-);
 beforeEach(() => {
     jest.resetAllMocks();
     jest.resetModules();
 });
-describe('metadata', () => {
-    test('metadata.findResource', () => {
+describe('normalize', () => {
+    test('resource not in metadata', () => {
         // GIVEN
-        const meta = new Metadata(PulumiProvider.AWS_NATIVE);
-
+        jest.mock(
+            '../schemas/aws-native-metadata.json',
+            () => {
+                return {
+                    types: {},
+                    resources: {},
+                };
+            },
+            { virtual: true },
+        );
         // WHEN
-        const res = meta.findResource('AWS::S3::Bucket');
-
-        // THEN
-        expect(res.inputs).toEqual({ bucketName: { type: 'string' } });
-    });
-    test('metadata.findResource error', () => {
-        // GIVEN
-        const meta = new Metadata(PulumiProvider.AWS_NATIVE);
-
-        // THEN
-        expect(() => {
-            meta.findResource('AWS::S3::Error');
-        }).toThrow(UnknownCfnType);
-    });
-    test('metadata.types', () => {
-        // GIVEN
-        const meta = new Metadata(PulumiProvider.AWS_NATIVE);
+        const normalized = normalize(
+            {
+                SomeKey: {
+                    NestedKey: 'someValue',
+                },
+            },
+            'AWS::DummyService::DummyResource',
+        );
 
         // THEN
-        expect(meta.types()).toMatchObject({
-            'aws-native:lambda:FunctionEnvironment': expect.anything(),
+        expect(normalized).toEqual({
+            someKey: {
+                nestedKey: 'someValue',
+            },
         });
     });
-});
-
-describe('processMetadataProperty', () => {
-    test('non-object type', () => {
-        // GIVEN
-        const property: PulumiProperty = { type: 'string' };
-        const types = {};
-        const pulumiProvider = PulumiProvider.AWS_NATIVE;
-
-        // WHEN
-        const { nativeType, meta } = processMetadataProperty(property, types, pulumiProvider);
-
-        // THEN
-        expect(meta).toBeUndefined();
-        expect(nativeType).toEqual(NativeType.NON_JSON);
-    });
-
-    test('pulumi json type', () => {
-        // GIVEN
-        const property: PulumiProperty = { $ref: 'pulumi.json#/Any' };
-        const types = {};
-        const pulumiProvider = PulumiProvider.AWS_NATIVE;
-
-        // WHEN
-        const { nativeType, meta } = processMetadataProperty(property, types, pulumiProvider);
-
-        // THEN
-        expect(meta).toBeUndefined();
-        expect(nativeType).toEqual(NativeType.JSON);
-    });
-
-    test('pulumi type', () => {
-        // GIVEN
-        const property: PulumiProperty = { $ref: 'pulumi.somethingelse' };
-        const types = {};
-        const pulumiProvider = PulumiProvider.AWS_NATIVE;
-
-        // WHEN
-        const { nativeType, meta } = processMetadataProperty(property, types, pulumiProvider);
-
-        // THEN
-        expect(meta).toBeUndefined();
-        expect(nativeType).toEqual(NativeType.NON_JSON);
-    });
-
-    test('pulumi object type', () => {
-        // GIVEN
-        const property: PulumiProperty = { type: 'object' };
-        const types = {};
-        const pulumiProvider = PulumiProvider.AWS_NATIVE;
-
-        // WHEN
-        const { nativeType, meta } = processMetadataProperty(property, types, pulumiProvider);
-
-        // THEN
-        expect(meta).toBeUndefined();
-        expect(nativeType).toEqual(NativeType.JSON);
-    });
-
-    test('array non-object type', () => {
-        // GIVEN
-        const property: PulumiProperty = { type: 'array', items: { type: 'string' } };
-        const types = {};
-        const pulumiProvider = PulumiProvider.AWS_NATIVE;
-
-        // WHEN
-        const { nativeType, meta } = processMetadataProperty(property, types, pulumiProvider);
 
-        // THEN
-        expect(meta).toBeUndefined();
-        expect(nativeType).toEqual(NativeType.NON_JSON);
-    });
-
-    test('array object type', () => {
+    test('resource in metadata with json values', () => {
         // GIVEN
-        const property: PulumiProperty = { type: 'array', items: { type: 'object' } };
-        const types = {};
-        const pulumiProvider = PulumiProvider.AWS_NATIVE;
+        jest.mock(
+            '../schemas/aws-native-metadata.json',
+            () => {
+                return {
+                    types: {
+                        'aws-native:lambda:FunctionEnvironment': {
+                            type: 'object',
+                            properties: {
+                                variables: {
+                                    type: 'object',
+                                    additionalProperties: {
+                                        type: 'string',
+                                    },
+                                    description:
+                                        'Environment variable key-value pairs. For more information, see [Using Lambda environment variables](https://docs.aws.amazon.com/lambda/latest/dg/configuration-envvars.html).',
+                                },
+                            },
+                        },
+                    },
+                    resources: {
+                        'aws-native:lambda:Function': {
+                            cf: 'AWS::Lambda::Function',
+                            inputs: {
+                                environment: {
+                                    $ref: '#/types/aws-native:lambda:FunctionEnvironment',
+                                    description:
+                                        'Environment variables that are accessible from function code during execution.',
+                                },
+                            },
+                        },
+                        'aws-native:s3:Bucket': {
+                            inputs: {
+                                bucketName: {
+                                    type: 'string',
+                                },
+                            },
+                        },
+                    },
+                };
+            },
+            { virtual: true },
+        );
 
         // WHEN
-        const { nativeType, meta } = processMetadataProperty(property, types, pulumiProvider);
-
-        // THEN
-        expect(meta).toBeUndefined();
-        expect(nativeType).toEqual(NativeType.JSON);
-    });
-
-    test('ref to object type', () => {
-        // GIVEN
-        const property: PulumiProperty = { type: 'object', $ref: '#/types/aws-native:SomeType' };
-        const types: { [key: string]: PulumiType } = {
-            'aws-native:SomeType': {
-                properties: {
-                    SomeKey: {
-                        type: 'string',
+        const normalized = normalize(
+            {
+                Environment: {
+                    Variables: {
+                        Key: 'Value',
                     },
                 },
             },
-        };
-        const pulumiProvider = PulumiProvider.AWS_NATIVE;
-
-        // WHEN
-        const { nativeType, meta } = processMetadataProperty(property, types, pulumiProvider);
+            'AWS::Lambda::Function',
+        );
 
         // THEN
-        expect(meta).toEqual({ SomeKey: { type: 'string' } });
-        expect(nativeType).toBeUndefined();
-    });
-});
-
-describe('isJsonType', () => {
-    test('top level property: false', () => {
-        // GIVEN
-        const propName = ['Key'];
-        const properties: { [key: string]: PulumiProperty } = {
-            key: {
-                type: 'string',
+        expect(normalized).toEqual({
+            environment: {
+                variables: {
+                    Key: 'Value',
+                },
             },
-        };
-        const types: { [key: string]: PulumiType } = {};
-        const pulumiProvider = PulumiProvider.AWS_NATIVE;
-
-        // WHEN
-        const isJson = isJsonType(propName, properties, types, pulumiProvider);
-
-        // THEN
-        expect(isJson).toEqual(false);
+        });
     });
 
-    test('top level property: true', () => {
+    test('resource in metadata with additionalProperties values', () => {
         // GIVEN
-        const propName = ['Key'];
-        const properties: { [key: string]: PulumiProperty } = {
-            key: {
-                type: 'object',
+        jest.mock(
+            '../schemas/aws-native-metadata.json',
+            () => {
+                return {
+                    types: {
+                        'aws-native:apigateway:UsagePlanApiStage': {
+                            type: 'object',
+                            properties: {
+                                apiId: {
+                                    type: 'string',
+                                    description: 'API Id of the associated API stage in a usage plan.',
+                                },
+                                stage: {
+                                    type: 'string',
+                                    description: 'API stage name of the associated API stage in a usage plan.',
+                                },
+                                throttle: {
+                                    type: 'object',
+                                    additionalProperties: {
+                                        $ref: '#/types/aws-native:apigateway:UsagePlanThrottleSettings',
+                                    },
+                                    description:
+                                        'Map containing method level throttling information for API stage in a usage plan.',
+                                },
+                            },
+                        },
+                        'aws-native:apigateway:UsagePlanThrottleSettings': {
+                            type: 'object',
+                            properties: {
+                                burstLimit: {
+                                    type: 'integer',
+                                },
+                                rateLimit: {
+                                    type: 'number',
+                                },
+                            },
+                        },
+                    },
+                    resources: {
+                        'aws-native:apigateway:UsagePlan': {
+                            cf: 'AWS::ApiGateway::UsagePlan',
+                            inputs: {
+                                apiStages: {
+                                    type: 'array',
+                                    items: {
+                                        $ref: '#/types/aws-native:apigateway:UsagePlanApiStage',
+                                    },
+                                    description: 'The associated API stages of a usage plan.',
+                                },
+                            },
+                        },
+                    },
+                };
             },
-        };
-        const types: { [key: string]: PulumiType } = {};
-        const pulumiProvider = PulumiProvider.AWS_NATIVE;
-
+            { virtual: true },
+        );
         // WHEN
-        const isJson = isJsonType(propName, properties, types, pulumiProvider);
+        const normalized = normalize(
+            {
+                ApiStages: [
+                    {
+                        ApiId: 'api',
+                        Stage: 'stage',
+                        Throttle: {
+                            // These keys should not be normalized, but their properties should
+                            '/v1/toys/GET': {
+                                BurstLimit: 2,
+                                RateLimit: 10,
+                            },
+                        },
+                    },
+                ],
+            },
+            'AWS::ApiGateway::UsagePlan',
+        );
 
         // THEN
-        expect(isJson).toEqual(true);
+        expect(normalized).toEqual({
+            apiStages: [
+                {
+                    apiId: 'api',
+                    stage: 'stage',
+                    throttle: {
+                        '/v1/toys/GET': {
+                            burstLimit: 2,
+                            rateLimit: 10,
+                        },
+                    },
+                },
+            ],
+        });
     });
 
-    test('nested property: true', () => {
+    test('resource in metadata with additionalProperties nested $ref values', () => {
         // GIVEN
-        const propName = ['Key', 'NestedKey'];
-        const properties: { [key: string]: PulumiProperty } = {
-            key: {
-                $ref: '#/types/aws-native:SomeType',
+        jest.mock(
+            '../schemas/aws-native-metadata.json',
+            () => {
+                return {
+                    types: {
+                        'aws-native:amplifyuibuilder:ComponentBindingPropertiesValue': {
+                            type: 'object',
+                            properties: {
+                                bindingProperties: {
+                                    $ref: '#/types/aws-native:amplifyuibuilder:ComponentBindingPropertiesValueProperties',
+                                    description: 'Describes the properties to customize with data at runtime.',
+                                },
+                                defaultValue: {
+                                    type: 'string',
+                                    description: 'The default value of the property.',
+                                },
+                                type: {
+                                    type: 'string',
+                                    description: 'The property type.',
+                                },
+                            },
+                        },
+                        'aws-native:amplifyuibuilder:ComponentBindingPropertiesValueProperties': {
+                            type: 'object',
+                            properties: {
+                                bucket: {
+                                    type: 'string',
+                                    description: 'An Amazon S3 bucket.',
+                                },
+                            },
+                        },
+                    },
+                    resources: {
+                        'aws-native:amplifyuibuilder:Component': {
+                            cf: 'AWS::AmplifyUIBuilder::Component',
+                            inputs: {
+                                bindingProperties: {
+                                    type: 'object',
+                                    additionalProperties: {
+                                        $ref: '#/types/aws-native:amplifyuibuilder:ComponentBindingPropertiesValue',
+                                    },
+                                    description:
+                                        "The information to connect a component's properties to data at runtime. You can't specify `tags` as a valid property for `bindingProperties` .",
+                                },
+                            },
+                        },
+                    },
+                };
             },
-        };
-        const types: { [key: string]: PulumiType } = {
-            'aws-native:SomeType': { properties: { nestedKey: { type: 'object' } } },
-        };
-        const pulumiProvider = PulumiProvider.AWS_NATIVE;
-
-        // WHEN
-        const isJson = isJsonType(propName, properties, types, pulumiProvider);
-
-        // THEN
-        expect(isJson).toEqual(true);
-    });
-});
-
-describe('normalize', () => {
-    test('resource not in metadata', () => {
+            { virtual: true },
+        );
         // WHEN
         const normalized = normalize(
             {
-                SomeKey: {
-                    NestedKey: 'someValue',
+                BindingProperties: {
+                    SomeProp: {
+                        BindingProperties: {
+                            Bucket: 'someBucket',
+                        },
+                        DefaultValue: 'someval',
+                        Type: 'sometype',
+                    },
                 },
             },
-            'AWS::DummyService::DummyResource',
+            'AWS::AmplifyUIBuilder::Component',
         );
 
         // THEN
         expect(normalized).toEqual({
-            someKey: {
-                nestedKey: 'someValue',
+            bindingProperties: {
+                SomeProp: {
+                    bindingProperties: {
+                        bucket: 'someBucket',
+                    },
+                    defaultValue: 'someval',
+                    type: 'sometype',
+                },
             },
         });
     });
 
-    test('resource in metadata with json values', () => {
+    test('resource in metadata with additionalProperties nested json values', () => {
+        // GIVEN
+        jest.mock(
+            '../schemas/aws-native-metadata.json',
+            () => {
+                return {
+                    types: {
+                        'aws-native:inspectorv2:CisScanConfigurationCisTargets': {
+                            type: 'object',
+                            properties: {
+                                targetResourceTags: {
+                                    type: 'object',
+                                    additionalProperties: {
+                                        $ref: 'pulumi.json#/Any',
+                                    },
+                                },
+                            },
+                        },
+                    },
+                    resources: {
+                        'aws-native:inspectorv2:CisScanConfiguration': {
+                            cf: 'AWS::InspectorV2::CisScanConfiguration',
+                            inputs: {
+                                targets: {
+                                    $ref: '#/types/aws-native:inspectorv2:CisScanConfigurationCisTargets',
+                                    description: "The CIS scan configuration's targets.",
+                                },
+                            },
+                        },
+                    },
+                };
+            },
+            { virtual: true },
+        );
         // WHEN
         const normalized = normalize(
             {
-                Environment: {
-                    Variables: {
-                        Key: 'Value',
+                Targets: {
+                    TargetResourceTags: {
+                        SomeTag: {
+                            NestedProp: 'someval',
+                        },
                     },
                 },
             },
-            'AWS::Lambda::Function',
+            'AWS::InspectorV2::CisScanConfiguration',
         );
 
         // THEN
         expect(normalized).toEqual({
-            environment: {
-                variables: {
-                    Key: 'Value',
+            targets: {
+                targetResourceTags: {
+                    SomeTag: {
+                        NestedProp: 'someval',
+                    },
                 },
             },
         });
diff --git a/tests/pulumi-metadata.test.ts b/tests/pulumi-metadata.test.ts
new file mode 100644
index 0000000..48b71b1
--- /dev/null
+++ b/tests/pulumi-metadata.test.ts
@@ -0,0 +1,362 @@
+import {
+    getNativeType,
+    Metadata,
+    NativeType,
+    processMetadataProperty,
+    PulumiProperty,
+    PulumiType,
+    UnknownCfnType,
+} from '../src/pulumi-metadata';
+import { PulumiProvider } from '../src/types';
+
+jest.mock(
+    '../schemas/aws-native-metadata.json',
+    () => {
+        return {
+            types: {
+                'aws-native:lambda:FunctionEnvironment': {
+                    type: 'object',
+                    properties: {
+                        variables: {
+                            type: 'object',
+                            additionalProperties: {
+                                type: 'string',
+                            },
+                            description:
+                                'Environment variable key-value pairs. For more information, see [Using Lambda environment variables](https://docs.aws.amazon.com/lambda/latest/dg/configuration-envvars.html).',
+                        },
+                    },
+                },
+            },
+            resources: {
+                'aws-native:lambda:Function': {
+                    cf: 'AWS::Lambda::Function',
+                    inputs: {
+                        environment: {
+                            $ref: '#/types/aws-native:lambda:FunctionEnvironment',
+                            description:
+                                'Environment variables that are accessible from function code during execution.',
+                        },
+                    },
+                },
+                'aws-native:s3:Bucket': {
+                    inputs: {
+                        bucketName: {
+                            type: 'string',
+                        },
+                    },
+                },
+            },
+        };
+    },
+    { virtual: true },
+);
+beforeEach(() => {
+    jest.resetAllMocks();
+    jest.resetModules();
+});
+
+describe('metadata', () => {
+    test('metadata.findResource', () => {
+        // GIVEN
+        const meta = new Metadata(PulumiProvider.AWS_NATIVE);
+
+        // WHEN
+        const res = meta.findResource('AWS::S3::Bucket');
+
+        // THEN
+        expect(res.inputs).toEqual({ bucketName: { type: 'string' } });
+    });
+    test('metadata.findResource error', () => {
+        // GIVEN
+        const meta = new Metadata(PulumiProvider.AWS_NATIVE);
+
+        // THEN
+        expect(() => {
+            meta.findResource('AWS::S3::Error');
+        }).toThrow(UnknownCfnType);
+    });
+    test('metadata.types', () => {
+        // GIVEN
+        const meta = new Metadata(PulumiProvider.AWS_NATIVE);
+
+        // THEN
+        expect(meta.types()).toMatchObject({
+            'aws-native:lambda:FunctionEnvironment': expect.anything(),
+        });
+    });
+});
+
+describe('processMetadataProperty', () => {
+    test('non-object type', () => {
+        // GIVEN
+        const property: PulumiProperty = { type: 'string' };
+        const types = {};
+        const pulumiProvider = PulumiProvider.AWS_NATIVE;
+
+        // WHEN
+        const { nativeType, meta } = processMetadataProperty(property, types, pulumiProvider);
+
+        // THEN
+        expect(meta).toBeUndefined();
+        expect(nativeType).toEqual(NativeType.NON_JSON);
+    });
+
+    test('pulumi json type', () => {
+        // GIVEN
+        const property: PulumiProperty = { $ref: 'pulumi.json#/Any' };
+        const types = {};
+        const pulumiProvider = PulumiProvider.AWS_NATIVE;
+
+        // WHEN
+        const { nativeType, meta } = processMetadataProperty(property, types, pulumiProvider);
+
+        // THEN
+        expect(meta).toBeUndefined();
+        expect(nativeType).toEqual(NativeType.JSON);
+    });
+
+    test('pulumi type', () => {
+        // GIVEN
+        const property: PulumiProperty = { $ref: 'pulumi.somethingelse' };
+        const types = {};
+        const pulumiProvider = PulumiProvider.AWS_NATIVE;
+
+        // WHEN
+        const { nativeType, meta } = processMetadataProperty(property, types, pulumiProvider);
+
+        // THEN
+        expect(meta).toBeUndefined();
+        expect(nativeType).toEqual(NativeType.NON_JSON);
+    });
+
+    test('pulumi object type is JSON', () => {
+        // GIVEN
+        const property: PulumiProperty = { type: 'object' };
+        const types = {};
+        const pulumiProvider = PulumiProvider.AWS_NATIVE;
+
+        // WHEN
+        const { nativeType, meta } = processMetadataProperty(property, types, pulumiProvider);
+
+        // THEN
+        expect(meta).toBeUndefined();
+        expect(nativeType).toEqual(NativeType.JSON);
+    });
+
+    test('pulumi object type with properties is NON_JSON', () => {
+        // GIVEN
+        const property: PulumiProperty = { type: 'object', properties: { someProp: { type: 'string' } } };
+        const types = {};
+        const pulumiProvider = PulumiProvider.AWS_NATIVE;
+
+        // WHEN
+        const { nativeType, meta } = processMetadataProperty(property, types, pulumiProvider);
+
+        // THEN
+        expect(meta).toBeUndefined();
+        expect(nativeType).toEqual(NativeType.NON_JSON);
+    });
+
+    test('pulumi object type with additionalProperties is ADDITIONAL_PROPERTIES', () => {
+        // GIVEN
+        const property: PulumiProperty = {
+            type: 'object',
+            additionalProperties: { $ref: '#/types/aws-native:SomeType' },
+        };
+        const types: { [key: string]: PulumiType } = {
+            'aws-native:SomeType': {
+                properties: {
+                    SomeKey: {
+                        type: 'string',
+                    },
+                },
+            },
+        };
+        const pulumiProvider = PulumiProvider.AWS_NATIVE;
+
+        // WHEN
+        const { nativeType, meta } = processMetadataProperty(property, types, pulumiProvider);
+
+        // THEN
+        expect(meta).toEqual({ SomeKey: { type: 'string' } });
+        expect(nativeType).toEqual(NativeType.ADDITIONAL_PROPERTIES);
+    });
+
+    test('array non-object type is NON_JSON', () => {
+        // GIVEN
+        const property: PulumiProperty = { type: 'array', items: { type: 'string' } };
+        const types = {};
+        const pulumiProvider = PulumiProvider.AWS_NATIVE;
+
+        // WHEN
+        const { nativeType, meta } = processMetadataProperty(property, types, pulumiProvider);
+
+        // THEN
+        expect(meta).toBeUndefined();
+        expect(nativeType).toEqual(NativeType.NON_JSON);
+    });
+
+    test('array object type', () => {
+        // GIVEN
+        const property: PulumiProperty = { type: 'array', items: { type: 'object' } };
+        const types = {};
+        const pulumiProvider = PulumiProvider.AWS_NATIVE;
+
+        // WHEN
+        const { nativeType, meta } = processMetadataProperty(property, types, pulumiProvider);
+
+        // THEN
+        expect(meta).toBeUndefined();
+        expect(nativeType).toEqual(NativeType.JSON);
+    });
+
+    test('ref to object type', () => {
+        // GIVEN
+        const property: PulumiProperty = { type: 'object', $ref: '#/types/aws-native:SomeType' };
+        const types: { [key: string]: PulumiType } = {
+            'aws-native:SomeType': {
+                properties: {
+                    SomeKey: {
+                        type: 'string',
+                    },
+                },
+            },
+        };
+        const pulumiProvider = PulumiProvider.AWS_NATIVE;
+
+        // WHEN
+        const { nativeType, meta } = processMetadataProperty(property, types, pulumiProvider);
+
+        // THEN
+        expect(meta).toEqual({ SomeKey: { type: 'string' } });
+        expect(nativeType).toBeUndefined();
+    });
+});
+
+describe('isJsonType', () => {
+    test('top level property: false', () => {
+        // GIVEN
+        const propName = ['Key'];
+        const properties: { [key: string]: PulumiProperty } = {
+            key: {
+                type: 'string',
+            },
+        };
+        const types: { [key: string]: PulumiType } = {};
+        const pulumiProvider = PulumiProvider.AWS_NATIVE;
+
+        // WHEN
+        const nativeType = getNativeType(propName, properties, types, pulumiProvider);
+
+        // THEN
+        expect(nativeType).toEqual(NativeType.NON_JSON);
+    });
+
+    test('top level property: true', () => {
+        // GIVEN
+        const propName = ['Key'];
+        const properties: { [key: string]: PulumiProperty } = {
+            key: {
+                type: 'object',
+            },
+        };
+        const types: { [key: string]: PulumiType } = {};
+        const pulumiProvider = PulumiProvider.AWS_NATIVE;
+
+        // WHEN
+        const nativeType = getNativeType(propName, properties, types, pulumiProvider);
+
+        // THEN
+        expect(nativeType).toEqual(NativeType.JSON);
+    });
+
+    test('nested property: true', () => {
+        // GIVEN
+        const propName = ['Key', 'NestedKey'];
+        const properties: { [key: string]: PulumiProperty } = {
+            key: {
+                $ref: '#/types/aws-native:SomeType',
+            },
+        };
+        const types: { [key: string]: PulumiType } = {
+            'aws-native:SomeType': { properties: { nestedKey: { type: 'object' } } },
+        };
+        const pulumiProvider = PulumiProvider.AWS_NATIVE;
+
+        // WHEN
+        const nativeType = getNativeType(propName, properties, types, pulumiProvider);
+
+        // THEN
+        expect(nativeType).toEqual(NativeType.JSON);
+    });
+
+    test('additionalProperties with $ref', () => {
+        // GIVEN
+        const propName = ['Key'];
+        const properties: { [key: string]: PulumiProperty } = {
+            key: {
+                type: 'object',
+                additionalProperties: {
+                    $ref: '#/types/aws-native:SomeType',
+                },
+            },
+        };
+        const types: { [key: string]: PulumiType } = {
+            'aws-native:SomeType': { properties: { nestedKey: { type: 'object' } } },
+        };
+        const pulumiProvider = PulumiProvider.AWS_NATIVE;
+
+        // WHEN
+        const nativeType = getNativeType(propName, properties, types, pulumiProvider);
+
+        // THEN
+        expect(nativeType).toEqual(NativeType.ADDITIONAL_PROPERTIES);
+    });
+
+    test('additionalProperties $ref with nested property, json', () => {
+        // GIVEN
+        const propName = ['Key', 'NestedKey'];
+        const properties: { [key: string]: PulumiProperty } = {
+            key: {
+                type: 'object',
+                additionalProperties: {
+                    $ref: '#/types/aws-native:SomeType',
+                },
+            },
+        };
+        const types: { [key: string]: PulumiType } = {
+            'aws-native:SomeType': { properties: { nestedKey: { type: 'object' } } },
+        };
+        const pulumiProvider = PulumiProvider.AWS_NATIVE;
+
+        // WHEN
+        const nativeType = getNativeType(propName, properties, types, pulumiProvider);
+
+        // THEN
+        expect(nativeType).toEqual(NativeType.JSON);
+    });
+
+    test('additionalProperties $ref with nested property, non-json', () => {
+        // GIVEN
+        const propName = ['Key', 'NestedKey'];
+        const properties: { [key: string]: PulumiProperty } = {
+            key: {
+                type: 'object',
+                additionalProperties: {
+                    $ref: '#/types/aws-native:SomeType',
+                },
+            },
+        };
+        const types: { [key: string]: PulumiType } = {
+            'aws-native:SomeType': { properties: { nestedKey: { type: 'string' } } },
+        };
+        const pulumiProvider = PulumiProvider.AWS_NATIVE;
+
+        // WHEN
+        const nativeType = getNativeType(propName, properties, types, pulumiProvider);
+
+        // THEN
+        expect(nativeType).toEqual(NativeType.NON_JSON);
+    });
+});