diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f355909..32b8831 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,8 +7,8 @@ repos: - repo: https://github.com/ambv/black rev: stable hooks: - - id: black - # language_version: python3.6 + - id: black + # language_version: python3.6 - repo: https://github.com/pre-commit/pre-commit-hooks rev: v2.0.0 hooks: diff --git a/README.md b/README.md index 33a2733..ebc2a9b 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,9 @@ If you are using this package to build resource providers for CloudFormation, in **Prerequisites** - - Python version 3.6 or above - - [SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-install.html) - - Your choice of TypeScript IDE +- Python version 3.6 or above +- [SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-install.html) +- Your choice of TypeScript IDE **Installation** @@ -75,6 +75,19 @@ pip3 install \ That ensures neither is accidentally installed from PyPI. +For changes to the typescript library "@amazon-web-services-cloudformation/cloudformation-cli-typescript-lib" pack up the compiled javascript: + +```shell +npm run build +npm pack +``` + +You can then install this in a cfn resource project using: + +```shell +npm install ../path/to/cloudformation-cli-typescript-plugin/amazon-web-services-cloudformation-cloudformation-cli-typescript-lib-1.0.1.tgz +``` + Linting and running unit tests is done via [pre-commit](https://pre-commit.com/), and so is performed automatically on commit after being installed (`pre-commit install`). The continuous integration also runs these checks. Manual options are available so you don't have to commit: ```shell diff --git a/python/rpdk/typescript/codegen.py b/python/rpdk/typescript/codegen.py index b23c7ae..59e29c6 100644 --- a/python/rpdk/typescript/codegen.py +++ b/python/rpdk/typescript/codegen.py @@ -161,6 +161,15 @@ def generate(self, project): models = resolve_models(project.schema) + if project.configuration_schema: + configuration_models = resolve_models( + project.configuration_schema, "TypeConfigurationModel" + ) + else: + configuration_models = {"TypeConfigurationModel": {}} + + models.update(configuration_models) + path = self.package_root / "models.ts" LOG.debug("Writing file: %s", path) template = self.env.get_template("models.ts") @@ -168,6 +177,7 @@ def generate(self, project): lib_name=SUPPORT_LIB_NAME, type_name=project.type_name, models=models, + contains_type_configuration=project.configuration_schema, primaryIdentifier=project.schema.get("primaryIdentifier", []), additionalIdentifiers=project.schema.get("additionalIdentifiers", []), ) diff --git a/python/rpdk/typescript/templates/handlers.ts b/python/rpdk/typescript/templates/handlers.ts index 60300c3..3cb0d9b 100644 --- a/python/rpdk/typescript/templates/handlers.ts +++ b/python/rpdk/typescript/templates/handlers.ts @@ -11,7 +11,7 @@ import { ResourceHandlerRequest, SessionProxy, } from '{{lib_name}}'; -import { ResourceModel } from './models'; +import { ResourceModel, TypeConfigurationModel } from './models'; interface CallbackContext extends Record {} @@ -154,7 +154,7 @@ class Resource extends BaseResource { } } -export const resource = new Resource(ResourceModel.TYPE_NAME, ResourceModel); +export const resource = new Resource(ResourceModel.TYPE_NAME, ResourceModel, TypeConfigurationModel); // Entrypoint for production usage after registered in CloudFormation export const entrypoint = resource.entrypoint; diff --git a/src/exceptions.ts b/src/exceptions.ts index 1c776af..d8536ec 100644 --- a/src/exceptions.ts +++ b/src/exceptions.ts @@ -22,6 +22,15 @@ export class NotUpdatable extends BaseHandlerException {} export class InvalidRequest extends BaseHandlerException {} +export class InvalidTypeConfiguration extends BaseHandlerException { + constructor(typeName: string, reason: string) { + super( + `Invalid TypeConfiguration provided for type '${typeName}'. Reason: ${reason}`, + HandlerErrorCode.InvalidTypeConfiguration + ); + } +} + export class AccessDenied extends BaseHandlerException {} export class InvalidCredentials extends BaseHandlerException {} diff --git a/src/interface.ts b/src/interface.ts index 7833115..8b97307 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -160,6 +160,7 @@ export enum HandlerErrorCode { ServiceInternalError = 'ServiceInternalError', NetworkFailure = 'NetworkFailure', InternalFailure = 'InternalFailure', + InvalidTypeConfiguration = 'InvalidTypeConfiguration', } export interface Credentials { @@ -261,6 +262,7 @@ export class RequestData extends BaseDto { @Expose() providerCredentials?: Credentials; @Expose() previousResourceProperties?: T; @Expose() previousStackTags?: Dict; + @Expose() typeConfiguration?: Dict; } export class HandlerRequest extends BaseDto { diff --git a/src/resource.ts b/src/resource.ts index c56792a..e692a79 100644 --- a/src/resource.ts +++ b/src/resource.ts @@ -2,7 +2,12 @@ import 'reflect-metadata'; import { boundMethod } from 'autobind-decorator'; import { AwsTaskWorkerPool, ProgressEvent, SessionProxy } from './proxy'; -import { BaseHandlerException, InternalFailure, InvalidRequest } from './exceptions'; +import { + BaseHandlerException, + InternalFailure, + InvalidRequest, + InvalidTypeConfiguration, +} from './exceptions'; import { Action, BaseModel, @@ -40,13 +45,13 @@ const MUTATING_ACTIONS: [Action, Action, Action] = [ Action.Delete, ]; -export type HandlerSignature = Callable< - [Optional, any, Dict, LoggerProxy], +export type HandlerSignature = Callable< + [Optional, any, Dict, TC, LoggerProxy], Promise> >; -export class HandlerSignatures extends Map< +export class HandlerSignatures extends Map< Action, - HandlerSignature + HandlerSignature > {} class HandlerEvents extends Map {} @@ -88,7 +93,10 @@ function ensureSerialize(toResponse = false): MethodDecorat }; } -export abstract class BaseResource { +export abstract class BaseResource< + T extends BaseModel = BaseModel, + TC extends BaseModel = BaseModel +> { protected loggerProxy: LoggerProxy; protected metricsPublisherProxy: MetricsPublisherProxy; @@ -111,11 +119,14 @@ export abstract class BaseResource { constructor( public readonly typeName: string, public readonly modelTypeReference: Constructor, + public readonly typeConfigurationTypeReference: Constructor & { + deserialize: Function; + }, protected readonly workerPool?: AwsTaskWorkerPool, - private handlers?: HandlerSignatures + private handlers?: HandlerSignatures ) { this.typeName = typeName || ''; - this.handlers = handlers || new HandlerSignatures(); + this.handlers = handlers || new HandlerSignatures(); this.lambdaLogger = console; this.platformLoggerProxy = new LoggerProxy(); @@ -294,8 +305,8 @@ export abstract class BaseResource { public addHandler = ( action: Action, - f: HandlerSignature - ): HandlerSignature => { + f: HandlerSignature + ): HandlerSignature => { this.handlers.set(action, f); return f; }; @@ -304,13 +315,14 @@ export abstract class BaseResource { session: Optional, request: BaseResourceHandlerRequest, action: Action, - callbackContext: Dict + callbackContext: Dict, + typeConfiguration?: TC ): Promise> => { const actionName = action == null ? '' : action.toString(); if (!this.handlers.has(action)) { throw new Error(`Unknown action ${actionName}`); } - const handleRequest: HandlerSignature = this.handlers.get(action); + const handleRequest: HandlerSignature = this.handlers.get(action); // We will make the callback context and resource states readonly // to avoid modification at a later time deepFreeze(callbackContext); @@ -320,6 +332,7 @@ export abstract class BaseResource { session, request, callbackContext, + typeConfiguration, this.loggerProxy || this.platformLoggerProxy ); this.log(`[${action}] handler invoked`); @@ -473,6 +486,17 @@ export abstract class BaseResource { } }; + private castTypeConfigurationRequest = (request: HandlerRequest): TC => { + try { + return this.typeConfigurationTypeReference.deserialize( + request.requestData.typeConfiguration + ); + } catch (err) { + this.log('Invalid Type Configuration'); + throw new InvalidTypeConfiguration(this.typeName, `${err} (${err.name}`); + } + }; + // @ts-ignore public async entrypoint( eventData: any | Dict, @@ -500,6 +524,8 @@ export abstract class BaseResource { const [callerCredentials, providerCredentials] = credentials; const request = this.castResourceRequest(event); + const typeConfiguration = this.castTypeConfigurationRequest(event); + let streamName = `${event.awsAccountId}-${event.region}`; if (event.stackId && request.logicalResourceIdentifier) { streamName = `${event.stackId}/${request.logicalResourceIdentifier}`; @@ -550,7 +576,8 @@ export abstract class BaseResource { this.callerSession, request, action, - callback + callback, + typeConfiguration ); } catch (err) { error = err; diff --git a/tests/lib/resource.test.ts b/tests/lib/resource.test.ts index 45a73d1..607fadc 100644 --- a/tests/lib/resource.test.ts +++ b/tests/lib/resource.test.ts @@ -4,6 +4,7 @@ import * as exceptions from '~/exceptions'; import { ProgressEvent, SessionProxy } from '~/proxy'; import { Action, + BaseModel, BaseResourceHandlerRequest, HandlerErrorCode, HandlerRequest, @@ -43,7 +44,12 @@ describe('when getting resource', () => { ['constructor']: typeof MockModel; public static readonly TYPE_NAME: string = TYPE_NAME; } - class Resource extends BaseResource {} + class Resource extends BaseResource {} + + class MockTypeConfigurationModel extends BaseModel { + ['constructor']: typeof MockTypeConfigurationModel; + public static readonly TYPE_NAME: string = TYPE_NAME; + } beforeAll(() => { jest.spyOn(WorkerPoolAwsSdk.prototype, 'runTask').mockRejectedValue( @@ -82,6 +88,9 @@ describe('when getting resource', () => { previousResourceProperties: { state: 'state2' }, stackTags: { tag1: 'abc' }, previousStackTags: { tag1: 'def' }, + typeConfiguration: { + apiToken: 'fklwqrdmlsn', + }, }, stackId: 'arn:aws:cloudformation:us-east-1:123456789012:stack/sample-stack/e722ae60-fe62-11e8-9a0e-0ae8cc519968', @@ -124,8 +133,16 @@ describe('when getting resource', () => { await workerPool.shutdown(); }); - const getResource = (handlers?: HandlerSignatures): Resource => { - const instance = new Resource(TYPE_NAME, MockModel, workerPool, handlers); + const getResource = ( + handlers?: HandlerSignatures + ): Resource => { + const instance = new Resource( + TYPE_NAME, + MockModel, + MockTypeConfigurationModel, + workerPool, + handlers + ); return instance; }; @@ -137,7 +154,7 @@ describe('when getting resource', () => { }); test('entrypoint missing model class', async () => { - const resource = new Resource(TYPE_NAME, null); + const resource = new Resource(TYPE_NAME, null, null); const event = await resource.entrypoint({}, null); expect(event).toMatchObject({ message: 'Error: Missing Model class to be used to deserialize JSON data.', @@ -148,7 +165,7 @@ describe('when getting resource', () => { test('entrypoint success production-like', async () => { const mockHandler: jest.Mock = jest.fn(() => ProgressEvent.success()); - const resource = new Resource(TYPE_NAME, MockModel); + const resource = new Resource(TYPE_NAME, MockModel, MockTypeConfigurationModel); resource.addHandler(Action.Create, mockHandler); const event = await resource.entrypoint(entrypointPayload, null); expect(spyInitializeRuntime).toBeCalledTimes(1); @@ -161,7 +178,7 @@ describe('when getting resource', () => { }); test('publish exception metric without proxy', async () => { - const resource = new Resource(TYPE_NAME, MockModel); + const resource = new Resource(TYPE_NAME, MockModel, MockTypeConfigurationModel); resource.addHandler(Action.Create, jest.fn()); const mockPublishException = jest.fn(); MetricsPublisherProxy.prototype[ @@ -176,7 +193,7 @@ describe('when getting resource', () => { }); test('entrypoint handler raises', async () => { - const resource = new Resource(TYPE_NAME, MockModel); + const resource = new Resource(TYPE_NAME, MockModel, MockTypeConfigurationModel); const mockPublishException = jest.fn(); MetricsPublisherProxy.prototype[ 'publishExceptionMetric' @@ -200,7 +217,7 @@ describe('when getting resource', () => { }); test('entrypoint non mutating action', async () => { - const resource = new Resource(TYPE_NAME, MockModel); + const resource = new Resource(TYPE_NAME, MockModel, MockTypeConfigurationModel); entrypointPayload['action'] = 'READ'; const mockHandler: jest.Mock = jest.fn(() => ProgressEvent.success()); resource.addHandler(Action.Create, mockHandler); @@ -226,7 +243,7 @@ describe('when getting resource', () => { const mockPublishMessage = jest.fn().mockResolvedValue({}); LambdaLogPublisher.prototype['publishMessage'] = mockPublishMessage; CloudWatchLogPublisher.prototype['publishMessage'] = mockPublishMessage; - const resource = new Resource(TYPE_NAME, MockModel); + const resource = new Resource(TYPE_NAME, MockModel, MockTypeConfigurationModel); entrypointPayload['action'] = 'READ'; const mockHandler: jest.Mock = jest.fn(() => ProgressEvent.success()); resource.addHandler(Action.Read, mockHandler); @@ -255,7 +272,7 @@ describe('when getting resource', () => { entrypointPayload['callbackContext'] = { a: 'b' }; const event = ProgressEvent.success(null, { c: 'd' }); const mockHandler: jest.Mock = jest.fn(() => event); - const resource = new Resource(TYPE_NAME, MockModel); + const resource = new Resource(TYPE_NAME, MockModel, MockTypeConfigurationModel); resource.addHandler(Action.Create, mockHandler); const response = await resource.entrypoint(entrypointPayload, null); expect(response).toMatchObject({ @@ -268,6 +285,7 @@ describe('when getting resource', () => { expect.any(SessionProxy), expect.any(BaseResourceHandlerRequest), entrypointPayload['callbackContext'], + expect.any(MockTypeConfigurationModel), expect.any(LoggerProxy) ); }); @@ -277,7 +295,7 @@ describe('when getting resource', () => { const event = ProgressEvent.progress(null, { c: 'd' }); event.callbackDelaySeconds = 5; const mockHandler: jest.Mock = jest.fn(() => event); - const resource = new Resource(TYPE_NAME, MockModel); + const resource = new Resource(TYPE_NAME, MockModel, MockTypeConfigurationModel); resource.addHandler(Action.Create, mockHandler); const response = await resource.entrypoint(entrypointPayload, null); expect(spyInitializeRuntime).toBeCalledTimes(1); @@ -292,13 +310,40 @@ describe('when getting resource', () => { expect.any(SessionProxy), expect.any(BaseResourceHandlerRequest), {}, + expect.any(MockTypeConfigurationModel), + expect.any(LoggerProxy) + ); + }); + + test('entrypoint without type configuration', async () => { + entrypointPayload['callbackContext'] = { a: 'b' }; + delete entrypointPayload.requestData.typeConfiguration; + const event = ProgressEvent.progress(null, { c: 'd' }); + event.callbackDelaySeconds = 5; + const mockHandler: jest.Mock = jest.fn(() => event); + const resource = new Resource(TYPE_NAME, MockModel, MockTypeConfigurationModel); + resource.addHandler(Action.Create, mockHandler); + const response = await resource.entrypoint(entrypointPayload, null); + expect(spyInitializeRuntime).toBeCalledTimes(1); + expect(response).toMatchObject({ + message: '', + status: OperationStatus.InProgress, + callbackDelaySeconds: 5, + callbackContext: { c: 'd' }, + }); + expect(mockHandler).toBeCalledTimes(1); + expect(mockHandler).toBeCalledWith( + expect.any(SessionProxy), + expect.any(BaseResourceHandlerRequest), + entrypointPayload.callbackContext, + null, expect.any(LoggerProxy) ); }); test('entrypoint success without caller provider creds', async () => { const mockHandler: jest.Mock = jest.fn(() => ProgressEvent.success()); - const resource = new Resource(TYPE_NAME, MockModel); + const resource = new Resource(TYPE_NAME, MockModel, MockTypeConfigurationModel); resource.addHandler(Action.Create, mockHandler); const expected = { message: '', @@ -320,7 +365,7 @@ describe('when getting resource', () => { test('entrypoint with log stream failure', async () => { const mockHandler: jest.Mock = jest.fn(() => ProgressEvent.success()); - const resource = new Resource(TYPE_NAME, MockModel); + const resource = new Resource(TYPE_NAME, MockModel, MockTypeConfigurationModel); resource.addHandler(Action.Create, mockHandler); const spyPrepareLogStream = jest .spyOn(CloudWatchLogHelper.prototype, 'prepareLogStream') @@ -402,7 +447,7 @@ describe('when getting resource', () => { test('parse request valid request and cast resource request', () => { const spyDeserialize: jest.SpyInstance = jest.spyOn(MockModel, 'deserialize'); - const resource = new Resource(TYPE_NAME, MockModel); + const resource = new Resource(TYPE_NAME, MockModel, MockTypeConfigurationModel); const [ [callerCredentials, providerCredentials], @@ -456,7 +501,7 @@ describe('when getting resource', () => { 'Not allowed to submit a new task after progress tracker has been closed', }); const mockHandler: jest.Mock = jest.fn(() => ProgressEvent.success()); - const resource = new Resource(TYPE_NAME, MockModel); + const resource = new Resource(TYPE_NAME, MockModel, MockTypeConfigurationModel); resource.addHandler(Action.Create, mockHandler); const event = await resource.entrypoint(entrypointPayload, lambdaContext); expect(spyInitializeRuntime).toBeCalledTimes(1); @@ -472,7 +517,7 @@ describe('when getting resource', () => { test('entrypoint success with two consecutive calls', async () => { // We are emulating the execution context reuse in the lambda function const mockHandler: jest.Mock = jest.fn(() => ProgressEvent.success()); - const resource = new Resource(TYPE_NAME, MockModel); + const resource = new Resource(TYPE_NAME, MockModel, MockTypeConfigurationModel); resource.addHandler(Action.Create, mockHandler); jest.spyOn(S3LogHelper.prototype, 'prepareFolder').mockResolvedValue( null @@ -491,7 +536,10 @@ describe('when getting resource', () => { }); test('add handler', () => { - class ResourceEventHandler extends BaseResource { + class ResourceEventHandler extends BaseResource< + MockModel, + MockTypeConfigurationModel + > { @handlerEvent(Action.Create) public create(): void {} @handlerEvent(Action.Read) @@ -503,8 +551,14 @@ describe('when getting resource', () => { @handlerEvent(Action.List) public list(): void {} } - const handlers = new HandlerSignatures(); - const resource = new ResourceEventHandler(null, null, workerPool, handlers); + const handlers = new HandlerSignatures(); + const resource = new ResourceEventHandler( + null, + null, + null, + workerPool, + handlers + ); expect(resource['handlers'].get(Action.Create)).toBe(resource.create); expect(resource['handlers'].get(Action.Read)).toBe(resource.read); expect(resource['handlers'].get(Action.Update)).toBe(resource.update); @@ -513,7 +567,10 @@ describe('when getting resource', () => { }); test('check resource instance and type name', async () => { - class ResourceEventHandler extends BaseResource { + class ResourceEventHandler extends BaseResource< + MockModel, + MockTypeConfigurationModel + > { @handlerEvent(Action.Create) public async create(): Promise> { const progress = ProgressEvent.builder>() @@ -524,10 +581,11 @@ describe('when getting resource', () => { return progress; } } - const handlers = new HandlerSignatures(); + const handlers = new HandlerSignatures(); const resource = new ResourceEventHandler( TYPE_NAME, MockModel, + MockTypeConfigurationModel, workerPool, handlers ); @@ -552,17 +610,19 @@ describe('when getting resource', () => { test('invoke handler was found', async () => { const event = ProgressEvent.progress(); const mockHandler: jest.Mock = jest.fn(() => event); - const handlers = new HandlerSignatures(); + const handlers = new HandlerSignatures(); handlers.set(Action.Create, mockHandler); const resource = getResource(handlers); const session = new SessionProxy({}); const request = new BaseResourceHandlerRequest(); + const typeConf = new MockTypeConfigurationModel(); const callbackContext = {}; const response = await resource['invokeHandler']( session, request, Action.Create, - callbackContext + callbackContext, + typeConf ); expect(response).toBe(event); expect(mockHandler).toBeCalledTimes(1); @@ -570,15 +630,19 @@ describe('when getting resource', () => { session, request, callbackContext, + typeConf, expect.any(LoggerProxy) ); }); test('invoke handler non mutating must be synchronous', async () => { const promises: any[] = []; - [Action.List, Action.Read].forEach(async (action: Action) => { + for (const action of [Action.List, Action.Read]) { const mockHandler: jest.Mock = jest.fn(() => ProgressEvent.progress()); - const handlers = new HandlerSignatures(); + const handlers = new HandlerSignatures< + MockModel, + MockTypeConfigurationModel + >(); handlers.set(action, mockHandler); const resource = getResource(handlers); const callbackContext = {}; @@ -593,7 +657,7 @@ describe('when getting resource', () => { } ) ); - }); + } expect.assertions(promises.length); await Promise.all(promises); }); @@ -601,7 +665,7 @@ describe('when getting resource', () => { test('invoke handler try object modification', async () => { const event = ProgressEvent.progress(); const mockHandler: jest.Mock = jest.fn(() => event); - const handlers = new HandlerSignatures(); + const handlers = new HandlerSignatures(); handlers.set(Action.Create, mockHandler); const resource = getResource(handlers); const callbackContext = { @@ -651,7 +715,7 @@ describe('when getting resource', () => { test('parse test request with object literal callback context', () => { const callbackContext = { a: 'b' }; testEntrypointPayload['callbackContext'] = callbackContext; - const resource = new Resource(TYPE_NAME, MockModel); + const resource = new Resource(TYPE_NAME, MockModel, MockTypeConfigurationModel); const [request, action, callback] = resource['parseTestRequest']( testEntrypointPayload ); @@ -663,7 +727,7 @@ describe('when getting resource', () => { test('parse test request with map callback context', () => { const callbackContext = { a: 'b' }; testEntrypointPayload['callbackContext'] = callbackContext; - const resource = new Resource(TYPE_NAME, MockModel); + const resource = new Resource(TYPE_NAME, MockModel, MockTypeConfigurationModel); const [request, action, callback] = resource['parseTestRequest']( testEntrypointPayload ); @@ -673,7 +737,7 @@ describe('when getting resource', () => { }); test('parse test request valid request', () => { - const resource = new Resource(TYPE_NAME, MockModel); + const resource = new Resource(TYPE_NAME, MockModel, MockTypeConfigurationModel); resource.addHandler(Action.Create, jest.fn()); const [request, action, callback] = resource['parseTestRequest']( testEntrypointPayload @@ -710,7 +774,7 @@ describe('when getting resource', () => { }); test('test entrypoint missing model class', async () => { - const resource = new Resource(TYPE_NAME, null, workerPool); + const resource = new Resource(TYPE_NAME, null, null, workerPool); const event = await resource.testEntrypoint({}, null); expect(event).toMatchObject({ message: 'Error: Missing Model class to be used to deserialize JSON data.', @@ -721,7 +785,7 @@ describe('when getting resource', () => { test('test entrypoint success', async () => { const spyDeserialize: jest.SpyInstance = jest.spyOn(MockModel, 'deserialize'); - const resource = new Resource(TYPE_NAME, MockModel); + const resource = new Resource(TYPE_NAME, MockModel, MockTypeConfigurationModel); const progressEvent = ProgressEvent.progress(); const mockHandler: jest.Mock = jest.fn(() => progressEvent);