From 4e8e5b9013992409b09a38ce3aa6951c2232c161 Mon Sep 17 00:00:00 2001 From: Duarte Nunes Date: Fri, 4 Mar 2022 10:59:34 -0300 Subject: [PATCH] feat(aws-s3objectlambda): add L2 construct for S3 Object Lambda (#15833) This PR adds an L2 construct for the S3 Object Lambda. To avoid a circular dependency, the construct lives outside of the aws-s3 package. Fixes #13675 *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- .../@aws-cdk/aws-s3objectlambda/README.md | 85 ++++- .../aws-s3objectlambda/lib/access-point.ts | 255 +++++++++++++ .../@aws-cdk/aws-s3objectlambda/lib/index.ts | 2 + .../@aws-cdk/aws-s3objectlambda/package.json | 25 +- .../test/integ.s3objectlambda.expected.json | 239 ++++++++++++ .../test/integ.s3objectlambda.ts | 45 +++ .../test/s3objectlambda.test.ts | 340 +++++++++++++++++- 7 files changed, 974 insertions(+), 17 deletions(-) create mode 100644 packages/@aws-cdk/aws-s3objectlambda/lib/access-point.ts create mode 100644 packages/@aws-cdk/aws-s3objectlambda/test/integ.s3objectlambda.expected.json create mode 100644 packages/@aws-cdk/aws-s3objectlambda/test/integ.s3objectlambda.ts diff --git a/packages/@aws-cdk/aws-s3objectlambda/README.md b/packages/@aws-cdk/aws-s3objectlambda/README.md index 60a5c42835925..35deed15685a9 100644 --- a/packages/@aws-cdk/aws-s3objectlambda/README.md +++ b/packages/@aws-cdk/aws-s3objectlambda/README.md @@ -9,23 +9,92 @@ > > [CFN Resources]: https://docs.aws.amazon.com/cdk/latest/guide/constructs.html#constructs_lib +![cdk-constructs: Experimental](https://img.shields.io/badge/cdk--constructs-experimental-important.svg?style=for-the-badge) + +> The APIs of higher level constructs in this module are experimental and under active development. +> They are subject to non-backward compatible changes or removal in any future version. These are +> not subject to the [Semantic Versioning](https://semver.org/) model and breaking changes will be +> announced in the release notes. This means that while you may use them, you may need to update +> your source code when upgrading to a newer version of this package. + --- -This module is part of the [AWS Cloud Development Kit](https://github.com/aws/aws-cdk) project. +This construct library allows you to define S3 object lambda access points. -```ts nofixture +```ts +import * as lambda from '@aws-cdk/aws-lambda'; +import * as s3 from '@aws-cdk/aws-s3'; import * as s3objectlambda from '@aws-cdk/aws-s3objectlambda'; +import * as cdk from '@aws-cdk/core'; + +const stack = new cdk.Stack(); +const bucket = new s3.Bucket(stack, 'MyBucket'); +const handler = new lambda.Function(stack, 'MyFunction', { + runtime: lambda.Runtime.NODEJS_14_X, + handler: 'index.handler', + code: lambda.Code.fromAsset('lambda.zip'), +}); +new s3objectlambda.AccessPoint(stack, 'MyObjectLambda', { + bucket, + handler, + accessPointName: 'my-access-point', + payload: { + prop: "value", + }, +}); ``` - +## Handling range and part number requests + +Lambdas are currently limited to only transforming `GetObject` requests. However, they can additionally support `GetObject-Range` and `GetObject-PartNumber` requests, which needs to be specified in the access point configuration: + +```ts +import * as lambda from '@aws-cdk/aws-lambda'; +import * as s3 from '@aws-cdk/aws-s3'; +import * as s3objectlambda from '@aws-cdk/aws-s3objectlambda'; +import * as cdk from '@aws-cdk/core'; -There are no hand-written ([L2](https://docs.aws.amazon.com/cdk/latest/guide/constructs.html#constructs_lib)) constructs for this service yet. -However, you can still use the automatically generated [L1](https://docs.aws.amazon.com/cdk/latest/guide/constructs.html#constructs_l1_using) constructs, and use this service exactly as you would using CloudFormation directly. +const stack = new cdk.Stack(); +const bucket = new s3.Bucket(stack, 'MyBucket'); +const handler = new lambda.Function(stack, 'MyFunction', { + runtime: lambda.Runtime.NODEJS_14_X, + handler: 'index.handler', + code: lambda.Code.fromAsset('lambda.zip'), +}); +new s3objectlambda.AccessPoint(stack, 'MyObjectLambda', { + bucket, + handler, + accessPointName: 'my-access-point', + supportsGetObjectRange: true, + supportsGetObjectPartNumber: true, +}); +``` -For more information on the resources and properties available for this service, see the [CloudFormation documentation for AWS::S3ObjectLambda](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/AWS_S3ObjectLambda.html). +## Pass additional data to Lambda function -(Read the [CDK Contributing Guide](https://github.com/aws/aws-cdk/blob/master/CONTRIBUTING.md) if you are interested in contributing to this construct library.) +You can specify an additional object that provides supplemental data to the Lambda function used to transform objects. The data is delivered as a JSON payload to the Lambda: - +```ts +import * as lambda from '@aws-cdk/aws-lambda'; +import * as s3 from '@aws-cdk/aws-s3'; +import * as s3objectlambda from '@aws-cdk/aws-s3objectlambda'; +import * as cdk from '@aws-cdk/core'; + +const stack = new cdk.Stack(); +const bucket = new s3.Bucket(stack, 'MyBucket'); +const handler = new lambda.Function(stack, 'MyFunction', { + runtime: lambda.Runtime.NODEJS_14_X, + handler: 'index.handler', + code: lambda.Code.fromAsset('lambda.zip'), +}); +new s3objectlambda.AccessPoint(stack, 'MyObjectLambda', { + bucket, + handler, + accessPointName: 'my-access-point', + payload: { + prop: "value", + }, +}); +``` diff --git a/packages/@aws-cdk/aws-s3objectlambda/lib/access-point.ts b/packages/@aws-cdk/aws-s3objectlambda/lib/access-point.ts new file mode 100644 index 0000000000000..a99fd64669f81 --- /dev/null +++ b/packages/@aws-cdk/aws-s3objectlambda/lib/access-point.ts @@ -0,0 +1,255 @@ +import * as iam from '@aws-cdk/aws-iam'; +import * as lambda from '@aws-cdk/aws-lambda'; +import * as s3 from '@aws-cdk/aws-s3'; +import * as core from '@aws-cdk/core'; +import { Construct } from 'constructs'; +import { CfnAccessPoint } from './s3objectlambda.generated'; + +/** + * The interface that represents the AccessPoint resource. + */ +export interface IAccessPoint extends core.IResource { + /** + * The ARN of the access point. + * @attribute + */ + readonly accessPointArn: string; + + /** + * The creation data of the access point. + * @attribute + */ + readonly accessPointCreationDate: string; + + /** + * The IPv4 DNS name of the access point. + */ + readonly domainName: string; + + /** + * The regional domain name of the access point. + */ + readonly regionalDomainName: string; + + /** + * The virtual hosted-style URL of an S3 object through this access point. + * Specify `regional: false` at the options for non-regional URL. + * @param key The S3 key of the object. If not specified, the URL of the + * bucket is returned. + * @param options Options for generating URL. + * @returns an ObjectS3Url token + */ + virtualHostedUrlForObject(key?: string, options?: s3.VirtualHostedStyleUrlOptions): string; +} + +/** + * The S3 object lambda access point configuration. + */ +export interface AccessPointProps { + /** + * The bucket to which this access point belongs. + */ + readonly bucket: s3.IBucket; + + /** + * The Lambda function used to transform objects. + */ + readonly handler: lambda.IFunction; + + /** + * The name of the S3 object lambda access point. + * + * @default a unique name will be generated + */ + readonly accessPointName?: string; + + /** + * Whether CloudWatch metrics are enabled for the access point. + * + * @default false + */ + readonly cloudWatchMetricsEnabled?: boolean; + + /** + * Whether the Lambda function can process `GetObject-Range` requests. + * + * @default false + */ + readonly supportsGetObjectRange?: boolean; + + /** + * Whether the Lambda function can process `GetObject-PartNumber` requests. + * + * @default false + */ + readonly supportsGetObjectPartNumber?: boolean; + + /** + * Additional JSON that provides supplemental data passed to the + * Lambda function on every request. + * + * @default - No data. + */ + readonly payload?: { [key: string]: any }; +} + +abstract class AccessPointBase extends core.Resource implements IAccessPoint { + public abstract readonly accessPointArn: string; + public abstract readonly accessPointCreationDate: string; + public abstract readonly accessPointName: string; + + /** Implement the {@link IAccessPoint.domainName} field. */ + get domainName(): string { + const urlSuffix = this.stack.urlSuffix; + return `${this.accessPointName}-${this.stack.account}.s3-object-lambda.${urlSuffix}`; + } + + /** Implement the {@link IAccessPoint.regionalDomainName} field. */ + get regionalDomainName(): string { + const urlSuffix = this.stack.urlSuffix; + const region = this.stack.region; + return `${this.accessPointName}-${this.stack.account}.s3-object-lambda.${region}.${urlSuffix}`; + } + + /** Implement the {@link IAccessPoint.virtualHostedUrlForObject} method. */ + public virtualHostedUrlForObject(key?: string, options?: s3.VirtualHostedStyleUrlOptions): string { + const domainName = options?.regional ?? true ? this.regionalDomainName : this.domainName; + const prefix = `https://${domainName}`; + if (!key) { + return prefix; + } + if (key.startsWith('/')) { + key = key.slice(1); + } + if (key.endsWith('/')) { + key = key.slice(0, -1); + } + return `${prefix}/${key}`; + } +} + +/** + * The access point resource attributes. + */ +export interface AccessPointAttributes { + /** + * The ARN of the access point. + */ + readonly accessPointArn: string + + /** + * The creation data of the access point. + */ + readonly accessPointCreationDate: string; +} + +/** + * Checks the access point name against the rules in https://docs.aws.amazon.com/AmazonS3/latest/userguide/creating-access-points.html#access-points-names + * @param name The name of the access point + */ +function validateAccessPointName(name: string): void { + if (name.length < 3 || name.length > 50) { + throw new Error('Access point name must be between 3 and 50 characters long'); + } + if (name.endsWith('-s3alias')) { + throw new Error('Access point name cannot end with the suffix -s3alias'); + } + if (name[0] === '-' || name[name.length - 1] === '-') { + throw new Error('Access point name cannot begin or end with a dash'); + } + if (!/^[0-9a-z](.(?![\.A-Z_]))+[0-9a-z]$/.test(name)) { + throw new Error('Access point name must begin with a number or lowercase letter and not contain underscores, uppercase letters, or periods'); + } +} + +/** + * An S3 object lambda access point for intercepting and + * transforming `GetObject` requests. + */ +export class AccessPoint extends AccessPointBase { + /** + * Reference an existing AccessPoint defined outside of the CDK code. + */ + public static fromAccessPointAttributes(scope: Construct, id: string, attrs: AccessPointAttributes): IAccessPoint { + const arn = core.Arn.split(attrs.accessPointArn, core.ArnFormat.SLASH_RESOURCE_NAME); + if (!arn.resourceName) { + throw new Error('Unable to parse acess point name'); + } + const name = arn.resourceName; + class Import extends AccessPointBase { + public readonly accessPointArn: string = attrs.accessPointArn; + public readonly accessPointCreationDate: string = attrs.accessPointCreationDate; + public readonly accessPointName: string = name; + } + return new Import(scope, id); + } + + /** + * The ARN of the access point. + */ + public readonly accessPointName: string + + /** + * The ARN of the access point. + * @attribute + */ + public readonly accessPointArn: string + + /** + * The creation data of the access point. + * @attribute + */ + public readonly accessPointCreationDate: string + + constructor(scope: Construct, id: string, props: AccessPointProps) { + super(scope, id, { + physicalName: props.accessPointName, + }); + + if (props.accessPointName) { + validateAccessPointName(props.accessPointName); + } + + const supporting = new s3.CfnAccessPoint(this, 'SupportingAccessPoint', { + bucket: props.bucket.bucketName, + }); + + const allowedFeatures = []; + if (props.supportsGetObjectPartNumber) { + allowedFeatures.push('GetObject-PartNumber'); + } + if (props.supportsGetObjectRange) { + allowedFeatures.push('GetObject-Range'); + } + + const accessPoint = new CfnAccessPoint(this, id, { + name: this.physicalName, + objectLambdaConfiguration: { + allowedFeatures, + cloudWatchMetricsEnabled: props.cloudWatchMetricsEnabled, + supportingAccessPoint: supporting.attrArn, + transformationConfigurations: [ + { + actions: ['GetObject'], + contentTransformation: { + AwsLambda: { + FunctionArn: props.handler.functionArn, + FunctionPayload: props.payload ? JSON.stringify(props.payload) : undefined, + }, + }, + }, + ], + }, + }); + this.accessPointName = accessPoint.ref; + this.accessPointArn = accessPoint.attrArn; + this.accessPointCreationDate = accessPoint.attrCreationDate; + + props.handler.addToRolePolicy( + new iam.PolicyStatement({ + actions: ['s3-object-lambda:WriteGetObjectResponse'], + resources: ['*'], + }), + ); + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-s3objectlambda/lib/index.ts b/packages/@aws-cdk/aws-s3objectlambda/lib/index.ts index 791ddcf126933..e3c96c8d8be85 100644 --- a/packages/@aws-cdk/aws-s3objectlambda/lib/index.ts +++ b/packages/@aws-cdk/aws-s3objectlambda/lib/index.ts @@ -1,2 +1,4 @@ +export * from './access-point'; + // AWS::S3ObjectLambda CloudFormation Resources: export * from './s3objectlambda.generated'; diff --git a/packages/@aws-cdk/aws-s3objectlambda/package.json b/packages/@aws-cdk/aws-s3objectlambda/package.json index 23f511c76cc9e..97ccfc93dfff9 100644 --- a/packages/@aws-cdk/aws-s3objectlambda/package.json +++ b/packages/@aws-cdk/aws-s3objectlambda/package.json @@ -85,25 +85,40 @@ "devDependencies": { "@aws-cdk/assertions": "0.0.0", "@aws-cdk/cdk-build-tools": "0.0.0", + "@aws-cdk/cdk-integ-tools": "0.0.0", "@aws-cdk/cfn2ts": "0.0.0", "@aws-cdk/pkglint": "0.0.0", - "@types/jest": "^27.4.1" + "@types/jest": "^27.4.1", + "jest": "^27.5.1" }, "dependencies": { - "@aws-cdk/core": "0.0.0" + "@aws-cdk/aws-iam": "0.0.0", + "@aws-cdk/aws-lambda": "0.0.0", + "@aws-cdk/aws-s3": "0.0.0", + "@aws-cdk/core": "0.0.0", + "constructs": "^3.3.69" }, "peerDependencies": { - "@aws-cdk/core": "0.0.0" + "@aws-cdk/aws-iam": "0.0.0", + "@aws-cdk/aws-lambda": "0.0.0", + "@aws-cdk/aws-s3": "0.0.0", + "@aws-cdk/core": "0.0.0", + "constructs": "^3.3.69" }, "engines": { "node": ">= 10.13.0 <13 || >=13.7.0" }, "stability": "experimental", - "maturity": "cfn-only", + "maturity": "experimental", "awscdkio": { "announce": false }, "publishConfig": { "tag": "latest" + }, + "awslint": { + "exclude": [ + "attribute-tag:@aws-cdk/aws-s3objectlambda.AccessPoint.accessPointName" + ] } -} +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-s3objectlambda/test/integ.s3objectlambda.expected.json b/packages/@aws-cdk/aws-s3objectlambda/test/integ.s3objectlambda.expected.json new file mode 100644 index 0000000000000..c53bfb57cd719 --- /dev/null +++ b/packages/@aws-cdk/aws-s3objectlambda/test/integ.s3objectlambda.expected.json @@ -0,0 +1,239 @@ +{ + "Resources": { + "MyBucketF68F3FF0": { + "Type": "AWS::S3::Bucket", + "UpdateReplacePolicy": "Retain", + "DeletionPolicy": "Retain" + }, + "MyFunction1ServiceRole9852B06B": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "MyFunction1ServiceRoleDefaultPolicy39556460": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "s3-object-lambda:WriteGetObjectResponse", + "Effect": "Allow", + "Resource": "*" + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "MyFunction1ServiceRoleDefaultPolicy39556460", + "Roles": [ + { + "Ref": "MyFunction1ServiceRole9852B06B" + } + ] + } + }, + "MyFunction12A744C2E": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "foo" + }, + "Role": { + "Fn::GetAtt": [ + "MyFunction1ServiceRole9852B06B", + "Arn" + ] + }, + "Handler": "index.handler", + "Runtime": "nodejs14.x" + }, + "DependsOn": [ + "MyFunction1ServiceRoleDefaultPolicy39556460", + "MyFunction1ServiceRole9852B06B" + ] + }, + "MyFunction2ServiceRole07E5BE0E": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + } + } + ], + "Version": "2012-10-17" + }, + "ManagedPolicyArns": [ + { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" + ] + ] + } + ] + } + }, + "MyFunction2ServiceRoleDefaultPolicyA79C693E": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "s3-object-lambda:WriteGetObjectResponse", + "Effect": "Allow", + "Resource": "*" + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "MyFunction2ServiceRoleDefaultPolicyA79C693E", + "Roles": [ + { + "Ref": "MyFunction2ServiceRole07E5BE0E" + } + ] + } + }, + "MyFunction2F2A964CA": { + "Type": "AWS::Lambda::Function", + "Properties": { + "Code": { + "ZipFile": "foo" + }, + "Role": { + "Fn::GetAtt": [ + "MyFunction2ServiceRole07E5BE0E", + "Arn" + ] + }, + "Handler": "index.handler", + "Runtime": "nodejs14.x" + }, + "DependsOn": [ + "MyFunction2ServiceRoleDefaultPolicyA79C693E", + "MyFunction2ServiceRole07E5BE0E" + ] + }, + "MyObjectLambda1SupportingAccessPoint223B719B": { + "Type": "AWS::S3::AccessPoint", + "Properties": { + "Bucket": { + "Ref": "MyBucketF68F3FF0" + } + } + }, + "MyObjectLambda17554FEF4": { + "Type": "AWS::S3ObjectLambda::AccessPoint", + "Properties": { + "Name": "obj-lambda-1", + "ObjectLambdaConfiguration": { + "AllowedFeatures": [ + "GetObject-PartNumber" + ], + "CloudWatchMetricsEnabled": true, + "SupportingAccessPoint": { + "Fn::GetAtt": [ + "MyObjectLambda1SupportingAccessPoint223B719B", + "Arn" + ] + }, + "TransformationConfigurations": [ + { + "Actions": [ + "GetObject" + ], + "ContentTransformation": { + "AwsLambda": { + "FunctionArn": { + "Fn::GetAtt": [ + "MyFunction12A744C2E", + "Arn" + ] + } + } + } + } + ] + } + } + }, + "MyObjectLambda2SupportingAccessPoint6C54778F": { + "Type": "AWS::S3::AccessPoint", + "Properties": { + "Bucket": { + "Ref": "MyBucketF68F3FF0" + } + } + }, + "MyObjectLambda2CCBCAAF7": { + "Type": "AWS::S3ObjectLambda::AccessPoint", + "Properties": { + "Name": "obj-lambda-1", + "ObjectLambdaConfiguration": { + "AllowedFeatures": [ + "GetObject-Range" + ], + "SupportingAccessPoint": { + "Fn::GetAtt": [ + "MyObjectLambda2SupportingAccessPoint6C54778F", + "Arn" + ] + }, + "TransformationConfigurations": [ + { + "Actions": [ + "GetObject" + ], + "ContentTransformation": { + "AwsLambda": { + "FunctionArn": { + "Fn::GetAtt": [ + "MyFunction2F2A964CA", + "Arn" + ] + }, + "FunctionPayload": "{\"foo\":10}" + } + } + } + ] + } + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-s3objectlambda/test/integ.s3objectlambda.ts b/packages/@aws-cdk/aws-s3objectlambda/test/integ.s3objectlambda.ts new file mode 100644 index 0000000000000..e840de6367479 --- /dev/null +++ b/packages/@aws-cdk/aws-s3objectlambda/test/integ.s3objectlambda.ts @@ -0,0 +1,45 @@ +import * as lambda from '@aws-cdk/aws-lambda'; +import * as s3 from '@aws-cdk/aws-s3'; +import * as cdk from '@aws-cdk/core'; +import { AccessPoint } from '../lib'; + +class TestStack extends cdk.Stack { + constructor(scope: cdk.App, id: string) { + super(scope, id); + + const bucket = new s3.Bucket(this, 'MyBucket'); + const handler1 = new lambda.Function(this, 'MyFunction1', { + runtime: lambda.Runtime.NODEJS_14_X, + handler: 'index.handler', + code: lambda.Code.fromInline('foo'), + }); + + const handler2 = new lambda.Function(this, 'MyFunction2', { + runtime: lambda.Runtime.NODEJS_14_X, + handler: 'index.handler', + code: lambda.Code.fromInline('foo'), + }); + + new AccessPoint(this, 'MyObjectLambda1', { + bucket, + handler: handler1, + accessPointName: 'obj-lambda-1', + cloudWatchMetricsEnabled: true, + supportsGetObjectPartNumber: true, + }); + + new AccessPoint(this, 'MyObjectLambda2', { + bucket, + handler: handler2, + accessPointName: 'obj-lambda-1', + supportsGetObjectRange: true, + payload: { foo: 10 }, + }); + } +} + +const app = new cdk.App(); + +new TestStack(app, 'aws-s3-object-lambda'); + +app.synth(); diff --git a/packages/@aws-cdk/aws-s3objectlambda/test/s3objectlambda.test.ts b/packages/@aws-cdk/aws-s3objectlambda/test/s3objectlambda.test.ts index 465c7bdea0693..3e62fd370f039 100644 --- a/packages/@aws-cdk/aws-s3objectlambda/test/s3objectlambda.test.ts +++ b/packages/@aws-cdk/aws-s3objectlambda/test/s3objectlambda.test.ts @@ -1,6 +1,338 @@ -import '@aws-cdk/assertions'; -import {} from '../lib'; +import { Template } from '@aws-cdk/assertions'; +import * as lambda from '@aws-cdk/aws-lambda'; +import * as s3 from '@aws-cdk/aws-s3'; +import * as cdk from '@aws-cdk/core'; +import { AccessPoint } from '../lib'; -test('No tests are specified for this package', () => { - expect(true).toBe(true); +let stack: cdk.Stack; +let bucket: s3.Bucket; +let handler: lambda.Function; + +beforeEach(() => { + stack = new cdk.Stack(); + bucket = new s3.Bucket(stack, 'MyBucket'); + handler = new lambda.Function(stack, 'MyFunction', { + runtime: lambda.Runtime.NODEJS_14_X, + handler: 'index.hello', + code: new lambda.InlineCode('def hello(): pass'), + }); +}); + +test('Can create a valid access point', () => { + const accessPoint = new AccessPoint(stack, 'MyObjectLambda', { + bucket, + handler, + accessPointName: 'obj-lambda', + supportsGetObjectRange: true, + supportsGetObjectPartNumber: true, + payload: { foo: 10 }, + }); + new cdk.CfnOutput(stack, 'AccessPointName', { + value: accessPoint.accessPointName, + }); + new cdk.CfnOutput(stack, 'DomainName', { + value: accessPoint.domainName, + }); + new cdk.CfnOutput(stack, 'RegionalDomainName', { + value: accessPoint.regionalDomainName, + }); + new cdk.CfnOutput(stack, 'VirtualHostedUrl', { + value: accessPoint.virtualHostedUrlForObject('key', { + regional: true, + }), + }); + new cdk.CfnOutput(stack, 'VirtualHostedRegionalUrl', { + value: accessPoint.virtualHostedUrlForObject('key', { + regional: false, + }), + }); + + expect(Template.fromStack(stack).findOutputs('*')).toEqual( + { + AccessPointName: { + Value: { + Ref: 'MyObjectLambda3F9602DC', + }, + }, + DomainName: { + Value: { + 'Fn::Join': [ + '', + [ + { + Ref: 'MyObjectLambda3F9602DC', + }, + '-', + { + Ref: 'AWS::AccountId', + }, + '.s3-object-lambda.', + { + Ref: 'AWS::URLSuffix', + }, + ], + ], + }, + }, + RegionalDomainName: { + Value: { + 'Fn::Join': [ + '', + [ + { + Ref: 'MyObjectLambda3F9602DC', + }, + '-', + { + Ref: 'AWS::AccountId', + }, + '.s3-object-lambda.', + { + Ref: 'AWS::Region', + }, + '.', + { + Ref: 'AWS::URLSuffix', + }, + ], + ], + }, + }, + VirtualHostedRegionalUrl: { + Value: { + 'Fn::Join': [ + '', + [ + 'https://', + { + Ref: 'MyObjectLambda3F9602DC', + }, + '-', + { + Ref: 'AWS::AccountId', + }, + '.s3-object-lambda.', + { + Ref: 'AWS::URLSuffix', + }, + '/key', + ], + ], + }, + }, + VirtualHostedUrl: { + Value: { + 'Fn::Join': [ + '', + [ + 'https://', + { + Ref: 'MyObjectLambda3F9602DC', + }, + '-', + { + Ref: 'AWS::AccountId', + }, + '.s3-object-lambda.', + { + Ref: 'AWS::Region', + }, + '.', + { + Ref: 'AWS::URLSuffix', + }, + '/key', + ], + ], + }, + }, + }, + ); + + Template.fromStack(stack).hasResourceProperties('AWS::S3ObjectLambda::AccessPoint', { + ObjectLambdaConfiguration: { + AllowedFeatures: [ + 'GetObject-PartNumber', + 'GetObject-Range', + ], + TransformationConfigurations: [ + { + Actions: [ + 'GetObject', + ], + ContentTransformation: { + AwsLambda: { + FunctionArn: { + 'Fn::GetAtt': [ + 'MyFunction3BAA72D1', + 'Arn', + ], + }, + FunctionPayload: '{"foo":10}', + }, + }, + }, + ], + }, + }); +}); + +test('Can create an access point without specifying the name', () => { + new AccessPoint(stack, 'MyObjectLambda', { + bucket, + handler, + }); + Template.fromStack(stack).hasResourceProperties('AWS::S3ObjectLambda::AccessPoint', { + ObjectLambdaConfiguration: { + AllowedFeatures: [], + }, + }); +}); + +test('Slashes are removed from the virtual hosted url', () => { + const accessPoint = new AccessPoint(stack, 'MyObjectLambda', { + bucket, + handler, + }); + new cdk.CfnOutput(stack, 'VirtualHostedUrlNoKey', { + value: accessPoint.virtualHostedUrlForObject(), + }); + new cdk.CfnOutput(stack, 'VirtualHostedUrlKeyBeginsSlash', { + value: accessPoint.virtualHostedUrlForObject('/key1/key2'), + }); + new cdk.CfnOutput(stack, 'VirtualHostedUrlKeyEndsSlash', { + value: accessPoint.virtualHostedUrlForObject('key1/key2/'), + }); + expect(Template.fromStack(stack).findOutputs('*')).toEqual( { + VirtualHostedUrlKeyBeginsSlash: { + Value: { + 'Fn::Join': [ + '', + [ + 'https://', + { + Ref: 'MyObjectLambda3F9602DC', + }, + '-', + { + Ref: 'AWS::AccountId', + }, + '.s3-object-lambda.', + { + Ref: 'AWS::Region', + }, + '.', + { + Ref: 'AWS::URLSuffix', + }, + '/key1/key2', + ], + ], + }, + }, + VirtualHostedUrlKeyEndsSlash: { + Value: { + 'Fn::Join': [ + '', + [ + 'https://', + { + Ref: 'MyObjectLambda3F9602DC', + }, + '-', + { + Ref: 'AWS::AccountId', + }, + '.s3-object-lambda.', + { + Ref: 'AWS::Region', + }, + '.', + { + Ref: 'AWS::URLSuffix', + }, + '/key1/key2', + ], + ], + }, + }, + VirtualHostedUrlNoKey: { + Value: { + 'Fn::Join': [ + '', + [ + 'https://', + { + Ref: 'MyObjectLambda3F9602DC', + }, + '-', + { + Ref: 'AWS::AccountId', + }, + '.s3-object-lambda.', + { + Ref: 'AWS::Region', + }, + '.', + { + Ref: 'AWS::URLSuffix', + }, + ], + ], + }, + }, + }); +}); + +test('Validates the access point name', () => { + expect(() => new AccessPoint(stack, 'MyObjectLambda1', { + bucket, + handler, + accessPointName: 'aa', + })).toThrowError(/name must be between 3 and 50 characters long/); + expect(() => new AccessPoint(stack, 'MyObjectLambda2', { + bucket, + handler, + accessPointName: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + })).toThrowError(/name must be between 3 and 50 characters long/); + expect(() => new AccessPoint(stack, 'MyObjectLambda3', { + bucket, + handler, + accessPointName: 'aaaa-s3alias', + })).toThrowError(/name cannot end with the suffix -s3alias/); + expect(() => new AccessPoint(stack, 'MyObjectLambda4', { + bucket, + handler, + accessPointName: '-aaaaa', + })).toThrowError(/name cannot begin or end with a dash/); + expect(() => new AccessPoint(stack, 'MyObjectLambda5', { + bucket, + handler, + accessPointName: 'aaaaa-', + })).toThrowError(/name cannot begin or end with a dash/); + expect(() => new AccessPoint(stack, 'MyObjectLambda6', { + bucket, + handler, + accessPointName: 'Aaaaa', + })).toThrowError(/name must begin with a number or lowercase letter and not contain underscores, uppercase letters, or periods/); + expect(() => new AccessPoint(stack, 'MyObjectLambda7', { + bucket, + handler, + accessPointName: '$aaaaa', + })).toThrowError(/name must begin with a number or lowercase letter and not contain underscores, uppercase letters, or periods/); + expect(() => new AccessPoint(stack, 'MyObjectLambda8', { + bucket, + handler, + accessPointName: 'aaaAaaa', + })).toThrowError(/name must begin with a number or lowercase letter and not contain underscores, uppercase letters, or periods/); + expect(() => new AccessPoint(stack, 'MyObjectLambda9', { + bucket, + handler, + accessPointName: 'aaa_aaa', + })).toThrowError(/name must begin with a number or lowercase letter and not contain underscores, uppercase letters, or periods/); + expect(() => new AccessPoint(stack, 'MyObjectLambda10', { + bucket, + handler, + accessPointName: 'aaa.aaa', + })).toThrowError(/name must begin with a number or lowercase letter and not contain underscores, uppercase letters, or periods/); });