From fedd16b753b2d606271568192e8e5fc91c868837 Mon Sep 17 00:00:00 2001 From: Eduardo Rodrigues Date: Thu, 30 Jul 2020 01:56:18 +0200 Subject: [PATCH 1/7] improve model deserialization --- global.d.ts | 7 + jest.config.js | 1 + package-lock.json | 11 +- package.json | 3 +- python/rpdk/typescript/codegen.py | 11 +- python/rpdk/typescript/data/tsconfig.json | 1 + python/rpdk/typescript/resolver.py | 45 ++++- python/rpdk/typescript/templates/models.ts | 95 ++++++++- src/index.ts | 1 + src/interface.ts | 83 ++++---- src/recast.ts | 85 ++++++++ src/utils.ts | 4 + tests/data/sample-model.ts | 219 +++++++++++++++++++++ tests/lib/interface.test.ts | 35 ++-- tests/lib/recast.test.ts | 144 ++++++++++++++ tsconfig.json | 1 + 16 files changed, 680 insertions(+), 66 deletions(-) create mode 100644 src/recast.ts create mode 100644 tests/data/sample-model.ts create mode 100644 tests/lib/recast.test.ts diff --git a/global.d.ts b/global.d.ts index 380edea..3bf29aa 100644 --- a/global.d.ts +++ b/global.d.ts @@ -15,4 +15,11 @@ declare global { */ toJSON(): Array<[K, V]>; } + + interface BigInt { + /** + * Defines the default JSON representation of a BigInt to be a number. + */ + toJSON(): number; + } } diff --git a/jest.config.js b/jest.config.js index e4863c0..1504a17 100644 --- a/jest.config.js +++ b/jest.config.js @@ -16,4 +16,5 @@ module.exports = { coverageDirectory: 'coverage/ts', collectCoverage: true, coverageReporters: ['json', 'lcov', 'text'], + coveragePathIgnorePatterns: ['/node_modules/', '/tests/data/'], }; diff --git a/package-lock.json b/package-lock.json index 95e9d81..3eff058 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1517,6 +1517,11 @@ "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", "dev": true }, + "class-transformer": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.2.3.tgz", + "integrity": "sha512-qsP+0xoavpOlJHuYsQJsN58HXSl8Jvveo+T37rEvCEeRfMWoytAyR0Ua/YsFgpM6AZYZ/og2PJwArwzJl1aXtQ==" + }, "class-utils": { "version": "0.3.6", "resolved": "https://registry.npmjs.org/class-utils/-/class-utils-0.3.6.tgz", @@ -4504,9 +4509,9 @@ } }, "lodash": { - "version": "4.17.15", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz", - "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==", + "version": "4.17.19", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.19.tgz", + "integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ==", "dev": true }, "lodash.memoize": { diff --git a/package.json b/package.json index 4d3dee4..7096f23 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "test:debug": "npx --node-arg=--inspect jest --runInBand" }, "engines": { - "node": ">=10.0.0", + "node": ">=10.4.0", "npm": ">=5.6.0" }, "repository": { @@ -34,6 +34,7 @@ "homepage": "https://github.com/eduardomourar/cloudformation-cli-typescript-plugin#readme", "dependencies": { "autobind-decorator": "^2.4.0", + "class-transformer": "^0.2.3", "promise-sequential": "^1.1.1", "reflect-metadata": "^0.1.13", "tombok": "https://github.com/eduardomourar/tombok/releases/download/v0.0.1/tombok-0.0.1.tgz", diff --git a/python/rpdk/typescript/codegen.py b/python/rpdk/typescript/codegen.py index c27ddcf..5e3c140 100644 --- a/python/rpdk/typescript/codegen.py +++ b/python/rpdk/typescript/codegen.py @@ -10,7 +10,7 @@ from rpdk.core.jsonutils.resolver import ContainerType, resolve_models from rpdk.core.plugin_base import LanguagePlugin -from .resolver import contains_model, translate_type +from .resolver import contains_model, get_inner_type, translate_type from .utils import safe_reserved LOG = logging.getLogger(__name__) @@ -42,6 +42,7 @@ def __init__(self): ) self.env.filters["translate_type"] = translate_type self.env.filters["contains_model"] = contains_model + self.env.filters["get_inner_type"] = get_inner_type self.env.filters["safe_reserved"] = safe_reserved self.env.globals["ContainerType"] = ContainerType self.namespace = None @@ -159,7 +160,13 @@ def generate(self, project): LOG.debug("Writing file: %s", path) template = self.env.get_template("models.ts") contents = template.render( - lib_name=SUPPORT_LIB_NAME, type_name=project.type_name, models=models, + lib_name=SUPPORT_LIB_NAME, + type_name=project.type_name, + models=models, + primaryIdentifier=project.schema.get("primaryIdentifier", []), + additionalIdentifiers=project.schema.get( + "additionalIdentifiers", [] + ), ) project.overwrite(path, contents) diff --git a/python/rpdk/typescript/data/tsconfig.json b/python/rpdk/typescript/data/tsconfig.json index ffbcc7d..db778b5 100644 --- a/python/rpdk/typescript/data/tsconfig.json +++ b/python/rpdk/typescript/data/tsconfig.json @@ -8,6 +8,7 @@ "moduleResolution": "node", "allowJs": true, "experimentalDecorators": true, + "emitDecoratorMetadata": true, "outDir": "dist" }, "include": [ diff --git a/python/rpdk/typescript/resolver.py b/python/rpdk/typescript/resolver.py index 58f6c98..d9f77f3 100644 --- a/python/rpdk/typescript/resolver.py +++ b/python/rpdk/typescript/resolver.py @@ -2,11 +2,50 @@ PRIMITIVE_TYPES = { "string": "string", - "integer": "number", + "integer": "bigint", "boolean": "boolean", "number": "number", - UNDEFINED: "Object", + UNDEFINED: "object", } +PRIMITIVE_WRAPPERS = { + "string": "String", + "bigint": "BigInt", + "boolean": "Boolean", + "number": "Number", + "object": "Object", +} + + +class InnerType: + def __init__(self, item_type): + self.primitive = False + self.classes = [] + self.type = self.resolve_type(item_type) + self.wrapper_type = self.type + if self.primitive: + self.wrapper_type = PRIMITIVE_WRAPPERS[self.type] + + def resolve_type(self, resolved_type): + if resolved_type.container == ContainerType.PRIMITIVE: + self.primitive = True + return PRIMITIVE_TYPES[resolved_type.type] + if resolved_type.container == ContainerType.MULTIPLE: + self.primitive = True + return "object" + if resolved_type.container == ContainerType.MODEL: + return resolved_type.type + if resolved_type.container == ContainerType.DICT: + self.classes.append('Map') + elif resolved_type.container == ContainerType.LIST: + self.classes.append('Array') + elif resolved_type.container == ContainerType.SET: + self.classes.append('Set') + + return self.resolve_type(resolved_type.type) + + +def get_inner_type(resolved_type): + return InnerType(resolved_type) def translate_type(resolved_type): @@ -14,6 +53,8 @@ def translate_type(resolved_type): return resolved_type.type if resolved_type.container == ContainerType.PRIMITIVE: return PRIMITIVE_TYPES[resolved_type.type] + if resolved_type.container == ContainerType.MULTIPLE: + return "object" item_type = translate_type(resolved_type.type) diff --git a/python/rpdk/typescript/templates/models.ts b/python/rpdk/typescript/templates/models.ts index e45b7fe..0256719 100644 --- a/python/rpdk/typescript/templates/models.ts +++ b/python/rpdk/typescript/templates/models.ts @@ -1,14 +1,103 @@ // This is a generated file. Modifications will be overwritten. -import { BaseModel, Optional } from '{{lib_name}}'; +import { BaseModel, Dict, Optional, transformValue } from '{{lib_name}}'; +import { Exclude, Expose, Type, Transform } from 'class-transformer'; {% for model, properties in models.items() %} -export class {{ model|uppercase_first_letter }}{% if model == "ResourceModel" %} extends BaseModel{% endif %} { +export class {{ model|uppercase_first_letter }} extends BaseModel { ['constructor']: typeof {{ model|uppercase_first_letter }}; + + {% if model == "ResourceModel" %} + @Exclude() public static readonly TYPE_NAME: string = '{{ type_name }}'; + {% for identifier in primaryIdentifier %} + {% set components = identifier.split("/") %} + @Exclude() + protected readonly IDENTIFIER_KEY_{{ components[2:]|join('_')|upper }}: string = '{{ identifier }}'; + {% endfor -%} + + {% for identifiers in additionalIdentifiers %} + {% for identifier in identifiers %} + {% set components = identifier.split("/") %} + @Exclude() + protected readonly IDENTIFIER_KEY_{{ components[2:]|join('_')|upper }}: string = '{{ identifier }}'; + {% endfor %} + {% endfor %} + {% endif %} + {% for name, type in properties.items() %} - {{ name|safe_reserved }}: Optional<{{ type|translate_type }}>; + {% set translated_type = type|translate_type %} + {% set inner_type = type|get_inner_type %} + @Expose({ name: '{{ name }}' }) + {% if type|contains_model %} + @Type(() => {{ inner_type.type }}) + {% else %} + @Transform( + (value: any, obj: any) => + transformValue({{ inner_type.wrapper_type }}, '{{ name|lowercase_first_letter|safe_reserved }}', value, obj, [{{ inner_type.classes|join(', ') }}]), + { + toClassOnly: true, + } + ) + {% endif %} + {{ name|lowercase_first_letter|safe_reserved }}: Optional<{{ translated_type }}>; + {% endfor %} + + {% if model == "ResourceModel" %} + @Exclude() + public getPrimaryIdentifier(): Dict { + const identifier: Dict = {}; + {% for identifier in primaryIdentifier %} + {% set components = identifier.split("/") %} + if (this.{{components[2]|lowercase_first_letter}} != null + {%- for i in range(4, components|length + 1) -%} + {#- #} && this + {%- for component in components[2:i] -%} .{{component|lowercase_first_letter}} {%- endfor -%} + {#- #} != null + {%- endfor -%} + ) { + identifier[this.IDENTIFIER_KEY_{{ components[2:]|join('_')|upper }}] = this{% for component in components[2:] %}.{{component|lowercase_first_letter}}{% endfor %}; + } + + {% endfor %} + // only return the identifier if it can be used, i.e. if all components are present + return identifier.length === {{ primaryIdentifier|length }} ? identifier : null; + } + + @Exclude() + public getAdditionalIdentifiers(): Array { + const identifiers: Array = new Array(); + {% for identifiers in additionalIdentifiers %} + if (this.getIdentifier {%- for identifier in identifiers -%} _{{identifier.split("/")[-1]|uppercase_first_letter}} {%- endfor -%} () != null) { + identifiers.push(this.getIdentifier{% for identifier in identifiers %}_{{identifier.split("/")[-1]|uppercase_first_letter}}{% endfor %}()); + } + {% endfor %} + // only return the identifiers if any can be used + return identifiers.length === 0 ? null : identifiers; + } + {% for identifiers in additionalIdentifiers %} + + @Exclude() + public getIdentifier {%- for identifier in identifiers -%} _{{identifier.split("/")[-1]|uppercase_first_letter}} {%- endfor -%} (): Dict { + const identifier: Dict = {}; + {% for identifier in identifiers %} + {% set components = identifier.split("/") %} + if ((this as any).{{components[2]|lowercase_first_letter}} != null + {%- for i in range(4, components|length + 1) -%} + {#- #} && (this as any) + {%- for component in components[2:i] -%} .{{component|lowercase_first_letter}} {%- endfor -%} + {#- #} != null + {%- endfor -%} + ) { + identifier[this.IDENTIFIER_KEY_{{ components[2:]|join('_')|upper }}] = (this as any){% for component in components[2:] %}.{{component|lowercase_first_letter}}{% endfor %}; + } + + {% endfor %} + // only return the identifier if it can be used, i.e. if all components are present + return identifier.length === {{ identifiers|length }} ? identifier : null; + } {% endfor %} + {% endif %} } {% endfor -%} diff --git a/src/index.ts b/src/index.ts index fc327aa..74fc187 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,4 +4,5 @@ export * from './log-delivery'; export * from './metrics'; export * from './proxy'; export * from './resource'; +export * from './recast'; export * from './utils'; diff --git a/src/interface.ts b/src/interface.ts index e5bb81e..0cae7fd 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -1,12 +1,15 @@ +import 'reflect-metadata'; import { ClientRequestToken, LogicalResourceId, NextToken, } from 'aws-sdk/clients/cloudformation'; -import { allArgsConstructor, builder } from 'tombok'; +import { classToPlain, Exclude, plainToClass } from 'class-transformer'; export type Optional = T | undefined | null; +export type Dict = Record; + export interface Callable, T> { (...args: R): T; } @@ -60,6 +63,47 @@ export interface Credentials { sessionToken: string; } +/** + * Base class for data transfer objects that will contain + * serialization and deserialization mechanisms. + */ +export abstract class BaseDto { + constructor(partial?: unknown) { + if (partial) { + Object.assign(this, partial); + } + } + + @Exclude() + public serialize(): Dict { + const data: Dict = classToPlain(this); + for (const key in data) { + const value = data[key]; + if (value == null) { + delete data[key]; + } + } + return data; + } + + public static deserialize(this: new () => T, jsonData: Dict): T { + if (jsonData == null) { + return null; + } + return plainToClass(this, jsonData, { enableImplicitConversion: false }); + } + + @Exclude() + public toJSON(key?: string): Dict { + return this.serialize(); + } + + @Exclude() + public toObject(): Dict { + return this.serialize(); + } +} + export interface RequestContext { invocation: number; callbackContext: T; @@ -67,54 +111,27 @@ export interface RequestContext { cloudWatchEventsTargetId: string; } -@builder -@allArgsConstructor -export class BaseModel { +export class BaseModel extends BaseDto { ['constructor']: typeof BaseModel; - protected static readonly TYPE_NAME?: string; - constructor(...args: any[]) {} - public static builder(): any { - return null; - } + @Exclude() + protected static readonly TYPE_NAME?: string; + @Exclude() public getTypeName(): string { return Object.getPrototypeOf(this).constructor.TYPE_NAME; } - - public serialize(): Map { - const data: Map = new Map(Object.entries(this)); - data.forEach((value: any, key: string) => { - if (value == null) { - data.delete(key); - } - }); - return data; - } - - public static deserialize(jsonData: any): ThisType { - return new this(new Map(Object.entries(jsonData))); - } - - public toObject(): any { - // @ts-ignore - const obj = Object.fromEntries(this.serialize().entries()); - return obj; - } } -@allArgsConstructor export class BaseResourceHandlerRequest { public clientRequestToken: ClientRequestToken; public desiredResourceState?: T; public previousResourceState?: T; public logicalResourceIdentifier?: LogicalResourceId; public nextToken?: NextToken; - - constructor(...args: any[]) {} } -export interface CfnResponse { +export interface CfnResponse { errorCode?: HandlerErrorCode; status: OperationStatus; message: string; diff --git a/src/recast.ts b/src/recast.ts new file mode 100644 index 0000000..59d9be9 --- /dev/null +++ b/src/recast.ts @@ -0,0 +1,85 @@ +import { InvalidRequest } from './exceptions'; + +type primitive = string | number | boolean | bigint | object; +type PrimitiveConstructor = + | StringConstructor + | NumberConstructor + | BooleanConstructor + | BigIntConstructor + | ObjectConstructor; +const LOGGER = console; + +/** + * CloudFormation recasts all primitive types as strings, this tries to set them back to + * the types defined in the model class + */ +export const recastPrimitive = ( + cls: PrimitiveConstructor, + k: string, + v: string +): primitive => { + if (Object.is(cls, Object)) { + // If the type is plain object, we cannot guess what the original type was, so we leave + // it as a string + return v; + } + if (Object.is(cls, Boolean)) { + if (v.toLowerCase() === 'true') { + return true; + } + if (v.toLowerCase() === 'false') { + return false; + } + throw new InvalidRequest(`Value for ${k} "${v}" is not boolean`); + } + return cls(v).valueOf(); +}; + +export const transformValue = ( + cls: any, + key: string, + value: any, + obj: any, + classes: any[] = [], + index = 0 +): primitive => { + if (value == null) { + return value; + } + classes.push(cls); + const currentClass = classes[index || 0]; + if (value instanceof Map || Object.is(currentClass, Map)) { + const temp = new Map(value instanceof Map ? value : Object.entries(value)); + temp.forEach((item: any, itemKey: string) => { + temp.set(itemKey, transformValue(cls, key, item, obj, classes, index + 1)); + }); + return new Map(temp); + } else if (value instanceof Set || Object.is(currentClass, Set)) { + const temp = Array.from(value).map((item: any) => { + return transformValue(cls, key, item, obj, classes, index + 1); + }); + return new Set(temp); + } else if (Array.isArray(value) || Array.isArray(currentClass)) { + return value.map((item: any) => { + return transformValue(cls, key, item, obj, classes, index + 1); + }); + } else { + // if type is plain object, we leave it as is + if (Object.is(cls, Object)) { + return value; + } + if ( + Object.is(cls, String) || + Object.is(cls, Number) || + Object.is(cls, Boolean) || + Object.is(cls, BigInt) + ) { + if (typeof value === 'string') { + return recastPrimitive(cls, key, value); + } + return value; + } else { + throw new InvalidRequest(`Unsupported type: ${typeof value} for ${key}`); + } + } +}; diff --git a/src/utils.ts b/src/utils.ts index fc53174..fed78cc 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -191,3 +191,7 @@ Map.prototype.toJSON = function (this: Map): Array<[K, V]> { // @ts-ignore return Object.fromEntries(this); }; + +BigInt.prototype.toJSON = function (): number { + return Number(this); +}; diff --git a/tests/data/sample-model.ts b/tests/data/sample-model.ts new file mode 100644 index 0000000..5560da5 --- /dev/null +++ b/tests/data/sample-model.ts @@ -0,0 +1,219 @@ +// This file represents a model built from a complex schema to test that ser/de is +// happening as expected +/* eslint-disable @typescript-eslint/no-use-before-define */ +import { BaseModel, Optional, transformValue } from '../../src'; +import { Exclude, Expose, Transform, Type } from 'class-transformer'; + +export class ResourceModel extends BaseModel { + ['constructor']: typeof ResourceModel; + + @Exclude() + public static readonly TYPE_NAME: string = 'Organization::Service::ComplexResource'; + + @Expose({ name: 'ListListAny' }) + @Transform( + (value, obj) => + transformValue(Object, 'listListAny', value, obj, [Array, Array]), + { + toClassOnly: true, + } + ) + listListAny?: Optional>>; + @Expose({ name: 'ListSetInt' }) + @Transform( + (value, obj) => transformValue(BigInt, 'listSetInt', value, obj, [Array, Set]), + { + toClassOnly: true, + } + ) + listSetInt?: Optional>>; + @Expose({ name: 'ListListInt' }) + @Transform( + (value, obj) => + transformValue(BigInt, 'listListInt', value, obj, [Array, Array]), + { + toClassOnly: true, + } + ) + listListInt?: Optional>>; + @Expose({ name: 'ASet' }) + @Transform((value, obj) => transformValue(Object, 'aSet', value, obj, [Set]), { + toClassOnly: true, + }) + aSet?: Optional>; + @Expose({ name: 'AnotherSet' }) + @Transform( + (value, obj) => transformValue(String, 'anotherSet', value, obj, [Set]), + { + toClassOnly: true, + } + ) + anotherSet?: Optional>; + @Expose({ name: 'AFreeformDict' }) + @Transform( + (value, obj) => transformValue(Object, 'aFreeformDict', value, obj, [Map]), + { + toClassOnly: true, + } + ) + aFreeformDict?: Optional>; + @Expose({ name: 'ANumberDict' }) + @Transform( + (value, obj) => transformValue(Number, 'aNumberDict', value, obj, [Map]), + { + toClassOnly: true, + } + ) + aNumberDict?: Optional>; + @Expose({ name: 'AnInt' }) + @Transform((value, obj) => transformValue(BigInt, 'anInt', value, obj), { + toClassOnly: true, + }) + anInt?: Optional; + @Expose({ name: 'ABool' }) + @Transform((value, obj) => transformValue(Boolean, 'aBool', value, obj), { + toClassOnly: true, + }) + aBool?: Optional; + @Expose({ name: 'NestedList' }) + @Type(() => NestedList) + nestedList?: Optional>>; + @Expose({ name: 'AList' }) + @Type(() => AList) + aList?: Optional>; + @Expose({ name: 'ADict' }) + @Type(() => ADict) + aDict?: Optional>; +} + +export class NestedList extends BaseModel { + ['constructor']: typeof NestedList; + + @Expose({ name: 'NestedListBool' }) + @Transform((value, obj) => transformValue(Boolean, 'nestedListBool', value, obj), { + toClassOnly: true, + }) + nestedListBool?: Optional; + @Expose({ name: 'NestedListList' }) + @Transform((value, obj) => transformValue(Number, 'nestedListList', value, obj), { + toClassOnly: true, + }) + nestedListList?: Optional; +} + +export class AList extends BaseModel { + ['constructor']: typeof AList; + + @Expose({ name: 'DeeperBool' }) + @Transform((value, obj) => transformValue(Boolean, 'deeperBool', value, obj), { + toClassOnly: true, + }) + deeperBool?: Optional; + @Expose({ name: 'DeeperList' }) + @Transform( + (value, obj) => transformValue(BigInt, 'deeperList', value, obj, [Array]), + { + toClassOnly: true, + } + ) + deeperList?: Optional>; + @Expose({ name: 'DeeperDictInList' }) + @Type(() => DeeperDictInList) + deeperDictInList?: Optional; +} + +export class DeeperDictInList extends BaseModel { + ['constructor']: typeof DeeperDictInList; + + @Expose({ name: 'DeepestBool' }) + @Transform((value, obj) => transformValue(Boolean, 'deepestBool', value, obj), { + toClassOnly: true, + }) + deepestBool?: Optional; + @Expose({ name: 'DeepestList' }) + @Transform( + (value, obj) => transformValue(BigInt, 'deepestList', value, obj, [Array]), + { + toClassOnly: true, + } + ) + deepestList?: Optional>; +} + +export class ADict extends BaseModel { + ['constructor']: typeof ADict; + + @Expose({ name: 'DeepBool' }) + @Transform((value, obj) => transformValue(Boolean, 'deepBool', value, obj), { + toClassOnly: true, + }) + deepBool?: Optional; + @Expose({ name: 'DeepList' }) + @Transform( + (value, obj) => transformValue(BigInt, 'deepList', value, obj, [Array]), + { + toClassOnly: true, + } + ) + deepList?: Optional>; + @Expose({ name: 'DeepDict' }) + @Type(() => DeepDict) + deepDict?: Optional; +} + +export class DeepDict extends BaseModel { + ['constructor']: typeof DeepDict; + + @Expose({ name: 'DeeperBool' }) + @Transform((value, obj) => transformValue(Boolean, 'deeperBool', value, obj), { + toClassOnly: true, + }) + deeperBool?: Optional; + @Expose({ name: 'DeeperList' }) + @Transform( + (value, obj) => transformValue(BigInt, 'deeperList', value, obj, [Array]), + { + toClassOnly: true, + } + ) + deeperList?: Optional>; + @Expose({ name: 'DeeperDict' }) + @Type(() => DeeperDict) + deeperDict?: Optional; +} + +export class DeeperDict extends BaseModel { + ['constructor']: typeof DeeperDict; + + @Expose({ name: 'DeepestBool' }) + @Transform((value, obj) => transformValue(Boolean, 'deepestBool', value, obj), { + toClassOnly: true, + }) + deepestBool?: Optional; + @Expose({ name: 'DeepestList' }) + @Transform( + (value, obj) => transformValue(BigInt, 'deepestList', value, obj, [Array]), + { + toClassOnly: true, + } + ) + deepestList?: Optional>; +} + +export class SimpleResourceModel extends BaseModel { + ['constructor']: typeof SimpleResourceModel; + + @Exclude() + public static readonly TYPE_NAME: string = 'Organization::Service::SimpleResource'; + + @Expose({ name: 'ANumber' }) + @Transform((value, obj) => transformValue(Number, 'aNumber', value, obj), { + toClassOnly: true, + }) + aNumber?: Optional; + @Expose({ name: 'ABoolean' }) + @Transform((value, obj) => transformValue(Boolean, 'aBoolean', value, obj), { + toClassOnly: true, + }) + aBoolean?: Optional; +} diff --git a/tests/lib/interface.test.ts b/tests/lib/interface.test.ts index 96796ac..1cb7430 100644 --- a/tests/lib/interface.test.ts +++ b/tests/lib/interface.test.ts @@ -15,34 +15,25 @@ describe('when getting interface', () => { }); test('base resource model deserialize', () => { - expect(() => ResourceModel.deserialize(null)).toThrow( - 'Cannot convert undefined or null to object' - ); + const model = ResourceModel.deserialize(null); + expect(model).toBeNull(); }); test('base resource model serialize', () => { - const model = new ResourceModel( - new Map( - Object.entries({ - somekey: 'a', - someotherkey: null, - }) - ) - ); - const serialized = model.serialize(); - expect(serialized.size).toBe(1); - expect(serialized.get('someotherkey')).not.toBeDefined(); + const model = ResourceModel.deserialize({ + somekey: 'a', + someotherkey: null, + }); + const serialized = JSON.parse(JSON.stringify(model)); + expect(Object.keys(serialized).length).toBe(1); + expect(serialized.someotherkey).not.toBeDefined(); }); test('base resource model to object', () => { - const model = new ResourceModel( - new Map( - Object.entries({ - somekey: 'a', - someotherkey: 'b', - }) - ) - ); + const model = new ResourceModel({ + somekey: 'a', + someotherkey: 'b', + }); const obj = model.toObject(); expect(obj).toMatchObject({ somekey: 'a', diff --git a/tests/lib/recast.test.ts b/tests/lib/recast.test.ts new file mode 100644 index 0000000..3bb97b9 --- /dev/null +++ b/tests/lib/recast.test.ts @@ -0,0 +1,144 @@ +import * as exceptions from '../../src/exceptions'; +import { transformValue, recastPrimitive } from '../../src/recast'; +import { + ResourceModel as ComplexResourceModel, + SimpleResourceModel, +} from '../data/sample-model'; + +const mockResult = (output: any): jest.Mock => { + return jest.fn().mockReturnValue({ + promise: jest.fn().mockResolvedValue(output), + }); +}; + +describe('when recasting objects', () => { + beforeAll(() => {}); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + test('recast simple object', () => { + const payload = { + ANumber: '12.54', + ABoolean: 'false', + }; + const expected = { + ANumber: 12.54, + ABoolean: false, + }; + const model = SimpleResourceModel.deserialize(payload); + expect(model.toJSON()).toMatchObject(expected); + const serialized = JSON.parse(JSON.stringify(model)); + expect(serialized).toMatchObject(expected); + }); + + test('recast complex object', () => { + const payload = { + ListListAny: [[{ key: 'val' }]], + ListListInt: [['1', '2', '3']], + ListSetInt: [['1', '2', '3']], + ASet: ['1', '2', '3'], + AnotherSet: ['a', 'b', 'c'], + AFreeformDict: { somekey: 'somevalue', someotherkey: '1' }, + ANumberDict: { key: '52.76' }, + AnInt: '1', + ABool: 'true', + AList: [ + { + DeeperBool: 'false', + DeeperList: ['1', '2', '3'], + DeeperDictInList: { DeepestBool: 'true', DeepestList: ['3', '4'] }, + }, + { DeeperDictInList: { DeepestBool: 'false', DeepestList: ['6', '7'] } }, + ], + ADict: { + DeepBool: 'true', + DeepList: ['10', '11'], + DeepDict: { + DeeperBool: 'false', + DeeperList: ['1', '2', '3'], + DeeperDict: { DeepestBool: 'true', DeepestList: ['13', '17'] }, + }, + }, + NestedList: [ + [{ NestedListBool: 'true', NestedListList: ['1', '2', '3'] }], + [{ NestedListBool: 'false', NestedListList: ['11', '12', '13'] }], + ], + }; + const expected = { + ListSetInt: [[1, 2, 3]], + ListListInt: [[1, 2, 3]], + ListListAny: [[{ key: 'val' }]], + ASet: ['1', '2', '3'], + AnotherSet: ['a', 'b', 'c'], + AFreeformDict: { somekey: 'somevalue', someotherkey: '1' }, + ANumberDict: { key: 52.76 }, + AnInt: 1, + ABool: true, + AList: [ + { + DeeperBool: false, + DeeperList: [1, 2, 3], + DeeperDictInList: { DeepestBool: true, DeepestList: [3, 4] }, + }, + { DeeperDictInList: { DeepestBool: false, DeepestList: [6, 7] } }, + ], + ADict: { + DeepBool: true, + DeepList: [10, 11], + DeepDict: { + DeeperBool: false, + DeeperList: [1, 2, 3], + DeeperDict: { DeepestBool: true, DeepestList: [13, 17] }, + }, + }, + NestedList: [ + [{ NestedListBool: true, NestedListList: [1.0, 2.0, 3.0] }], + [{ NestedListBool: false, NestedListList: [11.0, 12.0, 13.0] }], + ], + }; + const model = ComplexResourceModel.deserialize(payload); + const serialized = JSON.parse(JSON.stringify(model)); + expect(serialized).toMatchObject(expected); + // re-invocations should not fail because they already type-cast payloads + expect(ComplexResourceModel.deserialize(serialized).serialize()).toMatchObject( + expected + ); + }); + + test('recast object invalid sub type', () => { + const k = 'key'; + const v = { a: 1, b: 2 }; + const recastObject = () => { + transformValue(SimpleResourceModel, k, v, {}); + }; + expect(recastObject).toThrow(exceptions.InvalidRequest); + expect(recastObject).toThrow(`Unsupported type: ${typeof v} for ${k}`); + }); + + test('recast primitive object type', () => { + const k = 'key'; + const v = '{"a":"b"}'; + const value = recastPrimitive(Object, k, v); + expect(value).toBe(v); + }); + + test('recast primitive boolean invalid value', () => { + const k = 'key'; + const v = 'not-a-bool'; + const recastingPrimitive = () => { + recastPrimitive(Boolean, k, v); + }; + expect(recastingPrimitive).toThrow(exceptions.InvalidRequest); + expect(recastingPrimitive).toThrow(`Value for ${k} "${v}" is not boolean`); + }); + + test('recast primitive number valid value', () => { + const k = 'key'; + const v = '1252.53'; + const num = recastPrimitive(Number, k, v); + expect(num).toBe(1252.53); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index da5082c..d971b47 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,6 +10,7 @@ "removeComments": true, "sourceMap": false, "experimentalDecorators": true, + "emitDecoratorMetadata": true, "outDir": "dist" }, "include": [ From 22187385fb0c0a2b56d023cf664779327e587d92 Mon Sep 17 00:00:00 2001 From: Eduardo Rodrigues Date: Tue, 4 Aug 2020 23:32:59 +0200 Subject: [PATCH 2/7] move away from es6 map --- global.d.ts | 25 --- python/rpdk/typescript/resolver.py | 4 +- python/rpdk/typescript/templates/models.ts | 8 +- python/rpdk/typescript/utils.py | 1 + src/interface.ts | 180 ++++++++++++++++--- src/log-delivery.ts | 3 +- src/metrics.ts | 58 +++--- src/proxy.ts | 96 ++++------ src/recast.ts | 21 ++- src/resource.ts | 115 ++++-------- src/utils.ts | 178 +------------------ tests/data/sample-model.ts | 60 +++++-- tests/lib/interface.test.ts | 52 ++++-- tests/lib/log-delivery.test.ts | 74 +++----- tests/lib/metrics.test.ts | 15 +- tests/lib/proxy.test.ts | 195 ++++++++------------- tests/lib/recast.test.ts | 10 +- tests/lib/resource.test.ts | 161 +++++------------ 18 files changed, 523 insertions(+), 733 deletions(-) delete mode 100644 global.d.ts diff --git a/global.d.ts b/global.d.ts deleted file mode 100644 index 3bf29aa..0000000 --- a/global.d.ts +++ /dev/null @@ -1,25 +0,0 @@ -export {} -declare global { - interface Map { - /** - * Returns an ordinary object using the Map's keys as the object's keys and its values as the object's values. - * - * @throws {Error} Since object keys are evaluated as strings (in particular, `{ [myObj]: value }` will have a key named - * `[Object object]`), it's possible that two keys within the Map may evaluate to the same object key. - * In this case, if the associated values are not the same, throws an Error. - */ - toObject(): object; - - /** - * Defines the default JSON representation of a Map to be an array of key-value pairs. - */ - toJSON(): Array<[K, V]>; - } - - interface BigInt { - /** - * Defines the default JSON representation of a BigInt to be a number. - */ - toJSON(): number; - } -} diff --git a/python/rpdk/typescript/resolver.py b/python/rpdk/typescript/resolver.py index d9f77f3..97dbc35 100644 --- a/python/rpdk/typescript/resolver.py +++ b/python/rpdk/typescript/resolver.py @@ -2,14 +2,14 @@ PRIMITIVE_TYPES = { "string": "string", - "integer": "bigint", + "integer": "integer", "boolean": "boolean", "number": "number", UNDEFINED: "object", } PRIMITIVE_WRAPPERS = { "string": "String", - "bigint": "BigInt", + "integer": "Integer", "boolean": "Boolean", "number": "Number", "object": "Object", diff --git a/python/rpdk/typescript/templates/models.ts b/python/rpdk/typescript/templates/models.ts index 0256719..f61bcbe 100644 --- a/python/rpdk/typescript/templates/models.ts +++ b/python/rpdk/typescript/templates/models.ts @@ -1,5 +1,5 @@ // This is a generated file. Modifications will be overwritten. -import { BaseModel, Dict, Optional, transformValue } from '{{lib_name}}'; +import { BaseModel, Dict, integer, Integer, Optional, transformValue } from '{{lib_name}}'; import { Exclude, Expose, Type, Transform } from 'class-transformer'; {% for model, properties in models.items() %} @@ -40,7 +40,7 @@ export class {{ model|uppercase_first_letter }} extends BaseModel { } ) {% endif %} - {{ name|lowercase_first_letter|safe_reserved }}: Optional<{{ translated_type }}>; + {{ name|lowercase_first_letter|safe_reserved }}?: Optional<{{ translated_type }}>; {% endfor %} {% if model == "ResourceModel" %} @@ -61,7 +61,7 @@ export class {{ model|uppercase_first_letter }} extends BaseModel { {% endfor %} // only return the identifier if it can be used, i.e. if all components are present - return identifier.length === {{ primaryIdentifier|length }} ? identifier : null; + return Object.keys(identifier).length === {{ primaryIdentifier|length }} ? identifier : null; } @Exclude() @@ -94,7 +94,7 @@ export class {{ model|uppercase_first_letter }} extends BaseModel { {% endfor %} // only return the identifier if it can be used, i.e. if all components are present - return identifier.length === {{ identifiers|length }} ? identifier : null; + return Object.keys(identifier).length === {{ identifiers|length }} ? identifier : null; } {% endfor %} {% endif %} diff --git a/python/rpdk/typescript/utils.py b/python/rpdk/typescript/utils.py index ef78780..f827b3c 100644 --- a/python/rpdk/typescript/utils.py +++ b/python/rpdk/typescript/utils.py @@ -9,6 +9,7 @@ "as", "async", "await", + "bigint", "boolean", "break", "case", diff --git a/src/interface.ts b/src/interface.ts index 0cae7fd..bd9502f 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -1,19 +1,77 @@ import 'reflect-metadata'; import { ClientRequestToken, + LogGroupName, LogicalResourceId, NextToken, } from 'aws-sdk/clients/cloudformation'; -import { classToPlain, Exclude, plainToClass } from 'class-transformer'; +import { + classToPlain, + ClassTransformOptions, + Exclude, + Expose, + plainToClass, +} from 'class-transformer'; export type Optional = T | undefined | null; - export type Dict = Record; +export type Constructor = new (...args: any[]) => T; +export type integer = bigint; export interface Callable, T> { (...args: R): T; } +interface Integer extends BigInt { + /** + * Defines the default JSON representation of + * Integer (BigInt) to be a number. + */ + toJSON: () => number; +} + +interface IntegerConstructor extends BigIntConstructor { + (value?: unknown): integer; + readonly prototype: Integer; + /** + * Returns true if the value passed is a safe integer + * to be parsed as number. + * @param value An integer value. + */ + isSafeInteger(value: unknown): boolean; +} + +/** + * Wrapper with additional JSON serialization for bigint type + */ +export const Integer: IntegerConstructor = new Proxy(BigInt, { + apply( + target: IntegerConstructor, + _thisArg: unknown, + argArray?: unknown[] + ): integer { + target.prototype.toJSON = function (): number { + return Number(this.valueOf()); + }; + const isSafeInteger = (value: unknown): boolean => { + if ( + value && + (value < BigInt(Number.MIN_SAFE_INTEGER) || + value > BigInt(Number.MAX_SAFE_INTEGER)) + ) { + return false; + } + return true; + }; + target.isSafeInteger = isSafeInteger; + const value = target(...argArray); + if (value && !isSafeInteger(value)) { + throw new RangeError(`Value is not a safe integer: ${value.toString()}`); + } + return value; + }, +}) as IntegerConstructor; + export enum Action { Create = 'CREATE', Read = 'READ', @@ -75,33 +133,46 @@ export abstract class BaseDto { } @Exclude() - public serialize(): Dict { - const data: Dict = classToPlain(this); - for (const key in data) { - const value = data[key]; - if (value == null) { - delete data[key]; + static serializer = { + classToPlain, + plainToClass, + }; + + @Exclude() + public serialize(removeNull = true): Dict { + const data: Dict = JSON.parse(JSON.stringify(classToPlain(this))); + // To match Java serialization, which drops 'null' values, and the + // contract tests currently expect this also. + if (removeNull) { + for (const key in data) { + const value = data[key]; + if (value == null) { + delete data[key]; + } } } return data; } - public static deserialize(this: new () => T, jsonData: Dict): T { + public static deserialize( + this: new () => T, + jsonData: Dict, + options: ClassTransformOptions = {} + ): T { if (jsonData == null) { return null; } - return plainToClass(this, jsonData, { enableImplicitConversion: false }); + return plainToClass(this, jsonData, { + enableImplicitConversion: false, + excludeExtraneousValues: true, + ...options, + }); } @Exclude() public toJSON(key?: string): Dict { return this.serialize(); } - - @Exclude() - public toObject(): Dict { - return this.serialize(); - } } export interface RequestContext { @@ -123,12 +194,74 @@ export class BaseModel extends BaseDto { } } -export class BaseResourceHandlerRequest { - public clientRequestToken: ClientRequestToken; - public desiredResourceState?: T; - public previousResourceState?: T; - public logicalResourceIdentifier?: LogicalResourceId; - public nextToken?: NextToken; +export class TestEvent extends BaseDto { + @Expose() credentials: Credentials; + @Expose() action: Action; + @Expose() request: Dict; + @Expose() callbackContext: Dict; + @Expose() region?: string; +} + +export class RequestData extends BaseDto { + @Expose() callerCredentials?: Credentials; + @Expose() providerCredentials?: Credentials; + @Expose() providerLogGroupName: LogGroupName; + @Expose() logicalResourceId: LogicalResourceId; + @Expose() resourceProperties: T; + @Expose() previousResourceProperties?: T; + @Expose() systemTags?: Dict; + @Expose() stackTags?: Dict; + @Expose() previousStackTags?: Dict; +} + +export class HandlerRequest extends BaseDto { + @Expose() action: Action; + @Expose() awsAccountId: string; + @Expose() bearerToken: string; + @Expose() region: string; + @Expose() responseEndpoint: string; + @Expose() resourceType: string; + @Expose() resourceTypeVersion: string; + @Expose() requestData: RequestData; + @Expose() stackId: string; + @Expose() callbackContext?: CallbackT; + @Expose() nextToken?: NextToken; + @Expose() requestContext: RequestContext; +} + +export class BaseResourceHandlerRequest extends BaseDto { + @Expose() clientRequestToken: ClientRequestToken; + @Expose() desiredResourceState?: T; + @Expose() previousResourceState?: T; + @Expose() logicalResourceIdentifier?: LogicalResourceId; + @Expose() nextToken?: NextToken; +} + +export class UnmodeledRequest extends BaseResourceHandlerRequest { + @Exclude() + public static fromUnmodeled(obj: Dict): UnmodeledRequest { + return UnmodeledRequest.deserialize(obj); + } + + @Exclude() + public toModeled( + modelCls: Constructor & { deserialize?: Function } + ): BaseResourceHandlerRequest { + const request = BaseResourceHandlerRequest.deserialize< + BaseResourceHandlerRequest + >({ + clientRequestToken: this.clientRequestToken, + logicalResourceIdentifier: this.logicalResourceIdentifier, + nextToken: this.nextToken, + }); + request.desiredResourceState = modelCls.deserialize( + this.desiredResourceState || {} + ); + request.previousResourceState = modelCls.deserialize( + this.previousResourceState || {} + ); + return request; + } } export interface CfnResponse { @@ -139,3 +272,8 @@ export interface CfnResponse { resourceModels?: T[]; nextToken?: NextToken; } + +export interface LambdaContext { + invokedFunctionArn: string; + getRemainingTimeInMillis(): number; +} diff --git a/src/log-delivery.ts b/src/log-delivery.ts index 672c725..8de8e86 100644 --- a/src/log-delivery.ts +++ b/src/log-delivery.ts @@ -11,7 +11,8 @@ import S3, { PutObjectRequest, PutObjectOutput } from 'aws-sdk/clients/s3'; import promiseSequential from 'promise-sequential'; import { SessionProxy } from './proxy'; -import { delay, HandlerRequest } from './utils'; +import { HandlerRequest } from './interface'; +import { delay } from './utils'; type Console = globalThis.Console; type PromiseFunction = () => Promise; diff --git a/src/metrics.ts b/src/metrics.ts index bc270f0..f8db3cd 100644 --- a/src/metrics.ts +++ b/src/metrics.ts @@ -1,4 +1,4 @@ -import CloudWatch, { Dimension } from 'aws-sdk/clients/cloudwatch'; +import CloudWatch, { Dimension, DimensionName } from 'aws-sdk/clients/cloudwatch'; import { SessionProxy } from './proxy'; import { Action, MetricTypes, StandardUnit } from './interface'; @@ -7,14 +7,18 @@ import { BaseHandlerException } from './exceptions'; const LOGGER = console; const METRIC_NAMESPACE_ROOT = 'AWS/CloudFormation'; -export function formatDimensions(dimensions: Map): Array { +export type DimensionRecord = Record; + +export function formatDimensions(dimensions: DimensionRecord): Array { const formatted: Array = []; - dimensions.forEach((value: string, key: string) => { - formatted.push({ + for (const key in dimensions) { + const value = dimensions[key]; + const dimension: Dimension = { Name: key, Value: value, - }); - }); + }; + formatted.push(dimension); + } return formatted; } @@ -27,7 +31,7 @@ export class MetricPublisher { async publishMetric( metricName: MetricTypes, - dimensions: Map, + dimensions: DimensionRecord, unit: StandardUnit, value: number, timestamp: Date @@ -80,13 +84,12 @@ export class MetricsPublisherProxy { action: Action, error: Error ): Promise { - const dimensions = new Map(); - dimensions.set('DimensionKeyActionType', action); - dimensions.set( - 'DimensionKeyExceptionType', - (error as BaseHandlerException).errorCode || error.constructor.name - ); - dimensions.set('DimensionKeyResourceType', this.resourceType); + const dimensions: DimensionRecord = { + DimensionKeyActionType: action, + DimensionKeyExceptionType: + (error as BaseHandlerException).errorCode || error.constructor.name, + DimensionKeyResourceType: this.resourceType, + }; const promises: Array> = this.publishers.map( (publisher: MetricPublisher) => { return publisher.publishMetric( @@ -102,9 +105,10 @@ export class MetricsPublisherProxy { } async publishInvocationMetric(timestamp: Date, action: Action): Promise { - const dimensions = new Map(); - dimensions.set('DimensionKeyActionType', action); - dimensions.set('DimensionKeyResourceType', this.resourceType); + const dimensions: DimensionRecord = { + DimensionKeyActionType: action, + DimensionKeyResourceType: this.resourceType, + }; const promises: Array> = this.publishers.map( (publisher: MetricPublisher) => { return publisher.publishMetric( @@ -124,9 +128,10 @@ export class MetricsPublisherProxy { action: Action, milliseconds: number ): Promise { - const dimensions = new Map(); - dimensions.set('DimensionKeyActionType', action); - dimensions.set('DimensionKeyResourceType', this.resourceType); + const dimensions: DimensionRecord = { + DimensionKeyActionType: action, + DimensionKeyResourceType: this.resourceType, + }; const promises: Array> = this.publishers.map( (publisher: MetricPublisher) => { return publisher.publishMetric( @@ -145,13 +150,12 @@ export class MetricsPublisherProxy { timestamp: Date, error: Error ): Promise { - const dimensions = new Map(); - dimensions.set('DimensionKeyActionType', 'ProviderLogDelivery'); - dimensions.set( - 'DimensionKeyExceptionType', - (error as BaseHandlerException).errorCode || error.constructor.name - ); - dimensions.set('DimensionKeyResourceType', this.resourceType); + const dimensions: DimensionRecord = { + DimensionKeyActionType: 'ProviderLogDelivery', + DimensionKeyExceptionType: + (error as BaseHandlerException).errorCode || error.constructor.name, + DimensionKeyResourceType: this.resourceType, + }; const promises: Array> = this.publishers.map( (publisher: MetricPublisher) => { return publisher.publishMetric( diff --git a/src/proxy.ts b/src/proxy.ts index 7db4d40..2563288 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -2,14 +2,17 @@ import { ConfigurationOptions } from 'aws-sdk/lib/config'; import { CredentialsOptions } from 'aws-sdk/lib/credentials'; import * as Aws from 'aws-sdk/clients/all'; import { NextToken } from 'aws-sdk/clients/cloudformation'; -import { allArgsConstructor, builder, IBuilder } from 'tombok'; +import { builder, IBuilder } from 'tombok'; import { + BaseDto, BaseResourceHandlerRequest, BaseModel, + Dict, HandlerErrorCode, OperationStatus, } from './interface'; +import { Exclude, Expose } from 'class-transformer'; type ClientMap = typeof Aws; type Client = InstanceType; @@ -40,26 +43,25 @@ export class SessionProxy { } } -@allArgsConstructor @builder -export class ProgressEvent> { +export class ProgressEvent extends BaseDto { /** * The status indicates whether the handler has reached a terminal state or is * still computing and requires more time to complete */ - public status: OperationStatus; + @Expose() status: OperationStatus; /** * If OperationStatus is FAILED or IN_PROGRESS, an error code should be provided */ - public errorCode?: HandlerErrorCode; + @Expose() errorCode?: HandlerErrorCode; /** * The handler can (and should) specify a contextual information message which * can be shown to callers to indicate the nature of a progress transition or * callback delay; for example a message indicating "propagating to edge" */ - public message = ''; + @Expose() message = ''; /** * The callback context is an arbitrary datum which the handler can return in an @@ -67,64 +69,47 @@ export class ProgressEvent * metadata between subsequent retries; for example to pass through a Resource * identifier which can be used to continue polling for stabilization */ - public callbackContext?: T; + @Expose() callbackContext?: T; /** * A callback will be scheduled with an initial delay of no less than the number * of seconds specified in the progress event. */ - public callbackDelaySeconds = 0; + @Expose() callbackDelaySeconds = 0; /** * The output resource instance populated by a READ for synchronous results and * by CREATE/UPDATE/DELETE for final response validation/confirmation */ - public resourceModel?: R; + @Expose() resourceModel?: R; /** * The output resource instances populated by a LIST for synchronous results */ - public resourceModels?: Array; + @Expose() resourceModels?: Array; /** * The token used to request additional pages of resources for a LIST operation */ - public nextToken?: NextToken; + @Expose() nextToken?: NextToken; + + constructor(partial?: Partial) { + super(); + if (partial) { + Object.assign(this, partial); + } + } // TODO: remove workaround when decorator mutation implemented: https://github.com/microsoft/TypeScript/issues/4881 - constructor(...args: any[]) {} + @Exclude() public static builder(template?: Partial): IBuilder { return null; } - public serialize(): Map { - // To match Java serialization, which drops 'null' values, and the - // contract tests currently expect this also. - const json: Map = new Map(Object.entries(this)); //JSON.parse(JSON.stringify(this))); - json.forEach((value: any, key: string) => { - if (value == null) { - json.delete(key); - } - }); - // Mutate to what's expected in the response. - if (this.resourceModel) { - json.set('resourceModel', this.resourceModel.toObject()); - } - if (this.resourceModels) { - const models = this.resourceModels.map((resource: R) => - resource.toObject() - ); - json.set('resourceModels', models); - } - if (this.errorCode) { - json.set('errorCode', this.errorCode); - } - return json; - } - /** * Convenience method for constructing FAILED response */ + @Exclude() public static failed(errorCode: HandlerErrorCode, message: string): ProgressEvent { const event = ProgressEvent.builder() .status(OperationStatus.Failed) @@ -137,6 +122,7 @@ export class ProgressEvent /** * Convenience method for constructing IN_PROGRESS response */ + @Exclude() public static progress(model?: any, ctx?: any): ProgressEvent { const progress = ProgressEvent.builder().status(OperationStatus.InProgress); if (ctx) { @@ -149,6 +135,7 @@ export class ProgressEvent return event; } + @Exclude() /** * Convenience method for constructing a SUCCESS response */ @@ -157,12 +144,6 @@ export class ProgressEvent event.status = OperationStatus.Success; return event; } - - public toObject(): any { - // @ts-ignore - const obj = Object.fromEntries(this.serialize().entries()); - return obj; - } } /** @@ -172,26 +153,17 @@ export class ProgressEvent * * @param Type of resource model being provisioned */ -@allArgsConstructor -@builder export class ResourceHandlerRequest< T extends BaseModel > extends BaseResourceHandlerRequest { - public clientRequestToken: string; - public desiredResourceState: T; - public previousResourceState: T; - public desiredResourceTags: Map; - public systemTags: Map; - public awsAccountId: string; - public awsPartition: string; - public logicalResourceIdentifier: string; - public nextToken: string; - public region: string; - - constructor(...args: any[]) { - super(); - } - public static builder(): any { - return null; - } + @Expose() clientRequestToken: string; + @Expose() desiredResourceState: T; + @Expose() previousResourceState: T; + @Expose() desiredResourceTags: Dict; + @Expose() systemTags: Dict; + @Expose() awsAccountId: string; + @Expose() awsPartition: string; + @Expose() logicalResourceIdentifier: string; + @Expose() nextToken: string; + @Expose() region: string; } diff --git a/src/recast.ts b/src/recast.ts index 59d9be9..efa0492 100644 --- a/src/recast.ts +++ b/src/recast.ts @@ -1,13 +1,14 @@ import { InvalidRequest } from './exceptions'; +import { integer, Integer } from './interface'; -type primitive = string | number | boolean | bigint | object; +type primitive = string | number | boolean | bigint | integer | object; type PrimitiveConstructor = | StringConstructor | NumberConstructor | BooleanConstructor | BigIntConstructor + | typeof Integer | ObjectConstructor; -const LOGGER = console; /** * CloudFormation recasts all primitive types as strings, this tries to set them back to @@ -46,7 +47,9 @@ export const transformValue = ( if (value == null) { return value; } - classes.push(cls); + if (index === 0) { + classes.push(cls); + } const currentClass = classes[index || 0]; if (value instanceof Map || Object.is(currentClass, Map)) { const temp = new Map(value instanceof Map ? value : Object.entries(value)); @@ -70,16 +73,24 @@ export const transformValue = ( } if ( Object.is(cls, String) || + cls.name === 'String' || Object.is(cls, Number) || + cls.name === 'Number' || Object.is(cls, Boolean) || - Object.is(cls, BigInt) + cls.name === 'Boolean' || + Object.is(cls, BigInt) || + cls.name === 'BigInt' || + Object.is(cls, Integer) || + cls.name === 'Integer' ) { if (typeof value === 'string') { return recastPrimitive(cls, key, value); } return value; } else { - throw new InvalidRequest(`Unsupported type: ${typeof value} for ${key}`); + throw new InvalidRequest( + `Unsupported type: ${typeof value} [${cls.name}] for ${key}` + ); } } }; diff --git a/src/resource.ts b/src/resource.ts index 6cfa082..2997524 100644 --- a/src/resource.ts +++ b/src/resource.ts @@ -9,20 +9,19 @@ import { BaseResourceHandlerRequest, Callable, CfnResponse, + Constructor, Credentials, + Dict, HandlerErrorCode, + HandlerRequest, + LambdaContext, OperationStatus, Optional, + TestEvent, + UnmodeledRequest, } from './interface'; import { ProviderLogHandler } from './log-delivery'; import { MetricsPublisherProxy } from './metrics'; -import { - Constructor, - HandlerRequest, - LambdaContext, - TestEvent, - UnmodeledRequest, -} from './utils'; const LOGGER = console; const MUTATING_ACTIONS: [Action, Action, Action] = [ @@ -32,7 +31,7 @@ const MUTATING_ACTIONS: [Action, Action, Action] = [ ]; export type HandlerSignature = Callable< - [Optional, any, Map], + [Optional, any, Dict], Promise >; export class HandlerSignatures extends Map {} @@ -43,13 +42,12 @@ class HandlerEvents extends Map {} * * @returns {MethodDecorator} */ -function ensureSerialize(toResponse = false): MethodDecorator { +function ensureSerialize(toResponse = false): MethodDecorator { return function ( - target: any, + target: BaseResource, propertyKey: string, descriptor: PropertyDescriptor ): PropertyDescriptor { - type Resource = typeof target; // Save a reference to the original method this way we keep the values currently in the // descriptor and don't overwrite what another decorator might have done to the descriptor. if (descriptor === undefined) { @@ -58,24 +56,18 @@ function ensureSerialize(toResponse = false): MethodDecorator { const originalMethod = descriptor.value; // Wrapping the original method with new signature. descriptor.value = async function ( - event: any | Map, + event: any | Dict, context: any - ): Promise> { - let mappedEvent: Map; - if (event instanceof Map) { - mappedEvent = new Map(event); - } else { - mappedEvent = new Map(Object.entries(event)); - } + ): Promise> { const progress: ProgressEvent = await originalMethod.apply(this, [ - mappedEvent, + event, context, ]); if (toResponse) { // Use the raw event data as a last-ditch attempt to call back if the // request is invalid. const serialized = progress.serialize(); - return Promise.resolve(serialized.toObject() as CfnResponse); + return Promise.resolve(serialized as CfnResponse); } return Promise.resolve(progress); }; @@ -107,7 +99,7 @@ export abstract class BaseResource { session: Optional, request: BaseResourceHandlerRequest, action: Action, - callbackContext: Map + callbackContext: Dict ): Promise => { const handle: HandlerSignature = this.handlers.get(action); if (!handle) { @@ -128,20 +120,15 @@ export abstract class BaseResource { }; private parseTestRequest = ( - eventData: Map - ): [ - Optional, - BaseResourceHandlerRequest, - Action, - Map - ] => { + eventData: Dict + ): [Optional, BaseResourceHandlerRequest, Action, Dict] => { let session: SessionProxy; let request: BaseResourceHandlerRequest; let action: Action; let event: TestEvent; - let callbackContext: Map; + let callbackContext: Dict; try { - event = new TestEvent(eventData); + event = TestEvent.deserialize(eventData); const creds = event.credentials as Credentials; if (!creds) { throw new Error( @@ -153,28 +140,13 @@ export abstract class BaseResource { 'Missing Model class to be used to deserialize JSON data.' ); } - if (event.request instanceof Map) { - event.request = new Map(event.request); - } else { - event.request = new Map(Object.entries(event.request)); - } - request = new UnmodeledRequest(event.request).toModeled(this.modelCls); + request = UnmodeledRequest.deserialize(event.request).toModeled( + this.modelCls + ); session = SessionProxy.getSession(creds, event.region); action = event.action; - - if (!event.callbackContext) { - callbackContext = new Map(); - } else if ( - event.callbackContext instanceof Array || - event.callbackContext instanceof Map - ) { - callbackContext = new Map(event.callbackContext); - } else { - callbackContext = new Map( - Object.entries(event.callbackContext) - ); - } + callbackContext = event.callbackContext || {}; } catch (err) { LOGGER.error('Invalid request'); throw new InternalFailure(`${err} (${err.name})`); @@ -185,15 +157,12 @@ export abstract class BaseResource { // @ts-ignore public async testEntrypoint( - eventData: any | Map, + eventData: any | Dict, context: any ): Promise; @boundMethod - @ensureSerialize() - public async testEntrypoint( - eventData: Map, - context: any - ): Promise { + @ensureSerialize() + public async testEntrypoint(eventData: Dict, context: any): Promise { let msg = 'Uninitialized'; let progress: ProgressEvent; try { @@ -224,17 +193,12 @@ export abstract class BaseResource { } private static parseRequest = ( - eventData: Map - ): [ - [Optional, SessionProxy], - Action, - Map, - HandlerRequest - ] => { + eventData: Dict + ): [[Optional, SessionProxy], Action, Dict, HandlerRequest] => { let callerSession: Optional; let providerSession: SessionProxy; let action: Action; - let callbackContext: Map; + let callbackContext: Dict; let event: HandlerRequest; try { event = HandlerRequest.deserialize(eventData); @@ -250,18 +214,7 @@ export abstract class BaseResource { event.requestData.providerCredentials ); action = event.action; - if (!event.callbackContext) { - callbackContext = new Map(); - } else if ( - event.callbackContext instanceof Array || - event.callbackContext instanceof Map - ) { - callbackContext = new Map(event.callbackContext); - } else { - callbackContext = new Map( - Object.entries(event.callbackContext) - ); - } + callbackContext = event.callbackContext || {}; } catch (err) { LOGGER.error('Invalid request'); throw new InvalidRequest(`${err} (${err.name})`); @@ -288,13 +241,13 @@ export abstract class BaseResource { // @ts-ignore public async entrypoint( - eventData: any | Map, + eventData: any | Dict, context: LambdaContext - ): Promise>; + ): Promise>; @boundMethod - @ensureSerialize(true) + @ensureSerialize(true) public async entrypoint( - eventData: Map, + eventData: Dict, context: LambdaContext ): Promise { let isLogSetup = false; @@ -314,7 +267,7 @@ export abstract class BaseResource { ); const [callerSession, providerSession] = sessions; isLogSetup = await ProviderLogHandler.setup(event, providerSession); - // LOGGER.debug('entrypoint eventData', eventData.toObject()); + // LOGGER.debug('entrypoint eventData', eventData); const request = this.castResourceRequest(event); const metrics = new MetricsPublisherProxy( diff --git a/src/utils.ts b/src/utils.ts index fed78cc..1493c3a 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,23 +1,8 @@ -import { - LogGroupName, - LogicalResourceId, - NextToken, -} from 'aws-sdk/clients/cloudformation'; -import { allArgsConstructor } from 'tombok'; -import { - Action, - BaseResourceHandlerRequest, - BaseModel, - Credentials, - RequestContext, -} from './interface'; - -export type Constructor = new (...args: any[]) => T; - /** * Convert minutes to a valid scheduling expression to be used in the AWS Events * * @param {number} minutes Minutes to be converted + * @deprecated */ export function minToCron(minutes: number): string { const date = new Date(Date.now()); @@ -34,164 +19,3 @@ export function minToCron(minutes: number): string { export async function delay(seconds: number): Promise { return new Promise((_) => setTimeout(() => _(), seconds * 1000)); } - -@allArgsConstructor -export class TestEvent { - credentials: Credentials; - action: Action; - request: Map; - callbackContext: Map; - region?: string; - - constructor(...args: any[]) {} -} - -@allArgsConstructor -export class RequestData> { - callerCredentials?: Credentials; - providerCredentials?: Credentials; - providerLogGroupName: LogGroupName; - logicalResourceId: LogicalResourceId; - resourceProperties: T; - previousResourceProperties?: T; - systemTags?: { [index: string]: string }; - stackTags?: { [index: string]: string }; - previousStackTags?: { [index: string]: string }; - - constructor(...args: any[]) {} - - public static deserialize(jsonData: Map): RequestData { - if (!jsonData) { - jsonData = new Map(); - } - const reqData: RequestData = new RequestData(jsonData); - jsonData.forEach((value: any, key: string) => { - if (key.endsWith('Credentials')) { - type credentialsType = 'callerCredentials' | 'providerCredentials'; - const prop: credentialsType = key as credentialsType; - const creds = value; - if (creds) { - reqData[prop] = creds as Credentials; - } - } - }); - return reqData; - } - - serialize(): Map { - return null; - } -} - -@allArgsConstructor -export class HandlerRequest< - ResourceT = Map, - CallbackT = Map -> { - action: Action; - awsAccountId: string; - bearerToken: string; - region: string; - responseEndpoint: string; - resourceType: string; - resourceTypeVersion: string; - requestData: RequestData; - stackId: string; - callbackContext?: CallbackT; - nextToken?: NextToken; - requestContext: RequestContext; - - constructor(...args: any[]) {} - - public static deserialize(jsonData: Map): HandlerRequest { - if (!jsonData) { - jsonData = new Map(); - } - const event: HandlerRequest = new HandlerRequest(jsonData); - const requestData = new Map( - Object.entries(jsonData.get('requestData') || {}) - ); - event.requestData = RequestData.deserialize(requestData); - return event; - } - - public fromJSON(jsonData: Map): HandlerRequest { - return null; - } - - public toJSON(): any { - return null; - } -} - -@allArgsConstructor -export class UnmodeledRequest extends BaseResourceHandlerRequest { - constructor(...args: any[]) { - super(); - } - - public static fromUnmodeled(obj: any): UnmodeledRequest { - const mapped = new Map(Object.entries(obj)); - const request: UnmodeledRequest = new UnmodeledRequest(mapped); - return request; - } - - public toModeled( - modelCls: Constructor & { deserialize?: Function } - ): BaseResourceHandlerRequest { - return new BaseResourceHandlerRequest( - new Map( - Object.entries({ - clientRequestToken: this.clientRequestToken, - desiredResourceState: modelCls.deserialize( - this.desiredResourceState || {} - ), - previousResourceState: modelCls.deserialize( - this.previousResourceState || {} - ), - logicalResourceIdentifier: this.logicalResourceIdentifier, - nextToken: this.nextToken, - }) - ) - ); - } -} - -export interface LambdaContext { - invokedFunctionArn: string; - getRemainingTimeInMillis(): number; -} - -/** - * Returns an ordinary object using the Map's keys as the object's keys and its values as the object's values. - * - * @throws {Error} Since object keys are evaluated as strings (in particular, `{ [myObj]: value }` will have a key named - * `[Object object]`), it's possible that two keys within the Map may evaluate to the same object key. - * In this case, if the associated values are not the same, throws an Error. - */ -Map.prototype.toObject = function (): any { - const o: any = {}; - for (const [key, value] of this.entries()) { - if (o.hasOwnProperty(key) && o[key] !== value) { - throw new Error( - `Duplicate key ${key} found in Map. First value: ${o[key]}, next value: ${value}` - ); - } - - o[key] = value; - } - - return o; -}; - -/** - * Defines the default JSON representation of a Map to be an array of key-value pairs. - */ -Map.prototype.toJSON = function (this: Map): Array<[K, V]> { - // @ts-ignore - return Object.fromEntries(this); -}; - -BigInt.prototype.toJSON = function (): number { - return Number(this); -}; diff --git a/tests/data/sample-model.ts b/tests/data/sample-model.ts index 5560da5..fc218af 100644 --- a/tests/data/sample-model.ts +++ b/tests/data/sample-model.ts @@ -1,7 +1,7 @@ // This file represents a model built from a complex schema to test that ser/de is // happening as expected /* eslint-disable @typescript-eslint/no-use-before-define */ -import { BaseModel, Optional, transformValue } from '../../src'; +import { BaseModel, integer, Integer, Optional, transformValue } from '../../src'; import { Exclude, Expose, Transform, Type } from 'class-transformer'; export class ResourceModel extends BaseModel { @@ -21,21 +21,21 @@ export class ResourceModel extends BaseModel { listListAny?: Optional>>; @Expose({ name: 'ListSetInt' }) @Transform( - (value, obj) => transformValue(BigInt, 'listSetInt', value, obj, [Array, Set]), + (value, obj) => transformValue(Integer, 'listSetInt', value, obj, [Array, Set]), { toClassOnly: true, } ) - listSetInt?: Optional>>; + listSetInt?: Optional>>; @Expose({ name: 'ListListInt' }) @Transform( (value, obj) => - transformValue(BigInt, 'listListInt', value, obj, [Array, Array]), + transformValue(Integer, 'listListInt', value, obj, [Array, Array]), { toClassOnly: true, } ) - listListInt?: Optional>>; + listListInt?: Optional>>; @Expose({ name: 'ASet' }) @Transform((value, obj) => transformValue(Object, 'aSet', value, obj, [Set]), { toClassOnly: true, @@ -66,10 +66,10 @@ export class ResourceModel extends BaseModel { ) aNumberDict?: Optional>; @Expose({ name: 'AnInt' }) - @Transform((value, obj) => transformValue(BigInt, 'anInt', value, obj), { + @Transform((value, obj) => transformValue(Integer, 'anInt', value, obj), { toClassOnly: true, }) - anInt?: Optional; + anInt?: Optional; @Expose({ name: 'ABool' }) @Transform((value, obj) => transformValue(Boolean, 'aBool', value, obj), { toClassOnly: true, @@ -111,12 +111,12 @@ export class AList extends BaseModel { deeperBool?: Optional; @Expose({ name: 'DeeperList' }) @Transform( - (value, obj) => transformValue(BigInt, 'deeperList', value, obj, [Array]), + (value, obj) => transformValue(Integer, 'deeperList', value, obj, [Array]), { toClassOnly: true, } ) - deeperList?: Optional>; + deeperList?: Optional>; @Expose({ name: 'DeeperDictInList' }) @Type(() => DeeperDictInList) deeperDictInList?: Optional; @@ -132,12 +132,12 @@ export class DeeperDictInList extends BaseModel { deepestBool?: Optional; @Expose({ name: 'DeepestList' }) @Transform( - (value, obj) => transformValue(BigInt, 'deepestList', value, obj, [Array]), + (value, obj) => transformValue(Integer, 'deepestList', value, obj, [Array]), { toClassOnly: true, } ) - deepestList?: Optional>; + deepestList?: Optional>; } export class ADict extends BaseModel { @@ -150,12 +150,12 @@ export class ADict extends BaseModel { deepBool?: Optional; @Expose({ name: 'DeepList' }) @Transform( - (value, obj) => transformValue(BigInt, 'deepList', value, obj, [Array]), + (value, obj) => transformValue(Integer, 'deepList', value, obj, [Array]), { toClassOnly: true, } ) - deepList?: Optional>; + deepList?: Optional>; @Expose({ name: 'DeepDict' }) @Type(() => DeepDict) deepDict?: Optional; @@ -171,12 +171,12 @@ export class DeepDict extends BaseModel { deeperBool?: Optional; @Expose({ name: 'DeeperList' }) @Transform( - (value, obj) => transformValue(BigInt, 'deeperList', value, obj, [Array]), + (value, obj) => transformValue(Integer, 'deeperList', value, obj, [Array]), { toClassOnly: true, } ) - deeperList?: Optional>; + deeperList?: Optional>; @Expose({ name: 'DeeperDict' }) @Type(() => DeeperDict) deeperDict?: Optional; @@ -192,12 +192,12 @@ export class DeeperDict extends BaseModel { deepestBool?: Optional; @Expose({ name: 'DeepestList' }) @Transform( - (value, obj) => transformValue(BigInt, 'deepestList', value, obj, [Array]), + (value, obj) => transformValue(Integer, 'deepestList', value, obj, [Array]), { toClassOnly: true, } ) - deepestList?: Optional>; + deepestList?: Optional>; } export class SimpleResourceModel extends BaseModel { @@ -217,3 +217,29 @@ export class SimpleResourceModel extends BaseModel { }) aBoolean?: Optional; } + +export class SimpleStateModel extends BaseModel { + ['constructor']: typeof SimpleStateModel; + + @Exclude() + public static readonly TYPE_NAME: string = 'Organization::Service::SimpleState'; + + @Expose() + @Transform((value, obj) => transformValue(String, 'state', value, obj), { + toClassOnly: true, + }) + state?: Optional; +} + +export class SerializableModel extends BaseModel { + ['constructor']: typeof SerializableModel; + public static readonly TYPE_NAME: string = 'Organization::Service::Serializable'; + + @Expose() somekey?: Optional; + @Expose() someotherkey?: Optional; + @Expose({ name: 'SomeInt' }) + @Transform((value, obj) => transformValue(Integer, 'someint', value, obj), { + toClassOnly: true, + }) + someint?: Optional; +} diff --git a/tests/lib/interface.test.ts b/tests/lib/interface.test.ts index 1cb7430..5c6dc99 100644 --- a/tests/lib/interface.test.ts +++ b/tests/lib/interface.test.ts @@ -1,43 +1,65 @@ -import { BaseModel, Optional } from '../../src/interface'; +import { Integer } from '../../src/interface'; +import { SerializableModel } from '../data/sample-model'; describe('when getting interface', () => { - class ResourceModel extends BaseModel { - ['constructor']: typeof ResourceModel; - public static readonly TYPE_NAME: string = 'Test::Resource::Model'; - - public somekey: Optional; - public someotherkey: Optional; - } - test('base resource model get type name', () => { - const model = new ResourceModel(); + const model = new SerializableModel(); expect(model.getTypeName()).toBe(model.constructor.TYPE_NAME); }); test('base resource model deserialize', () => { - const model = ResourceModel.deserialize(null); + const model = SerializableModel.deserialize(null); expect(model).toBeNull(); }); test('base resource model serialize', () => { - const model = ResourceModel.deserialize({ + const model = SerializableModel.deserialize({ somekey: 'a', someotherkey: null, + someint: null, }); const serialized = JSON.parse(JSON.stringify(model)); expect(Object.keys(serialized).length).toBe(1); expect(serialized.someotherkey).not.toBeDefined(); }); - test('base resource model to object', () => { - const model = new ResourceModel({ + test('base resource model to plain object', () => { + const model = SerializableModel.deserialize({ somekey: 'a', someotherkey: 'b', }); - const obj = model.toObject(); + const obj = model.toJSON(); expect(obj).toMatchObject({ somekey: 'a', someotherkey: 'b', }); }); + + test('integer serialize from number to number', () => { + const valueNumber = 123597129357; + expect(typeof valueNumber).toBe('number'); + const valueInteger = Integer(valueNumber); + expect(typeof valueInteger).toBe('bigint'); + const serialized = JSON.parse(JSON.stringify(valueInteger)); + expect(typeof serialized).toBe('number'); + expect(serialized).toBe(valueNumber); + }); + + test('integer serialize invalid number', () => { + const parseInteger = () => { + Integer(Math.pow(2, 53)); + }; + expect(parseInteger).toThrow(RangeError); + expect(parseInteger).toThrow('Value is not a safe integer'); + }); + + test('integer serialize from string to number', () => { + const model = SerializableModel.deserialize({ + SomeInt: '35190274', + }); + expect(model['someint']).toBe(Integer(35190274)); + const serialized = model.serialize(); + expect(typeof serialized['SomeInt']).toBe('number'); + expect(serialized['SomeInt']).toBe(35190274); + }); }); diff --git a/tests/lib/log-delivery.test.ts b/tests/lib/log-delivery.test.ts index 30c8989..b53287a 100644 --- a/tests/lib/log-delivery.test.ts +++ b/tests/lib/log-delivery.test.ts @@ -8,7 +8,7 @@ import promiseSequential from 'promise-sequential'; import { Action } from '../../src/interface'; import { SessionProxy } from '../../src/proxy'; import { ProviderLogHandler } from '../../src/log-delivery'; -import { HandlerRequest, RequestData } from '../../src/utils'; +import { HandlerRequest, RequestData } from '../../src/interface'; const mockResult = (output: any): jest.Mock => { return jest.fn().mockReturnValue({ @@ -93,57 +93,39 @@ describe('when delivering log', () => { }; }); session['client'] = cwLogs; - const request = new HandlerRequest( - new Map( - Object.entries({ - awsAccountId: '123412341234', - resourceType: 'Foo::Bar::Baz', - requestData: new RequestData( - new Map( - Object.entries({ - providerLogGroupName: 'test-group', - logicalResourceId: 'MyResourceId', - resourceProperties: {}, - systemTags: {}, - }) - ) - ), - stackId: - 'arn:aws:cloudformation:us-east-1:123412341234:stack/baz/321', - }) - ) - ); + const request = new HandlerRequest({ + awsAccountId: '123412341234', + resourceType: 'Foo::Bar::Baz', + requestData: new RequestData({ + providerLogGroupName: 'test-group', + logicalResourceId: 'MyResourceId', + resourceProperties: {}, + systemTags: {}, + }), + stackId: 'arn:aws:cloudformation:us-east-1:123412341234:stack/baz/321', + }); await ProviderLogHandler.setup(request, session); // Get a copy of the instance and remove it from class // to avoid changing singleton. providerLogHandler = ProviderLogHandler.getInstance(); ProviderLogHandler['instance'] = null; cwLogs.mockClear(); - payload = new HandlerRequest( - new Map( - Object.entries({ - action: Action.Create, - awsAccountId: '123412341234', - bearerToken: uuidv4(), - region: 'us-east-1', - responseEndpoint: '', - resourceType: 'Foo::Bar::Baz', - resourceTypeVersion: '4', - requestData: new RequestData( - new Map( - Object.entries({ - providerLogGroupName: 'test_group', - logicalResourceId: 'MyResourceId', - resourceProperties: {}, - systemTags: {}, - }) - ) - ), - stackId: - 'arn:aws:cloudformation:us-east-1:123412341234:stack/baz/321', - }) - ) - ); + payload = new HandlerRequest({ + action: Action.Create, + awsAccountId: '123412341234', + bearerToken: uuidv4(), + region: 'us-east-1', + responseEndpoint: '', + resourceType: 'Foo::Bar::Baz', + resourceTypeVersion: '4', + requestData: new RequestData({ + providerLogGroupName: 'test_group', + logicalResourceId: 'MyResourceId', + resourceProperties: {}, + systemTags: {}, + }), + stackId: 'arn:aws:cloudformation:us-east-1:123412341234:stack/baz/321', + }); }); afterEach(() => { diff --git a/tests/lib/metrics.test.ts b/tests/lib/metrics.test.ts index ae96375..b0cfdde 100644 --- a/tests/lib/metrics.test.ts +++ b/tests/lib/metrics.test.ts @@ -4,6 +4,7 @@ import awsUtil from 'aws-sdk/lib/util'; import { Action, MetricTypes, StandardUnit } from '../../src/interface'; import { SessionProxy } from '../../src/proxy'; import { + DimensionRecord, MetricPublisher, MetricsPublisherProxy, formatDimensions, @@ -51,9 +52,10 @@ describe('when getting metrics', () => { }); test('format dimensions', () => { - const dimensions = new Map(); - dimensions.set('MyDimensionKeyOne', 'valOne'); - dimensions.set('MyDimensionKeyTwo', 'valTwo'); + const dimensions: DimensionRecord = { + MyDimensionKeyOne: 'valOne', + MyDimensionKeyTwo: 'valTwo', + }; const result = formatDimensions(dimensions); expect(result).toMatchObject([ { Name: 'MyDimensionKeyOne', Value: 'valOne' }, @@ -76,9 +78,10 @@ describe('when getting metrics', () => { ), }); const publisher = new MetricPublisher(session, NAMESPACE); - const dimensions = new Map(); - dimensions.set('DimensionKeyActionType', Action.Create); - dimensions.set('DimensionKeyResourceType', RESOURCE_TYPE); + const dimensions: DimensionRecord = { + DimensionKeyActionType: Action.Create, + DimensionKeyResourceType: RESOURCE_TYPE, + }; await publisher.publishMetric( MetricTypes.HandlerInvocationCount, dimensions, diff --git a/tests/lib/proxy.test.ts b/tests/lib/proxy.test.ts index 9eb1c11..6762eab 100644 --- a/tests/lib/proxy.test.ts +++ b/tests/lib/proxy.test.ts @@ -8,8 +8,6 @@ import { } from '../../src/interface'; describe('when getting session proxy', () => { - const BEARER_TOKEN = 'f3390613-b2b5-4c31-a4c6-66813dff96a6'; - class ResourceModel extends BaseModel { public static readonly TYPE_NAME: string = 'Test::Resource::Model'; @@ -17,6 +15,11 @@ describe('when getting session proxy', () => { public someotherkey: Optional; } + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + test('get session returns proxy', () => { const proxy: SessionProxy = SessionProxy.getSession({ accessKeyId: '', @@ -40,16 +43,12 @@ describe('when getting session proxy', () => { expect(event.errorCode).toBe(errorCode); expect(event.message).toBe(message); const serialized = event.serialize(); - expect(serialized).toEqual( - new Map( - Object.entries({ - status: OperationStatus.Failed, - errorCode: errorCode, - message, - callbackDelaySeconds: 0, - }) - ) - ); + expect(serialized).toMatchObject({ + status: OperationStatus.Failed, + errorCode: errorCode, + message, + callbackDelaySeconds: 0, + }); }); test('progress event serialize to response with context', () => { @@ -60,128 +59,88 @@ describe('when getting session proxy', () => { .status(OperationStatus.Success) .build(); const serialized = event.serialize(); - expect(serialized).toEqual( - new Map( - Object.entries({ - status: OperationStatus.Success, - message, - callbackContext: { - a: 'b', - }, - callbackDelaySeconds: 0, - }) - ) - ); + expect(serialized).toMatchObject({ + status: OperationStatus.Success, + message, + callbackContext: { + a: 'b', + }, + callbackDelaySeconds: 0, + }); }); test('progress event serialize to response with model', () => { const message = 'message of event with model'; - const model = new ResourceModel( - new Map( - Object.entries({ - somekey: 'a', - someotherkey: 'b', - somenullkey: null, - }) - ) - ); - const event = new ProgressEvent( - new Map( - Object.entries({ - status: OperationStatus.Success, - message, - resourceModel: model, - }) - ) - ); + const model = new ResourceModel({ + somekey: 'a', + someotherkey: 'b', + somenullkey: null, + }); + const event = new ProgressEvent({ + status: OperationStatus.Success, + message: message, + resourceModel: model, + }); const serialized = event.serialize(); - expect(serialized).toEqual( - new Map( - Object.entries({ - status: OperationStatus.Success, - message, - resourceModel: { - somekey: 'a', - someotherkey: 'b', - }, - callbackDelaySeconds: 0, - }) - ) - ); + expect(serialized).toMatchObject({ + status: OperationStatus.Success, + message, + resourceModel: { + somekey: 'a', + someotherkey: 'b', + }, + callbackDelaySeconds: 0, + }); }); test('progress event serialize to response with models', () => { const message = 'message of event with models'; const models = [ - new ResourceModel( - new Map( - Object.entries({ - somekey: 'a', - someotherkey: 'b', - }) - ) - ), - new ResourceModel( - new Map( - Object.entries({ - somekey: 'c', - someotherkey: 'd', - }) - ) - ), + new ResourceModel({ + somekey: 'a', + someotherkey: 'b', + }), + new ResourceModel({ + somekey: 'c', + someotherkey: 'd', + }), ]; - const event = new ProgressEvent( - new Map( - Object.entries({ - status: OperationStatus.Success, - message, - resourceModels: models, - }) - ) - ); + const event = new ProgressEvent({ + status: OperationStatus.Success, + message, + resourceModels: models, + }); const serialized = event.serialize(); - expect(serialized).toEqual( - new Map( - Object.entries({ - status: OperationStatus.Success, - message, - resourceModels: [ - { - somekey: 'a', - someotherkey: 'b', - }, - { - somekey: 'c', - someotherkey: 'd', - }, - ], - callbackDelaySeconds: 0, - }) - ) - ); + expect(serialized).toMatchObject({ + status: OperationStatus.Success, + message, + resourceModels: [ + { + somekey: 'a', + someotherkey: 'b', + }, + { + somekey: 'c', + someotherkey: 'd', + }, + ], + callbackDelaySeconds: 0, + }); }); test('progress event serialize to response with error code', () => { const message = 'message of event with error code'; - const event = new ProgressEvent( - new Map( - Object.entries({ - status: OperationStatus.Success, - message, - errorCode: HandlerErrorCode.InvalidRequest, - }) - ) - ); + const event = new ProgressEvent({ + status: OperationStatus.Success, + message, + errorCode: HandlerErrorCode.InvalidRequest, + }); const serialized = event.serialize(); - expect(serialized).toEqual( - new Map( - Object.entries({ - status: OperationStatus.Success, - message, - errorCode: HandlerErrorCode.InvalidRequest, - callbackDelaySeconds: 0, - }) - ) - ); + expect(serialized).toMatchObject({ + status: OperationStatus.Success, + message, + errorCode: HandlerErrorCode.InvalidRequest, + callbackDelaySeconds: 0, + }); }); }); diff --git a/tests/lib/recast.test.ts b/tests/lib/recast.test.ts index 3bb97b9..1666ac9 100644 --- a/tests/lib/recast.test.ts +++ b/tests/lib/recast.test.ts @@ -5,12 +5,6 @@ import { SimpleResourceModel, } from '../data/sample-model'; -const mockResult = (output: any): jest.Mock => { - return jest.fn().mockReturnValue({ - promise: jest.fn().mockResolvedValue(output), - }); -}; - describe('when recasting objects', () => { beforeAll(() => {}); @@ -115,7 +109,9 @@ describe('when recasting objects', () => { transformValue(SimpleResourceModel, k, v, {}); }; expect(recastObject).toThrow(exceptions.InvalidRequest); - expect(recastObject).toThrow(`Unsupported type: ${typeof v} for ${k}`); + expect(recastObject).toThrow( + `Unsupported type: ${typeof v} [${SimpleResourceModel.name}] for ${k}` + ); }); test('recast primitive object type', () => { diff --git a/tests/lib/resource.test.ts b/tests/lib/resource.test.ts index a7021b2..fa8c621 100644 --- a/tests/lib/resource.test.ts +++ b/tests/lib/resource.test.ts @@ -6,15 +6,14 @@ import { ProgressEvent, SessionProxy } from '../../src/proxy'; import { Action, BaseResourceHandlerRequest, - BaseModel, - CfnResponse, HandlerErrorCode, + HandlerRequest, OperationStatus, } from '../../src/interface'; import { ProviderLogHandler } from '../../src/log-delivery'; import { MetricsPublisherProxy } from '../../src/metrics'; import { handlerEvent, HandlerSignatures, BaseResource } from '../../src/resource'; -import { HandlerRequest } from '../../src/utils'; +import { SimpleStateModel } from '../data/sample-model'; const mockResult = (output: any): jest.Mock => { return jest.fn().mockReturnValue({ @@ -33,12 +32,9 @@ describe('when getting resource', () => { let mockSession: jest.SpyInstance; const TYPE_NAME = 'Test::Foo::Bar'; class Resource extends BaseResource {} - class MockModel extends BaseModel { + class MockModel extends SimpleStateModel { ['constructor']: typeof MockModel; public static readonly TYPE_NAME: string = TYPE_NAME; - public static deserialize(jsonData: any): MockModel { - return new MockModel(); - } } beforeEach(() => { @@ -92,8 +88,8 @@ describe('when getting resource', () => { }, providerLogGroupName: 'providerLoggingGroupName', logicalResourceId: 'myBucket', - resourceProperties: 'state1', - previousResourceProperties: 'state2', + resourceProperties: { state: 'state1' }, + previousResourceProperties: { state: 'state2' }, stackTags: { tag1: 'abc' }, previousStackTags: { tag1: 'def' }, }, @@ -131,7 +127,7 @@ describe('when getting resource', () => { test('entrypoint handler error', async () => { const resource = getResource(); - const event: CfnResponse = await resource.entrypoint({}, null); + const event = await resource.entrypoint({}, null); expect(event.status).toBe(OperationStatus.Failed); expect(event.errorCode).toBe(HandlerErrorCode.InvalidRequest); }); @@ -141,10 +137,7 @@ describe('when getting resource', () => { const mockHandler: jest.Mock = jest.fn(() => ProgressEvent.success()); const resource = new Resource(TYPE_NAME, MockModel); resource.addHandler(Action.Create, mockHandler); - const event: CfnResponse = await resource.entrypoint( - entrypointPayload, - null - ); + const event = await resource.entrypoint(entrypointPayload, null); expect(mockLogDelivery).toBeCalledTimes(1); expect(event).toMatchObject({ message: '', @@ -155,14 +148,7 @@ describe('when getting resource', () => { }); test('entrypoint handler raises', async () => { - class Model extends BaseModel { - ['constructor']: typeof Model; - aString: string; - public static deserialize(jsonData: any): Model { - return new Model('test'); - } - } - const resource = new Resource(TYPE_NAME, Model); + const resource = new Resource(TYPE_NAME, MockModel); const mockPublishException = (MetricsPublisherProxy.prototype .publishExceptionMetric as unknown) as jest.Mock; const mockInvokeHandler = jest.spyOn( @@ -172,10 +158,7 @@ describe('when getting resource', () => { mockInvokeHandler.mockImplementation(() => { throw new exceptions.InvalidRequest('handler failed'); }); - const event: CfnResponse = await resource.entrypoint( - entrypointPayload, - null - ); + const event = await resource.entrypoint(entrypointPayload, null); expect(mockPublishException).toBeCalledTimes(1); expect(mockInvokeHandler).toBeCalledTimes(1); expect(event).toMatchObject({ @@ -200,10 +183,7 @@ describe('when getting resource', () => { const mockHandler: jest.Mock = jest.fn(() => event); const resource = new Resource(TYPE_NAME, MockModel); resource.addHandler(Action.Create, mockHandler); - const response: CfnResponse = await resource.entrypoint( - entrypointPayload, - null - ); + const response = await resource.entrypoint(entrypointPayload, null); expect(response).toMatchObject({ message: '', status: OperationStatus.Success, @@ -213,7 +193,7 @@ describe('when getting resource', () => { expect(mockHandler).toBeCalledWith( expect.any(SessionProxy), expect.any(BaseResourceHandlerRequest), - new Map(Object.entries(entrypointPayload['callbackContext'])) + entrypointPayload['callbackContext'] ); }); @@ -225,10 +205,7 @@ describe('when getting resource', () => { const mockHandler: jest.Mock = jest.fn(() => event); const resource = new Resource(TYPE_NAME, MockModel); resource.addHandler(Action.Create, mockHandler); - const response: CfnResponse = await resource.entrypoint( - entrypointPayload, - null - ); + const response = await resource.entrypoint(entrypointPayload, null); expect(mockLogDelivery).toBeCalledTimes(1); expect(response).toMatchObject({ message: '', @@ -240,7 +217,7 @@ describe('when getting resource', () => { expect(mockHandler).toBeCalledWith( expect.any(SessionProxy), expect.any(BaseResourceHandlerRequest), - new Map() + {} ); }); @@ -256,10 +233,7 @@ describe('when getting resource', () => { // Credentials are defined in payload, but null. entrypointPayload['requestData']['providerCredentials'] = null; entrypointPayload['requestData']['callerCredentials'] = null; - let response: CfnResponse = await resource.entrypoint( - entrypointPayload, - null - ); + let response = await resource.entrypoint(entrypointPayload, null); expect(response).toMatchObject(expected); // Credentials are undefined in payload. @@ -271,21 +245,19 @@ describe('when getting resource', () => { test('parse request invalid request', () => { const parseRequest = () => { - Resource['parseRequest'](new Map()); + Resource['parseRequest']({}); }; expect(parseRequest).toThrow(exceptions.InvalidRequest); expect(parseRequest).toThrow(/missing.+awsAccountId/i); }); test('parse request with object literal callback context', () => { - const callbackContext = new Map(); - callbackContext.set('a', 'b'); + const callbackContext = { a: 'b' }; entrypointPayload['callbackContext'] = { a: 'b' }; - const payload = new Map(Object.entries(entrypointPayload)); const resource = getResource(); const [sessions, action, callback, request] = resource.constructor[ 'parseRequest' - ](payload); + ](entrypointPayload); expect(sessions).toBeDefined(); expect(action).toBeDefined(); expect(callback).toMatchObject(callbackContext); @@ -293,14 +265,12 @@ describe('when getting resource', () => { }); test('parse request with map callback context', () => { - const callbackContext = new Map(); - callbackContext.set('a', 'b'); + const callbackContext = { a: 'b' }; entrypointPayload['callbackContext'] = callbackContext; - const payload = new Map(Object.entries(entrypointPayload)); const resource = getResource(); const [sessions, action, callback, request] = resource.constructor[ 'parseRequest' - ](payload); + ](entrypointPayload); expect(sessions).toBeDefined(); expect(action).toBeDefined(); expect(callback).toMatchObject(callbackContext); @@ -308,8 +278,7 @@ describe('when getting resource', () => { }); test('cast resource request invalid request', () => { - const payload = new Map(Object.entries(entrypointPayload)); - const request = HandlerRequest.deserialize(payload); + const request = HandlerRequest.deserialize(entrypointPayload); request.requestData = null; const resource = getResource(); const castResourceRequest = () => { @@ -320,26 +289,12 @@ describe('when getting resource', () => { }); test('parse request valid request and cast resource request', () => { - const mockDeserialize: jest.Mock = jest - .fn() - .mockImplementationOnce(() => { - return { state: 'state1' }; - }) - .mockImplementationOnce(() => { - return { state: 'state2' }; - }); - - class Model extends BaseModel { - ['constructor']: typeof Model; - public static deserialize = mockDeserialize; - } - - const resource = new Resource(TYPE_NAME, Model); + const spyDeserialize: jest.SpyInstance = jest.spyOn(MockModel, 'deserialize'); + const resource = new Resource(TYPE_NAME, MockModel); - const payload = new Map(Object.entries(entrypointPayload)); const [sessions, action, callback, request] = resource.constructor[ 'parseRequest' - ](payload); + ](entrypointPayload); expect(mockSession).toBeCalledTimes(2); expect(mockSession).nthCalledWith( @@ -362,8 +317,8 @@ describe('when getting resource', () => { expect(callback).toMatchObject({}); const modeledRequest = resource['castResourceRequest'](request); - expect(mockDeserialize).nthCalledWith(1, 'state1'); - expect(mockDeserialize).nthCalledWith(2, 'state2'); + expect(spyDeserialize).nthCalledWith(1, { state: 'state1' }); + expect(spyDeserialize).nthCalledWith(2, { state: 'state2' }); expect(modeledRequest).toMatchObject({ clientRequestToken: request.bearerToken, desiredResourceState: { state: 'state1' }, @@ -378,7 +333,7 @@ describe('when getting resource', () => { throw new Error('exception'); }); const resource = getResource(); - const event: CfnResponse = await resource.entrypoint({}, null); + const event = await resource.entrypoint({}, null); expect(mockParseRequest).toBeCalledTimes(1); expect(event.status).toBe(OperationStatus.Failed); expect(event.errorCode).toBe(HandlerErrorCode.InternalFailure); @@ -422,8 +377,7 @@ describe('when getting resource', () => { const spyCreate = jest.spyOn(ResourceEventHandler.prototype, 'create'); const handlers: HandlerSignatures = new HandlerSignatures(); const resource = new ResourceEventHandler(TYPE_NAME, MockModel, handlers); - const payload = new Map(Object.entries(testEntrypointPayload)); - const event = await resource.testEntrypoint(payload, null); + const event = await resource.testEntrypoint(testEntrypointPayload, null); expect(spyCreate).toHaveReturnedTimes(1); expect(event.status).toBe(OperationStatus.Success); expect(event.message).toBe(TYPE_NAME); @@ -431,7 +385,7 @@ describe('when getting resource', () => { test('invoke handler not found', async () => { const resource = getResource(); - const callbackContext = new Map(); + const callbackContext = {}; const actual = await resource['invokeHandler']( null, null, @@ -453,7 +407,7 @@ describe('when getting resource', () => { const resource = getResource(handlers); const session = new SessionProxy({}); const request = new BaseResourceHandlerRequest(); - const callbackContext = new Map(); + const callbackContext = {}; const response = await resource['invokeHandler']( session, request, @@ -471,7 +425,7 @@ describe('when getting resource', () => { const handlers: HandlerSignatures = new HandlerSignatures(); handlers.set(action, mockHandler); const resource = getResource(handlers); - const callbackContext = new Map(); + const callbackContext = {}; expect( resource['invokeHandler'](null, null, action, callbackContext) ).rejects.toEqual( @@ -485,23 +439,18 @@ describe('when getting resource', () => { test('parse test request invalid request', () => { const resource = getResource(); const parseTestRequest = () => { - resource['parseTestRequest'](new Map()); + resource['parseTestRequest']({}); }; expect(parseTestRequest).toThrow(exceptions.InternalFailure); expect(parseTestRequest).toThrow(/missing.+credentials/i); }); test('parse test request with object literal callback context', () => { - const callbackContext = new Map(); - callbackContext.set('a', 'b'); - testEntrypointPayload['callbackContext'] = { a: 'b' }; - class Model extends BaseModel { - ['constructor']: typeof Model; - } - const resource = new Resource(TYPE_NAME, Model); - const payload = new Map(Object.entries(testEntrypointPayload)); + const callbackContext = { a: 'b' }; + testEntrypointPayload['callbackContext'] = callbackContext; + const resource = new Resource(TYPE_NAME, MockModel); const [session, request, action, callback] = resource['parseTestRequest']( - payload + testEntrypointPayload ); expect(session).toBeDefined(); expect(action).toBeDefined(); @@ -510,16 +459,11 @@ describe('when getting resource', () => { }); test('parse test request with map callback context', () => { - const callbackContext = new Map(); - callbackContext.set('a', 'b'); + const callbackContext = { a: 'b' }; testEntrypointPayload['callbackContext'] = callbackContext; - class Model extends BaseModel { - ['constructor']: typeof Model; - } - const resource = new Resource(TYPE_NAME, Model); - const payload = new Map(Object.entries(testEntrypointPayload)); + const resource = new Resource(TYPE_NAME, MockModel); const [session, request, action, callback] = resource['parseTestRequest']( - payload + testEntrypointPayload ); expect(session).toBeDefined(); expect(action).toBeDefined(); @@ -528,32 +472,15 @@ describe('when getting resource', () => { }); test('parse test request valid request', () => { - const mockDeserialize: jest.Mock = jest - .fn() - .mockImplementationOnce(() => { - return { state: 'state1' }; - }) - .mockImplementationOnce(() => { - return { state: 'state2' }; - }); - - class Model extends BaseModel { - ['constructor']: typeof Model; - public static deserialize = mockDeserialize; - } - - const resource = new Resource(TYPE_NAME, Model); + const resource = new Resource(TYPE_NAME, MockModel); resource.addHandler(Action.Create, jest.fn()); - const payload = new Map(Object.entries(testEntrypointPayload)); const [session, request, action, callback] = resource['parseTestRequest']( - payload + testEntrypointPayload ); expect(mockSession).toBeCalledTimes(1); expect(mockSession).toHaveReturnedWith(session); - expect(mockDeserialize).nthCalledWith(1, { state: 'state1' }); - expect(mockDeserialize).nthCalledWith(2, { state: 'state2' }); expect(request).toMatchObject({ clientRequestToken: 'ecba020e-b2e6-4742-a7d0-8a06ae7c4b2b', desiredResourceState: { state: 'state1' }, @@ -585,12 +512,8 @@ describe('when getting resource', () => { }); test('test entrypoint success', async () => { - class Model extends BaseModel { - ['constructor']: typeof Model; - } - const spyDeserialize: jest.SpyInstance = jest.spyOn(Model, 'deserialize'); - - const resource = new Resource(TYPE_NAME, Model); + const spyDeserialize: jest.SpyInstance = jest.spyOn(MockModel, 'deserialize'); + const resource = new Resource(TYPE_NAME, MockModel); const progressEvent: ProgressEvent = ProgressEvent.progress(); const mockHandler: jest.Mock = jest.fn(() => progressEvent); From 781a0a074135d4665b6e0af83cabc1beb835efdf Mon Sep 17 00:00:00 2001 From: Eduardo Rodrigues Date: Tue, 4 Aug 2020 23:50:37 +0200 Subject: [PATCH 3/7] update dependencies for class transformer --- package-lock.json | 6 +++--- package.json | 2 +- python/rpdk/typescript/codegen.py | 4 +--- python/rpdk/typescript/resolver.py | 6 +++--- python/rpdk/typescript/templates/package.json | 3 ++- 5 files changed, 10 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3eff058..a87e5f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1518,9 +1518,9 @@ "dev": true }, "class-transformer": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.2.3.tgz", - "integrity": "sha512-qsP+0xoavpOlJHuYsQJsN58HXSl8Jvveo+T37rEvCEeRfMWoytAyR0Ua/YsFgpM6AZYZ/og2PJwArwzJl1aXtQ==" + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.3.1.tgz", + "integrity": "sha512-cKFwohpJbuMovS8xVLmn8N2AUbAuc8pVo4zEfsUVo8qgECOogns1WVk/FkOZoxhOPTyTYFckuoH+13FO+MQ8GA==" }, "class-utils": { "version": "0.3.6", diff --git a/package.json b/package.json index 7096f23..17f9e6c 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "homepage": "https://github.com/eduardomourar/cloudformation-cli-typescript-plugin#readme", "dependencies": { "autobind-decorator": "^2.4.0", - "class-transformer": "^0.2.3", + "class-transformer": "^0.3.1", "promise-sequential": "^1.1.1", "reflect-metadata": "^0.1.13", "tombok": "https://github.com/eduardomourar/tombok/releases/download/v0.0.1/tombok-0.0.1.tgz", diff --git a/python/rpdk/typescript/codegen.py b/python/rpdk/typescript/codegen.py index 5e3c140..572a55e 100644 --- a/python/rpdk/typescript/codegen.py +++ b/python/rpdk/typescript/codegen.py @@ -164,9 +164,7 @@ def generate(self, project): type_name=project.type_name, models=models, primaryIdentifier=project.schema.get("primaryIdentifier", []), - additionalIdentifiers=project.schema.get( - "additionalIdentifiers", [] - ), + additionalIdentifiers=project.schema.get("additionalIdentifiers", []), ) project.overwrite(path, contents) diff --git a/python/rpdk/typescript/resolver.py b/python/rpdk/typescript/resolver.py index 97dbc35..96f4999 100644 --- a/python/rpdk/typescript/resolver.py +++ b/python/rpdk/typescript/resolver.py @@ -35,11 +35,11 @@ def resolve_type(self, resolved_type): if resolved_type.container == ContainerType.MODEL: return resolved_type.type if resolved_type.container == ContainerType.DICT: - self.classes.append('Map') + self.classes.append("Map") elif resolved_type.container == ContainerType.LIST: - self.classes.append('Array') + self.classes.append("Array") elif resolved_type.container == ContainerType.SET: - self.classes.append('Set') + self.classes.append("Set") return self.resolve_type(resolved_type.type) diff --git a/python/rpdk/typescript/templates/package.json b/python/rpdk/typescript/templates/package.json index 361adf9..cfceefb 100644 --- a/python/rpdk/typescript/templates/package.json +++ b/python/rpdk/typescript/templates/package.json @@ -12,7 +12,8 @@ "test": "echo \"Error: no test specified\" && exit 1" }, "dependencies": { - "{{lib_name}}": "{{lib_path}}" + "{{lib_name}}": "{{lib_path}}", + "class-transformer": "^0.3.1" }, "devDependencies": { "@types/node": "^12.0.0", From 608ada46813ae49590fc51ddab30b230967145fe Mon Sep 17 00:00:00 2001 From: Eduardo Rodrigues Date: Wed, 5 Aug 2020 15:54:11 +0200 Subject: [PATCH 4/7] increase test coverage --- python/rpdk/typescript/resolver.py | 2 + src/interface.ts | 5 +- tests/plugin/resolver_test.py | 81 ++++++++++++++++++++++++++---- 3 files changed, 76 insertions(+), 12 deletions(-) diff --git a/python/rpdk/typescript/resolver.py b/python/rpdk/typescript/resolver.py index 96f4999..f6857ed 100644 --- a/python/rpdk/typescript/resolver.py +++ b/python/rpdk/typescript/resolver.py @@ -40,6 +40,8 @@ def resolve_type(self, resolved_type): self.classes.append("Array") elif resolved_type.container == ContainerType.SET: self.classes.append("Set") + else: + raise ValueError(f"Unknown container type {resolved_type.container}") return self.resolve_type(resolved_type.type) diff --git a/src/interface.ts b/src/interface.ts index bd9502f..ad87d1a 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -27,7 +27,10 @@ interface Integer extends BigInt { * Defines the default JSON representation of * Integer (BigInt) to be a number. */ - toJSON: () => number; + toJSON(): number; + + /** Returns the primitive value of the specified object. */ + valueOf(): integer; } interface IntegerConstructor extends BigIntConstructor { diff --git a/tests/plugin/resolver_test.py b/tests/plugin/resolver_test.py index 7b6fe44..08e07c5 100644 --- a/tests/plugin/resolver_test.py +++ b/tests/plugin/resolver_test.py @@ -1,6 +1,11 @@ import pytest from rpdk.core.jsonutils.resolver import ContainerType, ResolvedType -from rpdk.typescript.resolver import PRIMITIVE_TYPES, contains_model, translate_type +from rpdk.typescript.resolver import ( + PRIMITIVE_TYPES, + contains_model, + get_inner_type, + translate_type, +) RESOLVED_TYPES = [ (ResolvedType(ContainerType.PRIMITIVE, item_type), native_type) @@ -10,8 +15,8 @@ def test_translate_type_model_passthrough(): item_type = object() - traslated = translate_type(ResolvedType(ContainerType.MODEL, item_type)) - assert traslated is item_type + translated = translate_type(ResolvedType(ContainerType.MODEL, item_type)) + assert translated is item_type @pytest.mark.parametrize("resolved_type,native_type", RESOLVED_TYPES) @@ -21,24 +26,30 @@ def test_translate_type_primitive(resolved_type, native_type): @pytest.mark.parametrize("resolved_type,native_type", RESOLVED_TYPES) def test_translate_type_dict(resolved_type, native_type): - traslated = translate_type(ResolvedType(ContainerType.DICT, resolved_type)) - assert traslated == f"Map" + translated = translate_type(ResolvedType(ContainerType.DICT, resolved_type)) + assert translated == f"Map" @pytest.mark.parametrize("resolved_type,native_type", RESOLVED_TYPES) def test_translate_type_list(resolved_type, native_type): - traslated = translate_type(ResolvedType(ContainerType.LIST, resolved_type)) - assert traslated == f"Array<{native_type}>" + translated = translate_type(ResolvedType(ContainerType.LIST, resolved_type)) + assert translated == f"Array<{native_type}>" @pytest.mark.parametrize("resolved_type,native_type", RESOLVED_TYPES) def test_translate_type_set(resolved_type, native_type): - traslated = translate_type(ResolvedType(ContainerType.SET, resolved_type)) - assert traslated == f"Set<{native_type}>" + translated = translate_type(ResolvedType(ContainerType.SET, resolved_type)) + assert translated == f"Set<{native_type}>" -@pytest.mark.parametrize("resolved_type,_typescript_type", RESOLVED_TYPES) -def test_translate_type_unknown(resolved_type, _typescript_type): +@pytest.mark.parametrize("resolved_type,_native_type", RESOLVED_TYPES) +def test_translate_type_multiple(resolved_type, _native_type): + translated = translate_type(ResolvedType(ContainerType.MULTIPLE, resolved_type)) + assert translated == "object" + + +@pytest.mark.parametrize("resolved_type,_native_type", RESOLVED_TYPES) +def test_translate_type_unknown(resolved_type, _native_type): with pytest.raises(ValueError): translate_type(ResolvedType("foo", resolved_type)) @@ -54,3 +65,51 @@ def test_contains_model_list_containing_model(): ResolvedType(ContainerType.LIST, ResolvedType(ContainerType.MODEL, "Foo")), ) assert contains_model(resolved_type) is True + + +def test_inner_type_model_passthrough(): + item_type = object() + inner_type = get_inner_type(ResolvedType(ContainerType.MODEL, item_type)) + assert inner_type.type is item_type + assert inner_type.primitive is False + + +@pytest.mark.parametrize("resolved_type,native_type", RESOLVED_TYPES) +def test_inner_type_primitive(resolved_type, native_type): + inner_type = get_inner_type(resolved_type) + assert inner_type.type == native_type + assert inner_type.primitive is True + + +@pytest.mark.parametrize("resolved_type,native_type", RESOLVED_TYPES) +def test_inner_type_dict(resolved_type, native_type): + inner_type = get_inner_type(ResolvedType(ContainerType.DICT, resolved_type)) + assert inner_type.type == native_type + assert inner_type.classes == ["Map"] + + +@pytest.mark.parametrize("resolved_type,native_type", RESOLVED_TYPES) +def test_inner_type_list(resolved_type, native_type): + inner_type = get_inner_type(ResolvedType(ContainerType.LIST, resolved_type)) + assert inner_type.type == native_type + assert inner_type.classes == ["Array"] + + +@pytest.mark.parametrize("resolved_type,native_type", RESOLVED_TYPES) +def test_inner_type_set(resolved_type, native_type): + inner_type = get_inner_type(ResolvedType(ContainerType.SET, resolved_type)) + assert inner_type.type == native_type + assert inner_type.classes == ["Set"] + + +@pytest.mark.parametrize("resolved_type,_native_type", RESOLVED_TYPES) +def test_inner_type_multiple(resolved_type, _native_type): + inner_type = get_inner_type(ResolvedType(ContainerType.MULTIPLE, resolved_type)) + assert inner_type.type == "object" + assert inner_type.primitive is True + + +@pytest.mark.parametrize("resolved_type,_native_type", RESOLVED_TYPES) +def test_inner_type_unknown(resolved_type, _native_type): + with pytest.raises(ValueError): + get_inner_type(ResolvedType("foo", resolved_type)) From ea70a9ebb94f9dbba2424e1d96c82919fc6d27a3 Mon Sep 17 00:00:00 2001 From: Eduardo Rodrigues Date: Wed, 5 Aug 2020 16:39:41 +0200 Subject: [PATCH 5/7] modify primitive callable type --- src/recast.ts | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/src/recast.ts b/src/recast.ts index efa0492..06f7e65 100644 --- a/src/recast.ts +++ b/src/recast.ts @@ -1,27 +1,20 @@ import { InvalidRequest } from './exceptions'; -import { integer, Integer } from './interface'; +import { Callable, integer, Integer } from './interface'; type primitive = string | number | boolean | bigint | integer | object; -type PrimitiveConstructor = - | StringConstructor - | NumberConstructor - | BooleanConstructor - | BigIntConstructor - | typeof Integer - | ObjectConstructor; /** * CloudFormation recasts all primitive types as strings, this tries to set them back to * the types defined in the model class */ export const recastPrimitive = ( - cls: PrimitiveConstructor, + cls: Callable, k: string, v: string ): primitive => { if (Object.is(cls, Object)) { - // If the type is plain object, we cannot guess what the original type was, so we leave - // it as a string + // If the type is plain object, we cannot guess what the original type was, + // so we leave it as a string return v; } if (Object.is(cls, Boolean)) { From 075690a2f02a10ec28632da13b46266c7d76cbee Mon Sep 17 00:00:00 2001 From: Eduardo Rodrigues Date: Sun, 9 Aug 2020 14:31:20 +0200 Subject: [PATCH 6/7] simplify typescript template --- python/rpdk/typescript/data/tsconfig.json | 1 - python/rpdk/typescript/templates/handlers.ts | 31 +++++++++++++------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/python/rpdk/typescript/data/tsconfig.json b/python/rpdk/typescript/data/tsconfig.json index db778b5..ffbcc7d 100644 --- a/python/rpdk/typescript/data/tsconfig.json +++ b/python/rpdk/typescript/data/tsconfig.json @@ -8,7 +8,6 @@ "moduleResolution": "node", "allowJs": true, "experimentalDecorators": true, - "emitDecoratorMetadata": true, "outDir": "dist" }, "include": [ diff --git a/python/rpdk/typescript/templates/handlers.ts b/python/rpdk/typescript/templates/handlers.ts index 371fa26..768f8d9 100644 --- a/python/rpdk/typescript/templates/handlers.ts +++ b/python/rpdk/typescript/templates/handlers.ts @@ -1,6 +1,7 @@ import { Action, BaseResource, + Dict, exceptions, handlerEvent, HandlerErrorCode, @@ -15,6 +16,8 @@ import { ResourceModel } from './models'; // Use this logger to forward log messages to CloudWatch Logs. const LOGGER = console; +interface CallbackContext extends Dict {} + class Resource extends BaseResource { /** @@ -23,13 +26,14 @@ class Resource extends BaseResource { * * @param session Current AWS session passed through from caller * @param request The request object for the provisioning request passed to the implementor - * @param callbackContext Custom context object to enable handlers to process re-invocation + * @param callbackContext Custom context object to allow the passing through of additional + * state or metadata between subsequent retries */ @handlerEvent(Action.Create) public async create( session: Optional, request: ResourceHandlerRequest, - callbackContext: Map, + callbackContext: CallbackContext, ): Promise { const model: ResourceModel = request.desiredResourceState; const progress: ProgressEvent = ProgressEvent.builder() @@ -61,13 +65,14 @@ class Resource extends BaseResource { * * @param session Current AWS session passed through from caller * @param request The request object for the provisioning request passed to the implementor - * @param callbackContext Custom context object to enable handlers to process re-invocation + * @param callbackContext Custom context object to allow the passing through of additional + * state or metadata between subsequent retries */ @handlerEvent(Action.Update) public async update( session: Optional, request: ResourceHandlerRequest, - callbackContext: Map, + callbackContext: CallbackContext, ): Promise { const model: ResourceModel = request.desiredResourceState; const progress: ProgressEvent = ProgressEvent.builder() @@ -86,13 +91,14 @@ class Resource extends BaseResource { * * @param session Current AWS session passed through from caller * @param request The request object for the provisioning request passed to the implementor - * @param callbackContext Custom context object to enable handlers to process re-invocation + * @param callbackContext Custom context object to allow the passing through of additional + * state or metadata between subsequent retries */ @handlerEvent(Action.Delete) public async delete( session: Optional, request: ResourceHandlerRequest, - callbackContext: Map, + callbackContext: CallbackContext, ): Promise { const model: ResourceModel = request.desiredResourceState; const progress: ProgressEvent = ProgressEvent.builder() @@ -109,13 +115,14 @@ class Resource extends BaseResource { * * @param session Current AWS session passed through from caller * @param request The request object for the provisioning request passed to the implementor - * @param callbackContext Custom context object to enable handlers to process re-invocation + * @param callbackContext Custom context object to allow the passing through of additional + * state or metadata between subsequent retries */ @handlerEvent(Action.Read) public async read( session: Optional, request: ResourceHandlerRequest, - callbackContext: Map, + callbackContext: CallbackContext, ): Promise { const model: ResourceModel = request.desiredResourceState; // TODO: put code here @@ -132,18 +139,20 @@ class Resource extends BaseResource { * * @param session Current AWS session passed through from caller * @param request The request object for the provisioning request passed to the implementor - * @param callbackContext Custom context object to enable handlers to process re-invocation + * @param callbackContext Custom context object to allow the passing through of additional + * state or metadata between subsequent retries */ @handlerEvent(Action.List) public async list( session: Optional, request: ResourceHandlerRequest, - callbackContext: Map, + callbackContext: CallbackContext, ): Promise { + const model: ResourceModel = request.desiredResourceState; // TODO: put code here const progress: ProgressEvent = ProgressEvent.builder() .status(OperationStatus.Success) - .resourceModels([]) + .resourceModels([model]) .build() as ProgressEvent; return progress; } From 6e2a80c8a075542c9c9267d55ff260b03a8a9ae1 Mon Sep 17 00:00:00 2001 From: Eduardo Rodrigues Date: Sun, 9 Aug 2020 22:01:27 +0200 Subject: [PATCH 7/7] update generics within progress event class --- python/rpdk/typescript/templates/handlers.ts | 26 +++++------------ src/proxy.ts | 30 ++++++++++++-------- 2 files changed, 25 insertions(+), 31 deletions(-) diff --git a/python/rpdk/typescript/templates/handlers.ts b/python/rpdk/typescript/templates/handlers.ts index 768f8d9..9d117b4 100644 --- a/python/rpdk/typescript/templates/handlers.ts +++ b/python/rpdk/typescript/templates/handlers.ts @@ -1,7 +1,6 @@ import { Action, BaseResource, - Dict, exceptions, handlerEvent, HandlerErrorCode, @@ -16,7 +15,7 @@ import { ResourceModel } from './models'; // Use this logger to forward log messages to CloudWatch Logs. const LOGGER = console; -interface CallbackContext extends Dict {} +interface CallbackContext extends Record {} class Resource extends BaseResource { @@ -36,10 +35,7 @@ class Resource extends BaseResource { callbackContext: CallbackContext, ): Promise { const model: ResourceModel = request.desiredResourceState; - const progress: ProgressEvent = ProgressEvent.builder() - .status(OperationStatus.InProgress) - .resourceModel(model) - .build() as ProgressEvent; + const progress = ProgressEvent.progress>(model); // TODO: put code here // Example: @@ -75,10 +71,7 @@ class Resource extends BaseResource { callbackContext: CallbackContext, ): Promise { const model: ResourceModel = request.desiredResourceState; - const progress: ProgressEvent = ProgressEvent.builder() - .status(OperationStatus.InProgress) - .resourceModel(model) - .build() as ProgressEvent; + const progress = ProgressEvent.progress>(model); // TODO: put code here progress.status = OperationStatus.Success; return progress; @@ -101,9 +94,7 @@ class Resource extends BaseResource { callbackContext: CallbackContext, ): Promise { const model: ResourceModel = request.desiredResourceState; - const progress: ProgressEvent = ProgressEvent.builder() - .status(OperationStatus.InProgress) - .build() as ProgressEvent; + const progress = ProgressEvent.progress>(); // TODO: put code here progress.status = OperationStatus.Success; return progress; @@ -126,10 +117,7 @@ class Resource extends BaseResource { ): Promise { const model: ResourceModel = request.desiredResourceState; // TODO: put code here - const progress: ProgressEvent = ProgressEvent.builder() - .status(OperationStatus.Success) - .resourceModel(model) - .build() as ProgressEvent; + const progress = ProgressEvent.success>(model); return progress; } @@ -150,10 +138,10 @@ class Resource extends BaseResource { ): Promise { const model: ResourceModel = request.desiredResourceState; // TODO: put code here - const progress: ProgressEvent = ProgressEvent.builder() + const progress = ProgressEvent.builder>() .status(OperationStatus.Success) .resourceModels([model]) - .build() as ProgressEvent; + .build(); return progress; } } diff --git a/src/proxy.ts b/src/proxy.ts index 2563288..6943844 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -44,7 +44,10 @@ export class SessionProxy { } @builder -export class ProgressEvent extends BaseDto { +export class ProgressEvent< + ResourceT extends BaseModel = BaseModel, + CallbackT = Dict +> extends BaseDto { /** * The status indicates whether the handler has reached a terminal state or is * still computing and requires more time to complete @@ -69,7 +72,7 @@ export class ProgressEvent extends Ba * metadata between subsequent retries; for example to pass through a Resource * identifier which can be used to continue polling for stabilization */ - @Expose() callbackContext?: T; + @Expose() callbackContext?: CallbackT; /** * A callback will be scheduled with an initial delay of no less than the number @@ -81,12 +84,12 @@ export class ProgressEvent extends Ba * The output resource instance populated by a READ for synchronous results and * by CREATE/UPDATE/DELETE for final response validation/confirmation */ - @Expose() resourceModel?: R; + @Expose() resourceModel?: ResourceT; /** * The output resource instances populated by a LIST for synchronous results */ - @Expose() resourceModels?: Array; + @Expose() resourceModels?: Array; /** * The token used to request additional pages of resources for a LIST operation @@ -102,7 +105,7 @@ export class ProgressEvent extends Ba // TODO: remove workaround when decorator mutation implemented: https://github.com/microsoft/TypeScript/issues/4881 @Exclude() - public static builder(template?: Partial): IBuilder { + public static builder(template?: Partial): IBuilder { return null; } @@ -110,8 +113,11 @@ export class ProgressEvent extends Ba * Convenience method for constructing FAILED response */ @Exclude() - public static failed(errorCode: HandlerErrorCode, message: string): ProgressEvent { - const event = ProgressEvent.builder() + public static failed( + errorCode: HandlerErrorCode, + message: string + ): T { + const event = ProgressEvent.builder() .status(OperationStatus.Failed) .errorCode(errorCode) .message(message) @@ -123,8 +129,8 @@ export class ProgressEvent extends Ba * Convenience method for constructing IN_PROGRESS response */ @Exclude() - public static progress(model?: any, ctx?: any): ProgressEvent { - const progress = ProgressEvent.builder().status(OperationStatus.InProgress); + public static progress(model?: any, ctx?: any): T { + const progress = ProgressEvent.builder().status(OperationStatus.InProgress); if (ctx) { progress.callbackContext(ctx); } @@ -135,12 +141,12 @@ export class ProgressEvent extends Ba return event; } - @Exclude() /** * Convenience method for constructing a SUCCESS response */ - public static success(model?: any, ctx?: any): ProgressEvent { - const event = ProgressEvent.progress(model, ctx); + @Exclude() + public static success(model?: any, ctx?: any): T { + const event = ProgressEvent.progress(model, ctx); event.status = OperationStatus.Success; return event; }