UNPKG

typed-serverless

Version:

Helps you write a consistent Serverless Framework configuration in TypeScript

428 lines (390 loc) 12.9 kB
import { BuildArn, CfRef, CfRefAtt, CfStringify, GetResourceLogicalId, GetResourceName, ServerlessResourcePlaceholder, } from './placeholders'; import { replaceValue } from '../utils/replaceValue'; import { debug, error, trace } from '../utils/logger'; import { traverseObject } from '../utils/traverseObject'; import { isCfIntrinsicFunction } from '../utils/isCfIntrinsicFunction'; import { Resource, FnSub, ResourceBuilder, Resources, ResourceProps, Functions, FunctionBuilder, ResourceAdapter, ServerlessFunction, BaseResourceParams, HookPhase, ProcessContext, ResourceType, TypedServerlessParams, } from './types'; import { getServerlessAwsFunctionLogicalId } from './serverlessNaming'; import { alarmArn, bucketArn, buildArnFnSub, BuildArnParamsWithoutResourceId, eventBusArn, lambdaArn, snsArn, sqsArn, stepFunctionArn, } from './arn'; import { Resolvable } from 'typed-aws'; import { AWS } from '@serverless/typescript'; import { defaultTypedServerlessParams } from './defaults'; export class TypedServerless< TId extends string = string, TResourceParams extends BaseResourceParams = BaseResourceParams, TConfigType extends AWS = AWS > { private constructor( readonly params: TypedServerlessParams<TId, TResourceParams, TConfigType> ) {} static createDefault<TId extends string = string>() { return new TypedServerless(defaultTypedServerlessParams<TId>()); } static create< TId extends string = string, TResourceParams extends BaseResourceParams = BaseResourceParams, TConfigType extends AWS = AWS >(params: TypedServerlessParams<TId, TResourceParams, TConfigType>) { return new TypedServerless<TId, TResourceParams, TConfigType>(params); } protected createResourcePlaceholder<T extends ResourceProps>( id: TId, type: ResourceType, builder: ResourceBuilder<TResourceParams, T> ): T { return this.asPlaceholder( new ServerlessResourcePlaceholder(id, type, builder) ); } extendsWith<T>( extension: (typed: TypedServerless<TId, TResourceParams, TConfigType>) => T ): TypedServerless<TId, TResourceParams, TConfigType> & T { const newInstance: TypedServerless<TId, TResourceParams, TConfigType> & T = Object.create(this); Object.assign(newInstance, extension(this)); return newInstance; } onlyFactory<Y extends string = string, X = unknown>(): (object: { [k in Y]: X; }) => { [k in Y]: X; } { return (object) => object; } only<Y extends string = string, X = unknown>(object: { [k in Y]: X }): { [k in Y]: X; } { return object; } protected addResources< TResourceId extends TId, TResource extends Resource<TResourceProps>, TResourceProps extends ResourceProps >(resources: { [key in TResourceId]: ResourceBuilder<TResourceParams, TResourceProps>; }): Resources<TResourceId, TResource, TResourceProps> { return Object.keys(resources).reduce((out, id) => { out[id] = this.createResourcePlaceholder( id as TResourceId, 'resource', resources[id] ); return out; }, {}); } resources< TResourceId extends TId, TResource extends Resource<TResourceProps>, TResourceProps extends ResourceProps >(resources: { [key in TResourceId]: ResourceBuilder<TResourceParams, TResourceProps>; }) { return this.addResources(resources) as Resources< TResourceId, TResource, TResourceProps >; } resource< TResourceId extends TId, TResource extends Resource<TResourceProps>, TResourceProps extends ResourceProps >(resource: { [key in TResourceId]: ResourceBuilder< TResourceParams, ResourceAdapter<TResource, TResourceProps> >; }) { return this.resources(resource); } functions< TFunctionId extends TId, TFunctionBuilderParams extends TResourceParams >(functions: { [K in TFunctionId]?: FunctionBuilder<TFunctionBuilderParams>; }): Functions<TFunctionId> { return Object.keys(functions).reduce((out, id) => { out[id] = this.createResourcePlaceholder( id as TFunctionId, 'function', functions[id] ); return out; }, {} as Functions<TFunctionId>); } protected asPlaceholder<T>(placeholder: unknown): T { return placeholder as unknown as T; } refId(id: TId): TId { return new GetResourceLogicalId(id) as unknown as TId; } ref<T>(id: TId): T { return this.asPlaceholder(new CfRef(id)); } getRef<T>(id: TId): T { return this.ref(id); } arn<T>(id: TId): T { return new CfRefAtt(id, 'Arn') as unknown as T; } getArn<T>(id: TId): T { return this.arn(id); } getAtt<T>(id: TId, attribute: string): T { return new CfRefAtt(id, attribute) as unknown as T; } getName<T>(id: TId): T { return new GetResourceName(id) as unknown as T; } fnSub(content: string, params?: Record<string, unknown>): FnSub { if (!params) return { 'Fn::Sub': content }; return { 'Fn::Sub': [content, params] }; } buildLambdaArn(id: TId) { return new BuildArn(lambdaArn(id)); } buildBucketArn(id: TId, path?: string) { return new BuildArn(bucketArn(id, path)); } buildSnsArn(id: TId) { return new BuildArn(snsArn(id)); } buildEventBusArn(id: TId) { return new BuildArn(eventBusArn(id)); } buildSqsArn(id: TId) { return new BuildArn(sqsArn(id)); } /** * @deprecated Prefer #arn - AWS Step Function automatically adds a name suffix, because of that its not possible to build a correct Arn */ buildStepFunctionArn(id: TId) { return new BuildArn(stepFunctionArn(id)); } buildAlarmArn(id: TId) { return new BuildArn(alarmArn(id)); } buildArn(id: TId, params?: BuildArnParamsWithoutResourceId) { return new BuildArn<TId>({ ...params, resourceId: id }); } /** * The main use case for this is to overcome a limitation in CloudFormation that * does not allow using intrinsic functions as dictionary keys (because * dictionary keys in JSON must be strings). Specifically this is common in IAM * conditions such as `StringEquals: { lhs: "rhs" }` where you want "lhs" to be * a reference. */ stringify<T>(content: unknown): T { return new CfStringify(content) as unknown as T; } cfn<T>(expression: Resolvable<string>): T { return expression as T; } protected resourcePlaceholderProcessor({ config, resourceNames, resourceTypes, }) { // deep traverse our config to find and resource placeholders traverseObject(config, (node, parent, key, path) => { if (node instanceof ServerlessResourcePlaceholder) { const { id, type, builder } = node; debug('Registering resource', id); const params = this.params.resourceParamsFactory(id, config); trace('Creating', type, id, 'parameters:', params); // Register this resource name and type resourceNames[id] = params.name; resourceTypes[id] = type; // Invoke builder to create new data for this placeholder const object = builder(params); trace('Created', type, id, 'object:', object); // Replace placeholder with new data replaceValue(parent, key, path, object); if (type === 'resource') { this.params?.onResourceCreated?.(object); } else if (type === 'function') { this.params?.onFunctionCreated?.(object as ServerlessFunction); } // stop visiting child properties, we do not support nested resources return false; } return true; }); } protected requiresResource( targetId: TId, sourcePath: string[], { errors, resourceNames, resourceTypes }: ProcessContext<TConfigType> ) { // validate if it's pointing to a registered resource... const name = resourceNames[targetId]; if (!name) { const message = `Referenced resource '${targetId}' not found! Check your configuration at '${sourcePath.join( '.' )}'`; error(message); errors.push(message); return null; } const logicalId = resourceTypes[targetId] === 'function' ? getServerlessAwsFunctionLogicalId(targetId) : targetId; return { logicalId, name }; } protected buildArnPlaceholderProcessor( processContext: ProcessContext<TConfigType> ) { // deep traverse our config to find and replace placeholders traverseObject(processContext.config, (node, parent, key, path) => { // if its a reference replaceholder... if (node instanceof BuildArn) { const id = node.params.resourceId; const resource = this.requiresResource(id, path, processContext); if (!resource) return true; // replace our placeholder with a real content... const arn = buildArnFnSub({ ...node.params, resourceId: resource.name, }); replaceValue(parent, key, path, arn); } // continue visiting all child nodes return true; }); } protected referencePlaceholderProcessor( processContext: ProcessContext<TConfigType> ) { // deep traverse our config to find and replace placeholders traverseObject(processContext.config, (node, parent, key, path) => { // if its a reference replaceholder... if ( node instanceof GetResourceName || node instanceof CfRefAtt || node instanceof CfRef || node instanceof GetResourceLogicalId ) { const resource = this.requiresResource(node.id, path, processContext); if (!resource) return true; // replace our placeholder with a real content... if (node instanceof GetResourceName) { replaceValue(parent, key, path, resource.name); } else if (node instanceof CfRefAtt) { const data = { 'Fn::GetAtt': [resource.logicalId, node.attribute] }; replaceValue(parent, key, path, data); } else if (node instanceof CfRef) { const data = { Ref: resource.logicalId }; replaceValue(parent, key, path, data); } else if (node instanceof GetResourceLogicalId) { replaceValue(parent, key, path, resource.logicalId); } } // continue visiting all child nodes return true; }); } protected replaceStringifyPlaceholders({ config, }: ProcessContext<TConfigType>) { // deep traverse our config to find and replace placeholders traverseObject(config, (node, parent, key, path) => { if (node instanceof CfStringify) { // extract cloudformation expressions as parameters const extractedParams = {}; // traverse all CloudFormation expressions Fn::* or Ref traverseObject(node, (childNode, parent, key, path) => { if (isCfIntrinsicFunction(childNode)) { const paramName = `extracted_param_${ Object.keys(extractedParams).length }`; extractedParams[paramName] = childNode; replaceValue(parent, key, path, '${' + paramName + '}'); return false; } return true; }); // Stringify content and replace with Fn::Sub [content, extractedParams] replaceValue(parent, key, path, { 'Fn::Sub': [JSON.stringify(node.content), extractedParams], }); } return true; }); } protected processHook( hookPhase: HookPhase, processContext: ProcessContext<TConfigType> ) { this.params.hooks?.[hookPhase]?.(processContext); } protected processPlaceholders(processContext: ProcessContext<TConfigType>) { // Replace Resource Placeholders this.processHook('before-resource', processContext); this.resourcePlaceholderProcessor(processContext); this.processHook('after-resource', processContext); // Replace BuildArn Placeholders this.buildArnPlaceholderProcessor(processContext); // Replace Reference Placeholders this.processHook('before-reference', processContext); this.referencePlaceholderProcessor(processContext); this.processHook('after-reference', processContext); // Replace Stringify Placeholders this.processHook('before-stringify', processContext); this.replaceStringifyPlaceholders(processContext); this.processHook('after-stringify', processContext); } process(config: TConfigType) { const processContext: ProcessContext<TConfigType> = { config, errors: [], resourceNames: {}, resourceTypes: {}, }; this.processPlaceholders(processContext); return processContext; } build(rawConfig: TConfigType) { const { config, errors } = this.process(rawConfig); if (errors.length) { throw Object.assign( new Error(`Validation errors!\n\t${errors.join('\n\t')}`), { errors, config } ); } return config; } }