From c86314c6beed04641e5f43e706957280222e1368 Mon Sep 17 00:00:00 2001
From: Will Fu <115735916+www-fu@users.noreply.github.com>
Date: Tue, 11 Feb 2025 11:01:49 -0500
Subject: [PATCH] Add AWS::Transfer::WebApp handlers (#70)
Add resource handler source code for new resource.
---
aws-transfer-webapp/.gitignore | 25 ++
aws-transfer-webapp/.rpdk-config | 32 ++
aws-transfer-webapp/README.md | 12 +
aws-transfer-webapp/aws-transfer-webapp.json | 229 +++++++++++++
aws-transfer-webapp/docs/README.md | 114 ++++++
.../docs/identityproviderdetails.md | 58 ++++
aws-transfer-webapp/docs/tag.md | 46 +++
.../docs/webappcustomization.md | 66 ++++
aws-transfer-webapp/docs/webappunits.md | 32 ++
aws-transfer-webapp/lombok.config | 1 +
aws-transfer-webapp/pom.xml | 276 +++++++++++++++
aws-transfer-webapp/resource-role.yaml | 62 ++++
aws-transfer-webapp/spotbugs-exclude.xml | 15 +
.../transfer/webapp/BaseHandlerStd.java | 229 +++++++++++++
.../transfer/webapp/CallbackContext.java | 9 +
.../amazon/transfer/webapp/ClientBuilder.java | 10 +
.../amazon/transfer/webapp/Configuration.java | 8 +
.../amazon/transfer/webapp/Converter.java | 57 +++
.../amazon/transfer/webapp/CreateHandler.java | 163 +++++++++
.../amazon/transfer/webapp/DeleteHandler.java | 52 +++
.../amazon/transfer/webapp/ListHandler.java | 60 ++++
.../transfer/webapp/MockableBaseHandler.java | 24 ++
.../amazon/transfer/webapp/ReadHandler.java | 131 +++++++
.../amazon/transfer/webapp/UpdateHandler.java | 162 +++++++++
.../transfer/webapp/translators/BaseArn.java | 59 ++++
.../webapp/translators/TagHelper.java | 129 +++++++
.../webapp/translators/Translator.java | 144 ++++++++
.../webapp/translators/WebAppArn.java | 46 +++
aws-transfer-webapp/src/resources/log4j2.xml | 17 +
.../transfer/webapp/AbstractTestBase.java | 271 +++++++++++++++
.../transfer/webapp/BaseHandlerStdTest.java | 56 +++
.../transfer/webapp/CreateHandlerTest.java | 141 ++++++++
.../transfer/webapp/DeleteHandlerTest.java | 49 +++
.../transfer/webapp/ListHandlerTest.java | 66 ++++
.../transfer/webapp/ReadHandlerTest.java | 85 +++++
.../transfer/webapp/UpdateHandlerTest.java | 324 ++++++++++++++++++
.../webapp/translators/ExtraCoverageTest.java | 42 +++
.../webapp/translators/WebAppArnTest.java | 21 ++
.../src/test/resources/favicon.jpeg | Bin 0 -> 745 bytes
.../src/test/resources/favicon.png | Bin 0 -> 378 bytes
.../src/test/resources/logo.jpeg | Bin 0 -> 745 bytes
.../src/test/resources/logo.png | Bin 0 -> 378 bytes
aws-transfer-webapp/template.yml | 24 ++
pom.xml | 1 +
44 files changed, 3348 insertions(+)
create mode 100644 aws-transfer-webapp/.gitignore
create mode 100644 aws-transfer-webapp/.rpdk-config
create mode 100644 aws-transfer-webapp/README.md
create mode 100644 aws-transfer-webapp/aws-transfer-webapp.json
create mode 100644 aws-transfer-webapp/docs/README.md
create mode 100644 aws-transfer-webapp/docs/identityproviderdetails.md
create mode 100644 aws-transfer-webapp/docs/tag.md
create mode 100644 aws-transfer-webapp/docs/webappcustomization.md
create mode 100644 aws-transfer-webapp/docs/webappunits.md
create mode 100644 aws-transfer-webapp/lombok.config
create mode 100644 aws-transfer-webapp/pom.xml
create mode 100644 aws-transfer-webapp/resource-role.yaml
create mode 100644 aws-transfer-webapp/spotbugs-exclude.xml
create mode 100644 aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/BaseHandlerStd.java
create mode 100644 aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/CallbackContext.java
create mode 100644 aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/ClientBuilder.java
create mode 100644 aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/Configuration.java
create mode 100644 aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/Converter.java
create mode 100644 aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/CreateHandler.java
create mode 100644 aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/DeleteHandler.java
create mode 100644 aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/ListHandler.java
create mode 100644 aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/MockableBaseHandler.java
create mode 100644 aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/ReadHandler.java
create mode 100644 aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/UpdateHandler.java
create mode 100644 aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/translators/BaseArn.java
create mode 100644 aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/translators/TagHelper.java
create mode 100644 aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/translators/Translator.java
create mode 100644 aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/translators/WebAppArn.java
create mode 100644 aws-transfer-webapp/src/resources/log4j2.xml
create mode 100644 aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/AbstractTestBase.java
create mode 100644 aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/BaseHandlerStdTest.java
create mode 100644 aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/CreateHandlerTest.java
create mode 100644 aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/DeleteHandlerTest.java
create mode 100644 aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/ListHandlerTest.java
create mode 100644 aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/ReadHandlerTest.java
create mode 100644 aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/UpdateHandlerTest.java
create mode 100644 aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/translators/ExtraCoverageTest.java
create mode 100644 aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/translators/WebAppArnTest.java
create mode 100644 aws-transfer-webapp/src/test/resources/favicon.jpeg
create mode 100644 aws-transfer-webapp/src/test/resources/favicon.png
create mode 100644 aws-transfer-webapp/src/test/resources/logo.jpeg
create mode 100644 aws-transfer-webapp/src/test/resources/logo.png
create mode 100644 aws-transfer-webapp/template.yml
diff --git a/aws-transfer-webapp/.gitignore b/aws-transfer-webapp/.gitignore
new file mode 100644
index 0000000..7ee414b
--- /dev/null
+++ b/aws-transfer-webapp/.gitignore
@@ -0,0 +1,25 @@
+# macOS
+.DS_Store
+._*
+
+# Maven outputs
+.classpath
+
+# IntelliJ
+*.iml
+.idea
+out.java
+out/
+.settings
+.project
+
+# auto-generated files
+target/
+.hypothesis/
+build/
+
+# our logs
+rpdk.log*
+
+# contains credentials
+sam-tests/
diff --git a/aws-transfer-webapp/.rpdk-config b/aws-transfer-webapp/.rpdk-config
new file mode 100644
index 0000000..47c0ca6
--- /dev/null
+++ b/aws-transfer-webapp/.rpdk-config
@@ -0,0 +1,32 @@
+{
+ "artifact_type": "RESOURCE",
+ "typeName": "AWS::Transfer::WebApp",
+ "language": "java",
+ "runtime": "java17",
+ "entrypoint": "software.amazon.transfer.webapp.HandlerWrapper::handleRequest",
+ "testEntrypoint": "software.amazon.transfer.webapp.HandlerWrapper::testEntrypoint",
+ "settings": {
+ "version": false,
+ "subparser_name": null,
+ "verbose": 0,
+ "force": false,
+ "type_name": null,
+ "artifact_type": null,
+ "endpoint_url": null,
+ "region": null,
+ "target_schemas": [],
+ "profile": null,
+ "namespace": [
+ "software",
+ "amazon",
+ "transfer",
+ "webapp"
+ ],
+ "codegen_template_path": "guided_aws",
+ "protocolVersion": "2.0.0"
+ },
+ "logProcessorEnabled": "true",
+ "executableEntrypoint": "software.amazon.transfer.webapp.HandlerWrapperExecutable",
+ "contractSettings": {},
+ "canarySettings": {}
+}
diff --git a/aws-transfer-webapp/README.md b/aws-transfer-webapp/README.md
new file mode 100644
index 0000000..d80567e
--- /dev/null
+++ b/aws-transfer-webapp/README.md
@@ -0,0 +1,12 @@
+# AWS::Transfer::WebApp
+
+Congratulations on starting development! Next steps:
+
+1. Write the JSON schema describing your resource, `aws-transfer-webapp.json`
+1. Implement your resource handlers.
+
+The RPDK will automatically generate the correct resource model from the schema whenever the project is built via Maven. You can also do this manually with the following command: `cfn generate`.
+
+> Please don't modify files under `target/generated-sources/rpdk`, as they will be automatically overwritten.
+
+The code uses [Lombok](https://projectlombok.org/), and [you may have to install IDE integrations](https://projectlombok.org/setup/overview) to enable auto-complete for Lombok-annotated classes.
diff --git a/aws-transfer-webapp/aws-transfer-webapp.json b/aws-transfer-webapp/aws-transfer-webapp.json
new file mode 100644
index 0000000..1d390dc
--- /dev/null
+++ b/aws-transfer-webapp/aws-transfer-webapp.json
@@ -0,0 +1,229 @@
+{
+ "typeName": "AWS::Transfer::WebApp",
+ "description": "Resource Type definition for AWS::Transfer::WebApp",
+ "definitions": {
+ "IdentityProviderDetails": {
+ "type": "object",
+ "description": "You can provide a structure that contains the details for the identity provider to use with your web app.",
+ "properties": {
+ "ApplicationArn": {
+ "type": "string",
+ "maxLength": 1224,
+ "minLength": 10,
+ "pattern": "^arn:[\\w-]+:sso::\\d{12}:application/(sso)?ins-[a-zA-Z0-9-.]{16}/apl-[a-zA-Z0-9]{16}$"
+ },
+ "InstanceArn": {
+ "type": "string",
+ "description": "The Amazon Resource Name (ARN) for the IAM Identity Center used for the web app.",
+ "maxLength": 1224,
+ "minLength": 10,
+ "pattern": "^arn:[\\w-]+:sso:::instance/(sso)?ins-[a-zA-Z0-9-.]{16}$"
+ },
+ "Role": {
+ "type": "string",
+ "description": "The IAM role in IAM Identity Center used for the web app.",
+ "maxLength": 2048,
+ "minLength": 20,
+ "pattern": "^arn:[a-z-]+:iam::[0-9]{12}:role[:/]\\S+$"
+ }
+ },
+ "additionalProperties": false
+ },
+ "Tag": {
+ "type": "object",
+ "description": "Key-value pair that can be used to group and search for web apps.",
+ "properties": {
+ "Key": {
+ "type": "string",
+ "maxLength": 128,
+ "minLength": 0
+ },
+ "Value": {
+ "type": "string",
+ "maxLength": 256,
+ "minLength": 0
+ }
+ },
+ "required": [
+ "Key",
+ "Value"
+ ],
+ "additionalProperties": false
+ },
+ "WebAppCustomization": {
+ "type": "object",
+ "properties": {
+ "Title": {
+ "description": "Specifies a title to display on the web app.",
+ "type": "string",
+ "maxLength": 100,
+ "minLength": 0
+ },
+ "LogoFile": {
+ "description": "Specifies a logo to display on the web app.",
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 51200
+ },
+ "FaviconFile": {
+ "description": "Specifies a favicon to display in the browser tab.",
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 20960
+ }
+ },
+ "additionalProperties": false
+ },
+ "WebAppUnits": {
+ "oneOf": [
+ {
+ "type": "object",
+ "description": "A union that contains the value for number of concurrent connections or the user sessions on your web app.",
+ "title": "Provisioned",
+ "properties": {
+ "Provisioned": {
+ "type": "integer",
+ "minimum": 1
+ }
+ },
+ "required": [
+ "Provisioned"
+ ],
+ "additionalProperties": false
+ }
+ ]
+ }
+ },
+ "properties": {
+ "Arn": {
+ "description": "Specifies the unique Amazon Resource Name (ARN) for the web app.",
+ "type": "string",
+ "pattern": "arn:.*",
+ "minLength": 20,
+ "maxLength": 1600
+ },
+ "WebAppId": {
+ "description": "A unique identifier for the web app.",
+ "type": "string",
+ "pattern": "^webapp-([0-9a-f]{17})$",
+ "minLength": 24,
+ "maxLength": 24
+ },
+ "IdentityProviderDetails": {
+ "$ref": "#/definitions/IdentityProviderDetails"
+ },
+ "AccessEndpoint": {
+ "description": "The AccessEndpoint is the URL that you provide to your users for them to interact with the Transfer Family web app. You can specify a custom URL or use the default value.",
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 1024
+ },
+ "WebAppUnits": {
+ "$ref": "#/definitions/WebAppUnits"
+ },
+ "WebAppCustomization": {
+ "$ref": "#/definitions/WebAppCustomization"
+ },
+ "Tags": {
+ "type": "array",
+ "description": "Key-value pairs that can be used to group and search for web apps.",
+ "maxItems": 50,
+ "insertionOrder": false,
+ "items": {
+ "$ref": "#/definitions/Tag"
+ }
+ }
+ },
+ "additionalProperties": false,
+ "required": [
+ "IdentityProviderDetails"
+ ],
+ "readOnlyProperties": [
+ "/properties/Arn",
+ "/properties/WebAppId",
+ "/properties/IdentityProviderDetails/ApplicationArn"
+ ],
+ "createOnlyProperties": [
+ "/properties/IdentityProviderDetails/InstanceArn"
+ ],
+ "primaryIdentifier": [
+ "/properties/Arn"
+ ],
+ "additionalIdentifiers": [
+ [
+ "/properties/WebAppId"
+ ]
+ ],
+ "tagging": {
+ "taggable": true,
+ "tagOnCreate": true,
+ "tagUpdatable": true,
+ "cloudFormationSystemTags": true,
+ "tagProperty": "/properties/Tags",
+ "permissions": [
+ "transfer:TagResource",
+ "transfer:UnTagResource",
+ "transfer:ListTagsForResource"
+ ]
+ },
+ "handlers": {
+ "create": {
+ "permissions": [
+ "transfer:CreateWebApp",
+ "transfer:DescribeWebApp",
+ "transfer:DescribeWebAppCustomization",
+ "transfer:TagResource",
+ "transfer:UpdateWebAppCustomization",
+ "iam:PassRole",
+ "sso:CreateApplication",
+ "sso:DescribeApplication",
+ "sso:ListApplications",
+ "sso:PutApplicationGrant",
+ "sso:GetApplicationGrant",
+ "sso:ListApplicationGrants",
+ "sso:PutApplicationAuthenticationMethod",
+ "sso:GetApplicationAuthenticationMethod",
+ "sso:ListApplicationAuthenticationMethods",
+ "sso:PutApplicationAccessScope",
+ "sso:GetApplicationAccessScope",
+ "sso:ListApplicationAccessScopes"
+ ]
+ },
+ "read": {
+ "permissions": [
+ "transfer:DescribeWebApp",
+ "transfer:DescribeWebAppCustomization"
+ ]
+ },
+ "update": {
+ "permissions": [
+ "transfer:DescribeWebApp",
+ "transfer:DescribeWebAppCustomization",
+ "transfer:UpdateWebApp",
+ "transfer:UpdateWebAppCustomization",
+ "transfer:DeleteWebAppCustomization",
+ "transfer:UnTagResource",
+ "transfer:TagResource",
+ "iam:PassRole",
+ "sso:PutApplicationGrant",
+ "sso:GetApplicationGrant",
+ "sso:ListApplicationGrants",
+ "sso:UpdateApplication",
+ "sso:DescribeApplication",
+ "sso:ListApplications"
+ ]
+ },
+ "delete": {
+ "permissions": [
+ "transfer:DeleteWebApp",
+ "sso:DescribeApplication",
+ "sso:DeleteApplication"
+ ]
+ },
+ "list": {
+ "permissions": [
+ "transfer:ListWebApps"
+ ]
+ }
+ }
+}
diff --git a/aws-transfer-webapp/docs/README.md b/aws-transfer-webapp/docs/README.md
new file mode 100644
index 0000000..fa4a82a
--- /dev/null
+++ b/aws-transfer-webapp/docs/README.md
@@ -0,0 +1,114 @@
+# AWS::Transfer::WebApp
+
+Resource Type definition for AWS::Transfer::WebApp
+
+## Syntax
+
+To declare this entity in your AWS CloudFormation template, use the following syntax:
+
+### JSON
+
+
+{
+ "Type" : "AWS::Transfer::WebApp",
+ "Properties" : {
+ "IdentityProviderDetails " : IdentityProviderDetails ,
+ "AccessEndpoint " : String ,
+ "WebAppUnits " : WebAppUnits ,
+ "WebAppCustomization " : WebAppCustomization ,
+ "Tags " : [ Tag , ... ]
+ }
+}
+
+
+### YAML
+
+
+Type: AWS::Transfer::WebApp
+Properties:
+ IdentityProviderDetails : IdentityProviderDetails
+ AccessEndpoint : String
+ WebAppUnits : WebAppUnits
+ WebAppCustomization : WebAppCustomization
+ Tags :
+ - Tag
+
+
+## Properties
+
+#### IdentityProviderDetails
+
+You can provide a structure that contains the details for the identity provider to use with your web app.
+
+_Required_: Yes
+
+_Type_: IdentityProviderDetails
+
+_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt)
+
+#### AccessEndpoint
+
+The AccessEndpoint is the URL that you provide to your users for them to interact with the Transfer Family web app. You can specify a custom URL or use the default value.
+
+_Required_: No
+
+_Type_: String
+
+_Minimum Length_: 1
+
+_Maximum Length_: 1024
+
+_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt)
+
+#### WebAppUnits
+
+A union that contains the value for number of concurrent connections or the user sessions on your web app.
+
+_Required_: No
+
+_Type_: WebAppUnits
+
+_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt)
+
+#### WebAppCustomization
+
+_Required_: No
+
+_Type_: WebAppCustomization
+
+_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt)
+
+#### Tags
+
+Key-value pairs that can be used to group and search for web apps.
+
+_Required_: No
+
+_Type_: List of Tag
+
+_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt)
+
+## Return Values
+
+### Ref
+
+When you pass the logical ID of this resource to the intrinsic `Ref` function, Ref returns the Arn.
+
+### Fn::GetAtt
+
+The `Fn::GetAtt` intrinsic function returns a value for a specified attribute of this type. The following are the available attributes and sample return values.
+
+For more information about using the `Fn::GetAtt` intrinsic function, see [Fn::GetAtt](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference-getatt.html).
+
+#### Arn
+
+Specifies the unique Amazon Resource Name (ARN) for the web app.
+
+#### WebAppId
+
+A unique identifier for the web app.
+
+#### ApplicationArn
+
+Returns the ApplicationArn
value.
+
diff --git a/aws-transfer-webapp/docs/identityproviderdetails.md b/aws-transfer-webapp/docs/identityproviderdetails.md
new file mode 100644
index 0000000..2805be8
--- /dev/null
+++ b/aws-transfer-webapp/docs/identityproviderdetails.md
@@ -0,0 +1,58 @@
+# AWS::Transfer::WebApp IdentityProviderDetails
+
+You can provide a structure that contains the details for the identity provider to use with your web app.
+
+## Syntax
+
+To declare this entity in your AWS CloudFormation template, use the following syntax:
+
+### JSON
+
+
+{
+ "InstanceArn " : String ,
+ "Role " : String
+}
+
+
+### YAML
+
+
+InstanceArn : String
+Role : String
+
+
+## Properties
+
+#### InstanceArn
+
+The Amazon Resource Name (ARN) for the IAM Identity Center used for the web app.
+
+_Required_: No
+
+_Type_: String
+
+_Minimum Length_: 10
+
+_Maximum Length_: 1224
+
+_Pattern_: ^arn:[\w-]+:sso:::instance/(sso)?ins-[a-zA-Z0-9-.]{16}$
+
+_Update requires_: [Replacement](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-replacement)
+
+#### Role
+
+The IAM role in IAM Identity Center used for the web app.
+
+_Required_: No
+
+_Type_: String
+
+_Minimum Length_: 20
+
+_Maximum Length_: 2048
+
+_Pattern_: ^arn:[a-z-]+:iam::[0-9]{12}:role[:/]\S+$
+
+_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt)
+
diff --git a/aws-transfer-webapp/docs/tag.md b/aws-transfer-webapp/docs/tag.md
new file mode 100644
index 0000000..9331c70
--- /dev/null
+++ b/aws-transfer-webapp/docs/tag.md
@@ -0,0 +1,46 @@
+# AWS::Transfer::WebApp Tag
+
+Key-value pair that can be used to group and search for web apps.
+
+## Syntax
+
+To declare this entity in your AWS CloudFormation template, use the following syntax:
+
+### JSON
+
+
+{
+ "Key " : String ,
+ "Value " : String
+}
+
+
+### YAML
+
+
+Key : String
+Value : String
+
+
+## Properties
+
+#### Key
+
+_Required_: Yes
+
+_Type_: String
+
+_Maximum Length_: 128
+
+_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt)
+
+#### Value
+
+_Required_: Yes
+
+_Type_: String
+
+_Maximum Length_: 256
+
+_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt)
+
diff --git a/aws-transfer-webapp/docs/webappcustomization.md b/aws-transfer-webapp/docs/webappcustomization.md
new file mode 100644
index 0000000..daeeebc
--- /dev/null
+++ b/aws-transfer-webapp/docs/webappcustomization.md
@@ -0,0 +1,66 @@
+# AWS::Transfer::WebApp WebAppCustomization
+
+## Syntax
+
+To declare this entity in your AWS CloudFormation template, use the following syntax:
+
+### JSON
+
+
+{
+ "Title " : String ,
+ "LogoFile " : String ,
+ "FaviconFile " : String
+}
+
+
+### YAML
+
+
+Title : String
+LogoFile : String
+FaviconFile : String
+
+
+## Properties
+
+#### Title
+
+Specifies a title to display on the web app.
+
+_Required_: No
+
+_Type_: String
+
+_Maximum Length_: 100
+
+_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt)
+
+#### LogoFile
+
+Specifies a logo to display on the web app.
+
+_Required_: No
+
+_Type_: String
+
+_Minimum Length_: 1
+
+_Maximum Length_: 51200
+
+_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt)
+
+#### FaviconFile
+
+Specifies a favicon to display in the browser tab.
+
+_Required_: No
+
+_Type_: String
+
+_Minimum Length_: 1
+
+_Maximum Length_: 20960
+
+_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt)
+
diff --git a/aws-transfer-webapp/docs/webappunits.md b/aws-transfer-webapp/docs/webappunits.md
new file mode 100644
index 0000000..c79a5e8
--- /dev/null
+++ b/aws-transfer-webapp/docs/webappunits.md
@@ -0,0 +1,32 @@
+# AWS::Transfer::WebApp WebAppUnits
+
+A union that contains the value for number of concurrent connections or the user sessions on your web app.
+
+## Syntax
+
+To declare this entity in your AWS CloudFormation template, use the following syntax:
+
+### JSON
+
+
+{
+ "Provisioned " : Integer
+}
+
+
+### YAML
+
+
+Provisioned : Integer
+
+
+## Properties
+
+#### Provisioned
+
+_Required_: Yes
+
+_Type_: Integer
+
+_Update requires_: [No interruption](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/using-cfn-updating-stacks-update-behaviors.html#update-no-interrupt)
+
diff --git a/aws-transfer-webapp/lombok.config b/aws-transfer-webapp/lombok.config
new file mode 100644
index 0000000..7a21e88
--- /dev/null
+++ b/aws-transfer-webapp/lombok.config
@@ -0,0 +1 @@
+lombok.addLombokGeneratedAnnotation = true
diff --git a/aws-transfer-webapp/pom.xml b/aws-transfer-webapp/pom.xml
new file mode 100644
index 0000000..d81ceab
--- /dev/null
+++ b/aws-transfer-webapp/pom.xml
@@ -0,0 +1,276 @@
+
+
+ 4.0.0
+
+ software.amazon.transfer.webapp
+ aws-transfer-webapp-handler
+ aws-transfer-webapp-handler
+ 1.0-SNAPSHOT
+ jar
+
+
+ 17
+ 17
+ UTF-8
+ UTF-8
+
+
+
+
+
+
+ software.amazon.awssdk
+ transfer
+ 2.29.30
+
+
+
+ software.amazon.cloudformation
+ aws-cloudformation-rpdk-java-plugin
+ [2.0.0,3.0.0)
+
+
+
+ org.projectlombok
+ lombok
+ 1.18.30
+ provided
+
+
+
+ org.apache.logging.log4j
+ log4j-api
+ 2.22.0
+
+
+
+ org.apache.logging.log4j
+ log4j-core
+ 2.22.0
+
+
+
+ org.apache.logging.log4j
+ log4j-slf4j-impl
+ 2.22.0
+
+
+
+
+ org.assertj
+ assertj-core
+ 3.24.2
+ test
+
+
+
+ org.junit.jupiter
+ junit-jupiter
+ 5.10.1
+ test
+
+
+
+ org.mockito
+ mockito-core
+ 5.8.0
+ test
+
+
+
+ org.mockito
+ mockito-junit-jupiter
+ 5.8.0
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.11.0
+
+
+ -Xlint:all,-options,-processing
+ -Werror
+
+
+
+
+ org.apache.maven.plugins
+ maven-shade-plugin
+ 3.5.1
+
+ false
+
+
+ *:*
+
+ **/Log4j2Plugins.dat
+
+
+
+
+
+
+ package
+
+ shade
+
+
+
+
+
+ org.codehaus.mojo
+ exec-maven-plugin
+ 3.1.1
+
+
+ generate
+ generate-sources
+
+ exec
+
+
+ cfn
+ generate ${cfn.generate.args}
+ ${project.basedir}
+
+
+
+
+
+ org.codehaus.mojo
+ build-helper-maven-plugin
+ 3.5.0
+
+
+ add-source
+ generate-sources
+
+ add-source
+
+
+
+ ${project.basedir}/target/generated-sources/rpdk
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-resources-plugin
+ 3.3.1
+
+
+ maven-surefire-plugin
+ 3.2.3
+
+
+ true
+
+
+
+
+ org.jacoco
+ jacoco-maven-plugin
+ 0.8.11
+
+
+ **/BaseConfiguration*
+ **/BaseHandler*
+ **/Configuration*
+ **/HandlerWrapper*
+ **/ResourceModel*
+ **/ClientBuilder*
+ **/CallBackContext*
+ **/Converter*
+
+
+
+
+
+ prepare-agent
+
+
+
+ report
+ test
+
+ report
+
+
+
+ jacoco-check
+
+ check
+
+
+
+
+ PACKAGE
+
+
+ BRANCH
+ COVEREDRATIO
+ 0.75
+
+
+ INSTRUCTION
+ COVEREDRATIO
+ 0.8
+
+
+
+
+
+
+
+
+
+ com.diffplug.spotless
+ spotless-maven-plugin
+ 2.43.0
+
+
+
+ check
+
+
+
+
+
+
+
+
+
+ java,javax,com,org,amazon,software.amazon,com.amazon,com.amazonaws
+
+
+
+
+
+
+
+
+
+ ${project.basedir}
+
+ aws-transfer-webapp.json
+
+
+
+ ${project.basedir}/target/loaded-target-schemas
+
+ **/*.json
+
+
+
+
+
diff --git a/aws-transfer-webapp/resource-role.yaml b/aws-transfer-webapp/resource-role.yaml
new file mode 100644
index 0000000..8b829dc
--- /dev/null
+++ b/aws-transfer-webapp/resource-role.yaml
@@ -0,0 +1,62 @@
+AWSTemplateFormatVersion: "2010-09-09"
+Description: >
+ This CloudFormation template creates a role assumed by CloudFormation
+ during CRUDL operations to mutate resources on behalf of the customer.
+
+Resources:
+ ExecutionRole:
+ Type: AWS::IAM::Role
+ Properties:
+ MaxSessionDuration: 8400
+ AssumeRolePolicyDocument:
+ Version: '2012-10-17'
+ Statement:
+ - Effect: Allow
+ Principal:
+ Service: resources.cloudformation.amazonaws.com
+ Action: sts:AssumeRole
+ Condition:
+ StringEquals:
+ aws:SourceAccount:
+ Ref: AWS::AccountId
+ StringLike:
+ aws:SourceArn:
+ Fn::Sub: arn:${AWS::Partition}:cloudformation:${AWS::Region}:${AWS::AccountId}:type/resource/AWS-Transfer-WebApp/*
+ Path: "/"
+ Policies:
+ - PolicyName: ResourceTypePolicy
+ PolicyDocument:
+ Version: '2012-10-17'
+ Statement:
+ - Effect: Allow
+ Action:
+ - "iam:PassRole"
+ - "sso:CreateApplication"
+ - "sso:DeleteApplication"
+ - "sso:DescribeApplication"
+ - "sso:GetApplicationAccessScope"
+ - "sso:GetApplicationAuthenticationMethod"
+ - "sso:GetApplicationGrant"
+ - "sso:ListApplicationAccessScopes"
+ - "sso:ListApplicationAuthenticationMethods"
+ - "sso:ListApplicationGrants"
+ - "sso:ListApplications"
+ - "sso:PutApplicationAccessScope"
+ - "sso:PutApplicationAuthenticationMethod"
+ - "sso:PutApplicationGrant"
+ - "sso:UpdateApplication"
+ - "transfer:CreateWebApp"
+ - "transfer:DeleteWebApp"
+ - "transfer:DeleteWebAppCustomization"
+ - "transfer:DescribeWebApp"
+ - "transfer:DescribeWebAppCustomization"
+ - "transfer:ListWebApps"
+ - "transfer:TagResource"
+ - "transfer:UnTagResource"
+ - "transfer:UpdateWebApp"
+ - "transfer:UpdateWebAppCustomization"
+ Resource: "*"
+Outputs:
+ ExecutionRoleArn:
+ Value:
+ Fn::GetAtt: ExecutionRole.Arn
diff --git a/aws-transfer-webapp/spotbugs-exclude.xml b/aws-transfer-webapp/spotbugs-exclude.xml
new file mode 100644
index 0000000..adf9de4
--- /dev/null
+++ b/aws-transfer-webapp/spotbugs-exclude.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/BaseHandlerStd.java b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/BaseHandlerStd.java
new file mode 100644
index 0000000..13c4364
--- /dev/null
+++ b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/BaseHandlerStd.java
@@ -0,0 +1,229 @@
+package software.amazon.transfer.webapp;
+
+import java.util.Map;
+import java.util.Set;
+
+import org.apache.commons.lang3.StringUtils;
+
+import software.amazon.awssdk.awscore.exception.AwsServiceException;
+import software.amazon.awssdk.services.transfer.TransferClient;
+import software.amazon.awssdk.services.transfer.model.AccessDeniedException;
+import software.amazon.awssdk.services.transfer.model.InvalidNextTokenException;
+import software.amazon.awssdk.services.transfer.model.InvalidRequestException;
+import software.amazon.awssdk.services.transfer.model.ResourceExistsException;
+import software.amazon.awssdk.services.transfer.model.ResourceNotFoundException;
+import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy;
+import software.amazon.cloudformation.proxy.HandlerErrorCode;
+import software.amazon.cloudformation.proxy.Logger;
+import software.amazon.cloudformation.proxy.ProgressEvent;
+import software.amazon.cloudformation.proxy.ProxyClient;
+import software.amazon.cloudformation.proxy.ResourceHandlerRequest;
+import software.amazon.transfer.webapp.translators.TagHelper;
+import software.amazon.transfer.webapp.translators.Translator;
+
+/**
+ * The purpose of this base class is to allow a simple calling pattern that
+ * makes testing Uluru handlers easier and avoids having to store context
+ * in class fields. The {@link MockableBaseHandler} interface is used to
+ * make isolation of the inner call such that in testing we guarantee that
+ * the caller will not pick the wrong method and avoid human error.
+ */
+public abstract class BaseHandlerStd extends BaseHandler {
+ protected static final String CREATE = "Create";
+ protected static final String DELETE = "Delete";
+ protected static final String READ = "Read";
+ protected static final String LIST = "List";
+ protected static final String UPDATE = "Update";
+ private static final String FAILURE_LOG_MESSAGE =
+ "[ClientRequestToken: %s] Resource %s failed in %s operation, Error: %s%n";
+ private static final String ACCESS_DENIED_ERROR_CODE = "AccessDenied";
+ protected Logger logger;
+
+ @Override
+ public final ProgressEvent handleRequest(
+ final AmazonWebServicesClientProxy proxy,
+ final ResourceHandlerRequest request,
+ final CallbackContext callbackContext,
+ final Logger logger) {
+ this.logger = logger;
+
+ return handleRequest(
+ proxy,
+ request,
+ callbackContext != null ? callbackContext : new CallbackContext(),
+ proxy.newProxy(ClientBuilder::getClient),
+ logger);
+ }
+
+ protected abstract ProgressEvent handleRequest(
+ final AmazonWebServicesClientProxy proxy,
+ final ResourceHandlerRequest request,
+ final CallbackContext callbackContext,
+ final ProxyClient proxyClient,
+ final Logger logger);
+
+ /**
+ * Handle any service operation errors here.
+ *
+ * @param operation the operation name
+ * @param exception the error
+ * @param model ResourceModel of Listener
+ * @param context Callback context from request
+ * @param clientRequestToken the request token
+ * @return ProgressEvent
+ */
+ protected ProgressEvent handleError(
+ final String operation,
+ final Exception exception,
+ final ResourceModel model,
+ final CallbackContext context,
+ final String clientRequestToken) {
+
+ if (exception instanceof ResourceExistsException) {
+ return translateToFailure(operation, exception, HandlerErrorCode.AlreadyExists, model, clientRequestToken);
+ }
+
+ if (exception instanceof ResourceNotFoundException) {
+ return translateToFailure(operation, exception, HandlerErrorCode.NotFound, model, clientRequestToken);
+ }
+
+ if (exception instanceof AccessDeniedException) {
+ return translateToFailure(operation, exception, HandlerErrorCode.AccessDenied, model, clientRequestToken);
+ }
+
+ if (exception instanceof InvalidRequestException) {
+ return translateToFailure(operation, exception, HandlerErrorCode.InvalidRequest, model, clientRequestToken);
+ }
+
+ if (exception instanceof InvalidNextTokenException) {
+ return translateToFailure(operation, exception, HandlerErrorCode.InvalidRequest, model, clientRequestToken);
+ }
+
+ return translateToFailure(
+ operation, exception, HandlerErrorCode.GeneralServiceException, model, clientRequestToken);
+ }
+
+ private ProgressEvent translateToFailure(
+ String operation,
+ Exception exception,
+ HandlerErrorCode errorCode,
+ ResourceModel model,
+ String clientRequestToken) {
+ logger.log(String.format(
+ FAILURE_LOG_MESSAGE, clientRequestToken, model.getPrimaryIdentifier(), operation, exception));
+ return ProgressEvent.defaultFailureHandler(exception, errorCode);
+ }
+
+ private String getErrorCode(Exception e) {
+ if (e instanceof AwsServiceException && ((AwsServiceException) e).awsErrorDetails() != null) {
+ return ((AwsServiceException) e).awsErrorDetails().errorCode();
+ }
+ return e.getMessage();
+ }
+
+ protected void log(String message, Object identifier) {
+ logger.log(String.format("%s [%s] %s", ResourceModel.TYPE_NAME, identifier, message));
+ }
+
+ protected ProgressEvent addTags(
+ ProgressEvent progress,
+ ResourceHandlerRequest request,
+ ResourceModel resourceModel,
+ AmazonWebServicesClientProxy proxy,
+ ProxyClient proxyClient,
+ CallbackContext callbackContext) {
+ if (TagHelper.shouldUpdateTags(request)) {
+
+ Map previousTags = TagHelper.getPreviouslyAttachedTags(request);
+ Map desiredTags = TagHelper.getNewDesiredTags(request);
+ Map tagsToAdd = TagHelper.generateTagsToAdd(previousTags, desiredTags);
+
+ if (!tagsToAdd.isEmpty()) {
+ progress = tagResource(proxy, proxyClient, resourceModel, request, callbackContext, tagsToAdd);
+ }
+ }
+ return progress;
+ }
+
+ private ProgressEvent tagResource(
+ final AmazonWebServicesClientProxy proxy,
+ final ProxyClient serviceClient,
+ final ResourceModel resourceModel,
+ final ResourceHandlerRequest request,
+ final CallbackContext callbackContext,
+ final Map addedTags) {
+ logger.log(String.format(
+ "[UPDATE][IN PROGRESS] Going to add tags for web app: %s with AccountId: %s",
+ resourceModel.getWebAppId(), request.getAwsAccountId()));
+ final String clientRequestToken = request.getClientRequestToken();
+
+ return proxy.initiate("AWS-Transfer-Web-Apps::TagOps", serviceClient, resourceModel, callbackContext)
+ .translateToServiceRequest(model -> Translator.tagResourceRequest(model, addedTags))
+ .makeServiceCall((tagRequest, client) -> {
+ try (TransferClient transferClient = client.client()) {
+ return client.injectCredentialsAndInvokeV2(tagRequest, transferClient::tagResource);
+ }
+ })
+ .handleError((ignored, exception, proxyClient, model, context) -> {
+ if (isEnvironmentTaggingException(exception)) {
+ return ProgressEvent.failed(
+ model, context, HandlerErrorCode.UnauthorizedTaggingOperation, exception.getMessage());
+ }
+ return handleError(UPDATE, exception, model, context, clientRequestToken);
+ })
+ .progress();
+ }
+
+ protected ProgressEvent removeTags(
+ ProgressEvent progress,
+ ResourceHandlerRequest request,
+ ResourceModel resourceModel,
+ AmazonWebServicesClientProxy proxy,
+ ProxyClient proxyClient,
+ CallbackContext callbackContext) {
+ if (TagHelper.shouldUpdateTags(request)) {
+
+ Map previousTags = TagHelper.getPreviouslyAttachedTags(request);
+ Map desiredTags = TagHelper.getNewDesiredTags(request);
+ Set tagsKeysToRemove = TagHelper.generateTagsToRemove(previousTags, desiredTags);
+
+ if (!tagsKeysToRemove.isEmpty()) {
+ progress = untagResource(proxy, proxyClient, resourceModel, request, callbackContext, tagsKeysToRemove);
+ }
+ }
+ return progress;
+ }
+
+ private ProgressEvent untagResource(
+ final AmazonWebServicesClientProxy proxy,
+ final ProxyClient serviceClient,
+ final ResourceModel resourceModel,
+ final ResourceHandlerRequest request,
+ final CallbackContext callbackContext,
+ final Set removedTags) {
+ logger.log(String.format(
+ "[UPDATE][IN PROGRESS] Going to remove tags for web app: %s with AccountId: %s",
+ resourceModel.getWebAppId(), request.getAwsAccountId()));
+ final String clientRequestToken = request.getClientRequestToken();
+
+ return proxy.initiate("AWS-Transfer-Web-App::TagOps", serviceClient, resourceModel, callbackContext)
+ .translateToServiceRequest(model -> Translator.untagResourceRequest(model, removedTags))
+ .makeServiceCall((untagRequest, client) -> {
+ try (TransferClient transferClient = client.client()) {
+ return client.injectCredentialsAndInvokeV2(untagRequest, transferClient::untagResource);
+ }
+ })
+ .handleError((ignored, exception, proxyClient, model, context) -> {
+ if (isEnvironmentTaggingException(exception)) {
+ return ProgressEvent.failed(
+ model, context, HandlerErrorCode.UnauthorizedTaggingOperation, exception.getMessage());
+ }
+ return handleError(UPDATE, exception, model, context, clientRequestToken);
+ })
+ .progress();
+ }
+
+ private boolean isEnvironmentTaggingException(Exception e) {
+ return StringUtils.equals(ACCESS_DENIED_ERROR_CODE, getErrorCode(e));
+ }
+}
diff --git a/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/CallbackContext.java b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/CallbackContext.java
new file mode 100644
index 0000000..f68055b
--- /dev/null
+++ b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/CallbackContext.java
@@ -0,0 +1,9 @@
+package software.amazon.transfer.webapp;
+
+import software.amazon.cloudformation.proxy.StdCallbackContext;
+
+@lombok.Getter
+@lombok.Setter
+@lombok.ToString
+@lombok.EqualsAndHashCode(callSuper = true)
+public class CallbackContext extends StdCallbackContext {}
diff --git a/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/ClientBuilder.java b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/ClientBuilder.java
new file mode 100644
index 0000000..3d5c65b
--- /dev/null
+++ b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/ClientBuilder.java
@@ -0,0 +1,10 @@
+package software.amazon.transfer.webapp;
+
+import software.amazon.awssdk.services.transfer.TransferClient;
+import software.amazon.cloudformation.LambdaWrapper;
+
+public class ClientBuilder {
+ public static TransferClient getClient() {
+ return TransferClient.builder().httpClient(LambdaWrapper.HTTP_CLIENT).build();
+ }
+}
diff --git a/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/Configuration.java b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/Configuration.java
new file mode 100644
index 0000000..66fd479
--- /dev/null
+++ b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/Configuration.java
@@ -0,0 +1,8 @@
+package software.amazon.transfer.webapp;
+
+class Configuration extends BaseConfiguration {
+
+ public Configuration() {
+ super("aws-transfer-webapp.json");
+ }
+}
diff --git a/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/Converter.java b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/Converter.java
new file mode 100644
index 0000000..eb4b37e
--- /dev/null
+++ b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/Converter.java
@@ -0,0 +1,57 @@
+package software.amazon.transfer.webapp;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+
+import software.amazon.awssdk.services.transfer.model.Tag;
+
+public class Converter {
+ static class TagConverter {
+ static Tag toSdk(software.amazon.transfer.webapp.Tag tag) {
+ if (tag == null) {
+ return null;
+ }
+ return Tag.builder().key(tag.getKey()).value(tag.getValue()).build();
+ }
+
+ static List translateTagfromMap(Map tags) {
+ if (tags == null) {
+ return List.of();
+ }
+
+ return tags.entrySet().stream()
+ .map(tag -> software.amazon.transfer.webapp.Tag.builder()
+ .key(tag.getKey())
+ .value(tag.getValue())
+ .build())
+ .toList();
+ }
+
+ static software.amazon.transfer.webapp.Tag fromSdk(Tag tag) {
+ if (tag == null) {
+ return null;
+ }
+ return software.amazon.transfer.webapp.Tag.builder()
+ .key(tag.key())
+ .value(tag.value())
+ .build();
+ }
+ }
+
+ static class DateConverter {
+ static Instant toSdk(String date) {
+ if (date == null) {
+ return null;
+ }
+ return Instant.parse(date);
+ }
+
+ static String fromSdk(Instant date) {
+ if (date == null) {
+ return null;
+ }
+ return date.toString();
+ }
+ }
+}
diff --git a/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/CreateHandler.java b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/CreateHandler.java
new file mode 100644
index 0000000..980398b
--- /dev/null
+++ b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/CreateHandler.java
@@ -0,0 +1,163 @@
+package software.amazon.transfer.webapp;
+
+import static software.amazon.transfer.webapp.translators.TagHelper.setDesiredTags;
+
+import java.util.Base64;
+import java.util.stream.Collectors;
+
+import org.apache.commons.lang3.StringUtils;
+
+import software.amazon.awssdk.core.SdkBytes;
+import software.amazon.awssdk.services.transfer.TransferClient;
+import software.amazon.awssdk.services.transfer.model.CreateWebAppRequest;
+import software.amazon.awssdk.services.transfer.model.CreateWebAppResponse;
+import software.amazon.awssdk.services.transfer.model.IdentityCenterConfig;
+import software.amazon.awssdk.services.transfer.model.Tag;
+import software.amazon.awssdk.services.transfer.model.UpdateWebAppCustomizationRequest;
+import software.amazon.awssdk.services.transfer.model.UpdateWebAppCustomizationResponse;
+import software.amazon.awssdk.services.transfer.model.WebAppIdentityProviderDetails;
+import software.amazon.awssdk.services.transfer.model.WebAppUnits;
+import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy;
+import software.amazon.cloudformation.proxy.Logger;
+import software.amazon.cloudformation.proxy.ProgressEvent;
+import software.amazon.cloudformation.proxy.ProxyClient;
+import software.amazon.cloudformation.proxy.ResourceHandlerRequest;
+import software.amazon.transfer.webapp.translators.WebAppArn;
+
+import com.amazonaws.regions.Region;
+import com.amazonaws.regions.RegionUtils;
+
+public class CreateHandler extends BaseHandlerStd {
+
+ @Override
+ public ProgressEvent handleRequest(
+ final AmazonWebServicesClientProxy proxy,
+ final ResourceHandlerRequest request,
+ final CallbackContext callbackContext,
+ final ProxyClient proxyClient,
+ final Logger logger) {
+
+ this.logger = logger;
+
+ final String clientRequestToken = request.getClientRequestToken();
+ ResourceModel newModel = request.getDesiredResourceState();
+
+ setDesiredTags(request, newModel);
+
+ return ProgressEvent.progress(newModel, callbackContext)
+ .then(progress -> proxy.initiate(
+ "AwS-Transfer-Web-App::Create",
+ proxyClient,
+ progress.getResourceModel(),
+ progress.getCallbackContext())
+ .translateToServiceRequest(this::translateToCreateRequest)
+ .makeServiceCall(this::createWebApp)
+ .stabilize((ignored, response, client, model, context) ->
+ stabilizeAfterCreate(request, response, client, model, context))
+ .handleError((ignored, exception, client, model, context) ->
+ handleError(CREATE, exception, model, context, clientRequestToken))
+ .progress())
+ .then(progress -> {
+ // Only proceed with customization update if WebAppCustomization exists
+ if (progress.getResourceModel().getWebAppCustomization() != null) {
+ return proxy.initiate(
+ "AWS-Transfer-Web-App::UpdateCustomization",
+ proxyClient,
+ progress.getResourceModel(),
+ progress.getCallbackContext())
+ .translateToServiceRequest(this::translateToUpdateRequest)
+ .makeServiceCall(this::updateWebAppCustomization)
+ .handleError((ignored, exception, client, model, context) ->
+ handleError(UPDATE, exception, model, context, clientRequestToken))
+ .progress();
+ }
+ return progress;
+ })
+ .then(progress ->
+ new ReadHandler().handleRequest(proxy, request, callbackContext, proxyClient, logger));
+ }
+
+ private CreateWebAppRequest translateToCreateRequest(final ResourceModel model) {
+ return CreateWebAppRequest.builder()
+ .accessEndpoint(model.getAccessEndpoint())
+ .identityProviderDetails(WebAppIdentityProviderDetails.builder()
+ .identityCenterConfig(IdentityCenterConfig.builder()
+ .instanceArn(model.getIdentityProviderDetails().getInstanceArn())
+ .role(model.getIdentityProviderDetails().getRole())
+ .build())
+ .build())
+ .webAppUnits(WebAppUnits.builder()
+ .provisioned(
+ model.getWebAppUnits() != null
+ ? model.getWebAppUnits().getProvisioned()
+ : 1)
+ .build())
+ .tags(
+ model.getTags() == null
+ ? null
+ : model.getTags().stream()
+ .map(tag -> Tag.builder()
+ .key(tag.getKey())
+ .value(tag.getValue())
+ .build())
+ .collect(Collectors.toList()))
+ .build();
+ }
+
+ private CreateWebAppResponse createWebApp(CreateWebAppRequest awsRequest, ProxyClient client) {
+ try (TransferClient transferClient = client.client()) {
+ CreateWebAppResponse response =
+ client.injectCredentialsAndInvokeV2(awsRequest, transferClient::createWebApp);
+ log("web app created successfully", response.webAppId());
+ return response;
+ }
+ }
+
+ private boolean stabilizeAfterCreate(
+ ResourceHandlerRequest request,
+ CreateWebAppResponse awsResponse,
+ ProxyClient ignored1,
+ ResourceModel model,
+ CallbackContext ignored2) {
+ String webAppId = awsResponse.webAppId();
+ Region region = RegionUtils.getRegion(request.getRegion());
+ WebAppArn webAppArn = new WebAppArn(region, request.getAwsAccountId(), webAppId);
+ model.setArn(webAppArn.getArn());
+ model.setWebAppId(webAppId);
+
+ return true;
+ }
+
+ private UpdateWebAppCustomizationRequest translateToUpdateRequest(final ResourceModel model) {
+ if (StringUtils.isBlank(model.getWebAppId())) {
+ WebAppArn webappArn = WebAppArn.fromString(model.getArn());
+ model.setWebAppId(webappArn.getWebAppId());
+ }
+
+ return UpdateWebAppCustomizationRequest.builder()
+ .webAppId(model.getWebAppId())
+ .title(model.getWebAppCustomization().getTitle())
+ .logoFile(convertFileToSdkBytes(model.getWebAppCustomization().getLogoFile()))
+ .faviconFile(
+ convertFileToSdkBytes(model.getWebAppCustomization().getFaviconFile()))
+ .build();
+ }
+
+ private UpdateWebAppCustomizationResponse updateWebAppCustomization(
+ UpdateWebAppCustomizationRequest awsRequest, ProxyClient client) {
+ try (TransferClient transferClient = client.client()) {
+ UpdateWebAppCustomizationResponse response =
+ client.injectCredentialsAndInvokeV2(awsRequest, transferClient::updateWebAppCustomization);
+ log("web app customization updated successfully", response.webAppId());
+ return response;
+ }
+ }
+
+ private SdkBytes convertFileToSdkBytes(String fileData) {
+ if (fileData == null) {
+ return null;
+ }
+ // Decode Base64 string to byte array, then convert to SdkBytes
+ return SdkBytes.fromByteArray(Base64.getDecoder().decode(fileData));
+ }
+}
diff --git a/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/DeleteHandler.java b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/DeleteHandler.java
new file mode 100644
index 0000000..08c5e04
--- /dev/null
+++ b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/DeleteHandler.java
@@ -0,0 +1,52 @@
+package software.amazon.transfer.webapp;
+
+import software.amazon.awssdk.services.transfer.TransferClient;
+import software.amazon.awssdk.services.transfer.model.DeleteWebAppRequest;
+import software.amazon.awssdk.services.transfer.model.DeleteWebAppResponse;
+import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy;
+import software.amazon.cloudformation.proxy.Logger;
+import software.amazon.cloudformation.proxy.ProgressEvent;
+import software.amazon.cloudformation.proxy.ProxyClient;
+import software.amazon.cloudformation.proxy.ResourceHandlerRequest;
+import software.amazon.transfer.webapp.translators.Translator;
+
+public class DeleteHandler extends BaseHandlerStd {
+
+ protected ProgressEvent handleRequest(
+ final AmazonWebServicesClientProxy proxy,
+ final ResourceHandlerRequest request,
+ final CallbackContext callbackContext,
+ final ProxyClient proxyClient,
+ final Logger logger) {
+ this.logger = logger;
+
+ final String clientRequestToken = request.getClientRequestToken();
+ final ResourceModel newModel = request.getDesiredResourceState();
+ Translator.ensureWebAppIdInModel(newModel);
+
+ return ProgressEvent.progress(newModel, callbackContext)
+ .then(progress -> proxy.initiate(
+ "AWS-Transfer-Web-App::Delete",
+ proxyClient,
+ progress.getResourceModel(),
+ progress.getCallbackContext())
+ .translateToServiceRequest(DeleteHandler::translateToDeleteRequest)
+ .makeServiceCall(this::deleteWebApp)
+ .handleError((ignored, exception, client, model, context) ->
+ handleError(DELETE, exception, model, context, clientRequestToken))
+ .progress())
+ .then(progress -> ProgressEvent.defaultSuccessHandler(null));
+ }
+
+ private static DeleteWebAppRequest translateToDeleteRequest(final ResourceModel model) {
+ return DeleteWebAppRequest.builder().webAppId(model.getWebAppId()).build();
+ }
+
+ private DeleteWebAppResponse deleteWebApp(DeleteWebAppRequest request, ProxyClient client) {
+ try (TransferClient transferClient = client.client()) {
+ DeleteWebAppResponse response = client.injectCredentialsAndInvokeV2(request, transferClient::deleteWebApp);
+ log("successfully deleted.", request.webAppId());
+ return response;
+ }
+ }
+}
diff --git a/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/ListHandler.java b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/ListHandler.java
new file mode 100644
index 0000000..f05f316
--- /dev/null
+++ b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/ListHandler.java
@@ -0,0 +1,60 @@
+package software.amazon.transfer.webapp;
+
+import java.util.List;
+
+import software.amazon.awssdk.services.transfer.TransferClient;
+import software.amazon.awssdk.services.transfer.model.ListWebAppsRequest;
+import software.amazon.awssdk.services.transfer.model.ListWebAppsResponse;
+import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy;
+import software.amazon.cloudformation.proxy.Logger;
+import software.amazon.cloudformation.proxy.OperationStatus;
+import software.amazon.cloudformation.proxy.ProgressEvent;
+import software.amazon.cloudformation.proxy.ProxyClient;
+import software.amazon.cloudformation.proxy.ResourceHandlerRequest;
+import software.amazon.transfer.webapp.translators.Translator;
+
+public class ListHandler extends BaseHandlerStd {
+
+ @Override
+ protected ProgressEvent handleRequest(
+ final AmazonWebServicesClientProxy proxy,
+ final ResourceHandlerRequest request,
+ final CallbackContext callbackContext,
+ final ProxyClient proxyClient,
+ final Logger logger) {
+ this.logger = logger;
+
+ final String clientRequestToken = request.getClientRequestToken();
+ final ResourceModel resourceModel = request.getDesiredResourceState();
+
+ return proxy.initiate("AWS-Transfer-Web-App::List", proxyClient, resourceModel, callbackContext)
+ .translateToServiceRequest(m -> translateToListRequest(request.getNextToken()))
+ .makeServiceCall(this::listWebApps)
+ .handleError((ignored, exception, client, model, context) ->
+ handleError(LIST, exception, model, context, clientRequestToken))
+ .done(response -> ProgressEvent.builder()
+ .resourceModels(translateFromListResponce(response))
+ .nextToken(response.nextToken())
+ .status(OperationStatus.SUCCESS)
+ .build());
+ }
+
+ private ListWebAppsRequest translateToListRequest(final String nextToken) {
+ return ListWebAppsRequest.builder().maxResults(10).nextToken(nextToken).build();
+ }
+
+ private ListWebAppsResponse listWebApps(ListWebAppsRequest awsRequest, ProxyClient client) {
+ try (TransferClient transferClient = client.client()) {
+ return client.injectCredentialsAndInvokeV2(awsRequest, transferClient::listWebApps);
+ }
+ }
+
+ private List translateFromListResponce(final ListWebAppsResponse awsResponse) {
+ return Translator.streamOfOrEmpty(awsResponse.webApps())
+ .map(resource -> ResourceModel.builder()
+ .arn(resource.arn())
+ .webAppId(resource.webAppId())
+ .build())
+ .toList();
+ }
+}
diff --git a/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/MockableBaseHandler.java b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/MockableBaseHandler.java
new file mode 100644
index 0000000..32ae4b6
--- /dev/null
+++ b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/MockableBaseHandler.java
@@ -0,0 +1,24 @@
+package software.amazon.transfer.webapp;
+
+import software.amazon.awssdk.services.transfer.TransferClient;
+import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy;
+import software.amazon.cloudformation.proxy.Logger;
+import software.amazon.cloudformation.proxy.ProgressEvent;
+import software.amazon.cloudformation.proxy.ProxyClient;
+import software.amazon.cloudformation.proxy.ResourceHandlerRequest;
+
+/**
+ * Interface exposing the only handler method that should be used when
+ * testing Uluru handlers. This provides a mechanism to feed the call chain
+ * a mock client as needed without complex static mocking of the ClientBuilder.
+ *
+ * @param
+ */
+interface MockableBaseHandler {
+ ProgressEvent handleRequest(
+ final AmazonWebServicesClientProxy proxy,
+ final ResourceHandlerRequest request,
+ final CallbackT callbackContext,
+ final ProxyClient proxyClient,
+ final Logger logger);
+}
diff --git a/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/ReadHandler.java b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/ReadHandler.java
new file mode 100644
index 0000000..025ea56
--- /dev/null
+++ b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/ReadHandler.java
@@ -0,0 +1,131 @@
+package software.amazon.transfer.webapp;
+
+import static software.amazon.transfer.webapp.translators.Translator.translateFromSdkTags;
+
+import java.util.Base64;
+
+import software.amazon.awssdk.core.SdkBytes;
+import software.amazon.awssdk.services.transfer.TransferClient;
+import software.amazon.awssdk.services.transfer.model.DescribeWebAppCustomizationRequest;
+import software.amazon.awssdk.services.transfer.model.DescribeWebAppCustomizationResponse;
+import software.amazon.awssdk.services.transfer.model.DescribeWebAppRequest;
+import software.amazon.awssdk.services.transfer.model.DescribeWebAppResponse;
+import software.amazon.awssdk.services.transfer.model.DescribedWebApp;
+import software.amazon.awssdk.services.transfer.model.DescribedWebAppCustomization;
+import software.amazon.awssdk.services.transfer.model.ResourceNotFoundException;
+import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy;
+import software.amazon.cloudformation.proxy.Logger;
+import software.amazon.cloudformation.proxy.ProgressEvent;
+import software.amazon.cloudformation.proxy.ProxyClient;
+import software.amazon.cloudformation.proxy.ResourceHandlerRequest;
+import software.amazon.transfer.webapp.translators.Translator;
+
+public class ReadHandler extends BaseHandlerStd {
+
+ @Override
+ protected ProgressEvent handleRequest(
+ final AmazonWebServicesClientProxy proxy,
+ final ResourceHandlerRequest request,
+ final CallbackContext callbackContext,
+ final ProxyClient proxyClient,
+ final Logger logger) {
+ this.logger = logger;
+
+ final String clientRequestToken = request.getClientRequestToken();
+
+ final ResourceModel model = request.getDesiredResourceState();
+ Translator.ensureWebAppIdInModel(model);
+
+ return proxy.initiate("AWS-Transfer-Web-App::Read", proxyClient, model, callbackContext)
+ .translateToServiceRequest(this::translateToReadRequest)
+ .makeServiceCall(this::readWebApp)
+ .handleError((ignored, exception, client, resourceModel, context) ->
+ handleError(READ, exception, resourceModel, context, clientRequestToken))
+ .done(awsResponse -> proxy.initiate(
+ "AWS-Transfer-Web-App::ReadCustomization", proxyClient, model, callbackContext)
+ .translateToServiceRequest(this::translateToReadCustomizationRequest)
+ .makeServiceCall(this::readWebAppCustomization)
+ .handleError((ignored, exception, client, resourceModel, context) ->
+ handleError(READ, exception, resourceModel, context, clientRequestToken))
+ .done(customizationResponse -> ProgressEvent.defaultSuccessHandler(
+ translateFromReadResponse(awsResponse, customizationResponse))));
+ }
+
+ private ResourceModel translateFromReadResponse(
+ final DescribeWebAppResponse webAppResponse,
+ final DescribeWebAppCustomizationResponse customizationResponse) {
+
+ DescribedWebApp webApp = webAppResponse.webApp();
+ ResourceModel model = new ResourceModel();
+ model.setArn(webApp.arn());
+ model.setWebAppId(webApp.webAppId());
+
+ IdentityProviderDetails identityProviderDetails = new IdentityProviderDetails();
+ identityProviderDetails.setApplicationArn(
+ webApp.describedIdentityProviderDetails().identityCenterConfig().applicationArn());
+ identityProviderDetails.setInstanceArn(
+ webApp.describedIdentityProviderDetails().identityCenterConfig().instanceArn());
+ identityProviderDetails.setRole(
+ webApp.describedIdentityProviderDetails().identityCenterConfig().role());
+ model.setIdentityProviderDetails(identityProviderDetails);
+
+ model.setAccessEndpoint(webApp.accessEndpoint());
+
+ WebAppUnits webAppUnits = new WebAppUnits();
+ webAppUnits.setProvisioned(webApp.webAppUnits().provisioned());
+ model.setWebAppUnits(webAppUnits);
+
+ model.setTags(translateFromSdkTags(webApp.tags()));
+
+ // Only add customization if response exists
+ if (customizationResponse != null && customizationResponse.webAppCustomization() != null) {
+ DescribedWebAppCustomization webAppCustomization = customizationResponse.webAppCustomization();
+ WebAppCustomization customization = new WebAppCustomization();
+ customization.setTitle(webAppCustomization.title());
+ customization.setLogoFile(convertToBase64String(webAppCustomization.logoFile()));
+ customization.setFaviconFile(convertToBase64String(webAppCustomization.faviconFile()));
+ model.setWebAppCustomization(customization);
+ }
+
+ return model;
+ }
+
+ private DescribeWebAppRequest translateToReadRequest(final ResourceModel model) {
+ return DescribeWebAppRequest.builder().webAppId(model.getWebAppId()).build();
+ }
+
+ private DescribeWebAppCustomizationRequest translateToReadCustomizationRequest(final ResourceModel model) {
+ return DescribeWebAppCustomizationRequest.builder()
+ .webAppId(model.getWebAppId())
+ .build();
+ }
+
+ protected DescribeWebAppResponse readWebApp(DescribeWebAppRequest awsRequest, ProxyClient client) {
+ try (TransferClient transferClient = client.client()) {
+ DescribeWebAppResponse awsResponse =
+ client.injectCredentialsAndInvokeV2(awsRequest, transferClient::describeWebApp);
+ log("has been read successfully.", awsRequest.webAppId());
+ return awsResponse;
+ }
+ }
+
+ protected DescribeWebAppCustomizationResponse readWebAppCustomization(
+ DescribeWebAppCustomizationRequest awsRequest, ProxyClient client) {
+ try (TransferClient transferClient = client.client()) {
+ DescribeWebAppCustomizationResponse awsResponse =
+ client.injectCredentialsAndInvokeV2(awsRequest, transferClient::describeWebAppCustomization);
+ log("has been read successfully.", awsRequest.webAppId());
+ return awsResponse;
+ } catch (ResourceNotFoundException e) {
+ log("No web app customization to add", awsRequest.webAppId());
+ return null;
+ }
+ }
+
+ private String convertToBase64String(SdkBytes bytes) {
+ if (bytes == null) {
+ return null;
+ }
+ return Base64.getEncoder().encodeToString(bytes.asByteArray());
+ }
+}
diff --git a/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/UpdateHandler.java b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/UpdateHandler.java
new file mode 100644
index 0000000..9727305
--- /dev/null
+++ b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/UpdateHandler.java
@@ -0,0 +1,162 @@
+package software.amazon.transfer.webapp;
+
+import static software.amazon.transfer.webapp.translators.TagHelper.setDesiredTags;
+
+import java.util.Base64;
+
+import software.amazon.awssdk.core.SdkBytes;
+import software.amazon.awssdk.services.transfer.TransferClient;
+import software.amazon.awssdk.services.transfer.model.UpdateWebAppCustomizationRequest;
+import software.amazon.awssdk.services.transfer.model.UpdateWebAppCustomizationResponse;
+import software.amazon.awssdk.services.transfer.model.UpdateWebAppIdentityCenterConfig;
+import software.amazon.awssdk.services.transfer.model.UpdateWebAppIdentityProviderDetails;
+import software.amazon.awssdk.services.transfer.model.UpdateWebAppRequest;
+import software.amazon.awssdk.services.transfer.model.UpdateWebAppResponse;
+import software.amazon.awssdk.services.transfer.model.WebAppUnits;
+import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy;
+import software.amazon.cloudformation.proxy.Logger;
+import software.amazon.cloudformation.proxy.ProgressEvent;
+import software.amazon.cloudformation.proxy.ProxyClient;
+import software.amazon.cloudformation.proxy.ResourceHandlerRequest;
+import software.amazon.transfer.webapp.translators.Translator;
+
+public class UpdateHandler extends BaseHandlerStd {
+
+ @Override
+ protected ProgressEvent handleRequest(
+ final AmazonWebServicesClientProxy proxy,
+ final ResourceHandlerRequest request,
+ final CallbackContext callbackContext,
+ final ProxyClient proxyClient,
+ final Logger logger) {
+ this.logger = logger;
+
+ final String clientRequestToken = request.getClientRequestToken();
+ final ResourceModel previousModel = request.getPreviousResourceState();
+ final ResourceModel newModel = request.getDesiredResourceState();
+
+ if (newModel.getIdentityProviderDetails() == null) {
+ newModel.setIdentityProviderDetails(previousModel.getIdentityProviderDetails());
+ }
+ if (newModel.getAccessEndpoint() == null) {
+ newModel.setAccessEndpoint(previousModel.getAccessEndpoint());
+ }
+ // Preserve WebAppUnits
+ if (newModel.getWebAppUnits() == null) {
+ newModel.setWebAppUnits(previousModel.getWebAppUnits());
+ }
+ if (newModel.getWebAppCustomization() == null && previousModel.getWebAppCustomization() != null) {
+ newModel.setWebAppCustomization(previousModel.getWebAppCustomization());
+ }
+
+ Translator.ensureWebAppIdInModel(newModel);
+
+ setDesiredTags(request, newModel);
+
+ return ProgressEvent.progress(newModel, callbackContext)
+ .then(progress -> updateWebApp(proxy, proxyClient, clientRequestToken, progress))
+ .then(progress -> {
+ // Only update customization if it has exists in the model
+ if (progress.getResourceModel().getWebAppCustomization() != null) {
+ return updateWebAppCustomization(proxy, proxyClient, clientRequestToken, progress);
+ }
+ return progress;
+ })
+ .then(progress -> addTags(progress, request, newModel, proxy, proxyClient, callbackContext))
+ .then(progress -> removeTags(progress, request, newModel, proxy, proxyClient, callbackContext))
+ .then(progress ->
+ new ReadHandler().handleRequest(proxy, request, callbackContext, proxyClient, logger));
+ }
+
+ private ProgressEvent updateWebApp(
+ AmazonWebServicesClientProxy proxy,
+ ProxyClient proxyClient,
+ String clientRequestToken,
+ ProgressEvent progress) {
+ return proxy.initiate(
+ "AWS-Transfer-Web-App::Update",
+ proxyClient,
+ progress.getResourceModel(),
+ progress.getCallbackContext())
+ .translateToServiceRequest(this::translateToUpdateRequest)
+ .makeServiceCall(this::updateWebApp)
+ .handleError((ignored, exception, client, model, context) ->
+ handleError(UPDATE, exception, model, context, clientRequestToken))
+ .progress();
+ }
+
+ private UpdateWebAppResponse updateWebApp(UpdateWebAppRequest request, ProxyClient client) {
+ try (TransferClient transferClient = client.client()) {
+ UpdateWebAppResponse response = client.injectCredentialsAndInvokeV2(request, transferClient::updateWebApp);
+ log("has successfully been updated.", request.webAppId());
+ return response;
+ }
+ }
+
+ private UpdateWebAppRequest translateToUpdateRequest(final ResourceModel model) {
+ return UpdateWebAppRequest.builder()
+ .webAppId(model.getWebAppId())
+ .identityProviderDetails(UpdateWebAppIdentityProviderDetails.builder()
+ .identityCenterConfig(UpdateWebAppIdentityCenterConfig.builder()
+ .role(model.getIdentityProviderDetails().getRole())
+ .build())
+ .build())
+ .accessEndpoint(model.getAccessEndpoint())
+ .webAppUnits(WebAppUnits.builder()
+ .provisioned(model.getWebAppUnits().getProvisioned())
+ .build())
+ .build();
+ }
+
+ private ProgressEvent updateWebAppCustomization(
+ AmazonWebServicesClientProxy proxy,
+ ProxyClient proxyClient,
+ String clientRequestToken,
+ ProgressEvent progress) {
+ return proxy.initiate(
+ "AWS-Transfer-Web-App::UpdateCustomization",
+ proxyClient,
+ progress.getResourceModel(),
+ progress.getCallbackContext())
+ .translateToServiceRequest(this::translateToUpdateCustomizationRequest)
+ .makeServiceCall(this::updateWebAppCustomization)
+ .handleError((ignored, exception, client, model, context) ->
+ handleError(UPDATE, exception, model, context, clientRequestToken))
+ .progress();
+ }
+
+ private UpdateWebAppCustomizationRequest translateToUpdateCustomizationRequest(final ResourceModel model) {
+ return UpdateWebAppCustomizationRequest.builder()
+ .webAppId(model.getWebAppId())
+ .title(model.getWebAppCustomization().getTitle())
+ .logoFile(
+ model.getWebAppCustomization().getLogoFile() != null
+ ? convertFileToSdkBytes(
+ model.getWebAppCustomization().getLogoFile())
+ : null)
+ .faviconFile(
+ model.getWebAppCustomization().getFaviconFile() != null
+ ? convertFileToSdkBytes(
+ model.getWebAppCustomization().getFaviconFile())
+ : null)
+ .build();
+ }
+
+ private UpdateWebAppCustomizationResponse updateWebAppCustomization(
+ UpdateWebAppCustomizationRequest request, ProxyClient client) {
+ try (TransferClient transferClient = client.client()) {
+ UpdateWebAppCustomizationResponse response =
+ client.injectCredentialsAndInvokeV2(request, transferClient::updateWebAppCustomization);
+ log("customization has successfully been updated.", request.webAppId());
+ return response;
+ }
+ }
+
+ private SdkBytes convertFileToSdkBytes(String fileData) {
+ if (fileData == null) {
+ return null;
+ }
+ // Decode Base64 string to byte array, then convert to SdkBytes
+ return SdkBytes.fromByteArray(Base64.getDecoder().decode(fileData));
+ }
+}
diff --git a/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/translators/BaseArn.java b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/translators/BaseArn.java
new file mode 100644
index 0000000..8104ecd
--- /dev/null
+++ b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/translators/BaseArn.java
@@ -0,0 +1,59 @@
+package software.amazon.transfer.webapp.translators;
+
+import com.amazonaws.arn.Arn;
+import com.amazonaws.arn.ArnResource;
+import com.amazonaws.regions.Region;
+
+/** Transfer service base ARN class. */
+public abstract class BaseArn {
+ static final String RESOURCE_DELIMITER = "/";
+
+ /** Public SDK name of the service. */
+ static final String SERVICE_NAME = "transfer";
+
+ private final Region region;
+ private final String accountId;
+
+ BaseArn(Region region, String accountId) {
+ this.region = region;
+ this.accountId = accountId;
+ }
+
+ public abstract String getResourceType();
+
+ public abstract String getResourceId();
+
+ public String getVendor() {
+ return SERVICE_NAME;
+ }
+
+ public String getAccountId() {
+ return accountId;
+ }
+
+ public Region getRegion() {
+ return region;
+ }
+
+ public String getArn() {
+ String resource = ArnResource.builder()
+ .withResourceType(getResourceType())
+ .withResource(getResourceId())
+ .build()
+ .toString()
+ .replace(":", RESOURCE_DELIMITER);
+
+ return Arn.builder()
+ .withPartition(getRegion().getPartition())
+ .withService(SERVICE_NAME)
+ .withRegion(getRegion().getName())
+ .withAccountId(getAccountId())
+ .withResource(resource)
+ .build()
+ .toString();
+ }
+
+ protected static String[] getRelativeIdParts(Arn arn) {
+ return arn.getResourceAsString().split(RESOURCE_DELIMITER, -1);
+ }
+}
diff --git a/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/translators/TagHelper.java b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/translators/TagHelper.java
new file mode 100644
index 0000000..8be7749
--- /dev/null
+++ b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/translators/TagHelper.java
@@ -0,0 +1,129 @@
+package software.amazon.transfer.webapp.translators;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import org.apache.commons.lang3.ObjectUtils;
+
+import software.amazon.cloudformation.proxy.ResourceHandlerRequest;
+import software.amazon.transfer.webapp.ResourceModel;
+
+public final class TagHelper {
+ private TagHelper() {}
+
+ /**
+ * shouldUpdateTags
+ *
+ * Determines whether web app defined tags have been changed during update.
+ */
+ public static boolean shouldUpdateTags(final ResourceHandlerRequest handlerRequest) {
+ final Map previousTags = getPreviouslyAttachedTags(handlerRequest);
+ final Map desiredTags = getNewDesiredTags(handlerRequest);
+ return ObjectUtils.notEqual(previousTags, desiredTags);
+ }
+
+ /**
+ * getPreviouslyAttachedTags
+ *
+ * If stack tags and resource tags are not merged together in Configuration class, we will
+ * get previously attached system (with `aws:cloudformation` prefix) and web app defined tags from
+ * handlerRequest.getPreviousSystemTags() (system tags),
+ * handlerRequest.getPreviousResourceTags() (stack tags),
+ * handlerRequest.getPreviousResourceState().getTags() (resource tags).
+ *
+ *
System tags are an optional feature. Merge them to your tags if you have enabled them for
+ * your resource. System tags can change on resource update if the resource is imported to the
+ * stack.
+ */
+ public static Map getPreviouslyAttachedTags(
+ final ResourceHandlerRequest handlerRequest) {
+ final Map previousTags = new HashMap<>();
+
+ if (handlerRequest.getPreviousSystemTags() != null) {
+ previousTags.putAll(handlerRequest.getPreviousSystemTags());
+ }
+
+ if (handlerRequest.getPreviousResourceTags() != null) {
+ previousTags.putAll(handlerRequest.getPreviousResourceTags());
+ }
+
+ ResourceModel model = handlerRequest.getPreviousResourceState();
+ if (model != null && model.getTags() != null) {
+ model.getTags().forEach(tag -> {
+ previousTags.put(tag.getKey(), tag.getValue());
+ });
+ }
+ return previousTags;
+ }
+
+ /**
+ * getNewDesiredTags
+ *
+ * If stack tags and resource tags are not merged together in Configuration class, we will
+ * get new desired system (with `aws:cloudformation` prefix) and web app defined tags from
+ * handlerRequest.getSystemTags() (system tags), handlerRequest.getDesiredResourceTags() (stack
+ * tags), handlerRequest.getDesiredResourceState().getTags() (resource tags).
+ *
+ *
System tags are an optional feature. Merge them to your tags if you have enabled them for
+ * your resource. System tags can change on resource update if the resource is imported to the
+ * stack.
+ */
+ public static Map getNewDesiredTags(final ResourceHandlerRequest handlerRequest) {
+ final Map desiredTags = new HashMap<>();
+
+ if (handlerRequest.getSystemTags() != null) {
+ desiredTags.putAll(handlerRequest.getSystemTags());
+ }
+
+ // get desired stack level tags from handlerRequest
+ if (handlerRequest.getDesiredResourceTags() != null) {
+ desiredTags.putAll(handlerRequest.getDesiredResourceTags());
+ }
+
+ if (handlerRequest.getDesiredResourceState().getTags() != null) {
+ handlerRequest.getDesiredResourceState().getTags().forEach(tag -> {
+ desiredTags.put(tag.getKey(), tag.getValue());
+ });
+ }
+ return desiredTags;
+ }
+
+ /**
+ * generateTagsToAdd
+ *
+ * Determines the tags the customer desired to define or redefine.
+ */
+ public static Map generateTagsToAdd(
+ final Map previousTags, final Map desiredTags) {
+ return desiredTags.entrySet().stream()
+ .filter(e -> !previousTags.containsKey(e.getKey())
+ || !Objects.equals(previousTags.get(e.getKey()), e.getValue()))
+ .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
+ }
+
+ /**
+ * getTagsToRemove
+ *
+ * Determines the tags the customer desired to remove from the function.
+ */
+ public static Set generateTagsToRemove(
+ final Map previousTags, final Map desiredTags) {
+ final Set desiredTagNames = desiredTags.keySet();
+
+ return previousTags.keySet().stream()
+ .filter(tagName -> !desiredTagNames.contains(tagName))
+ .collect(Collectors.toSet());
+ }
+
+ public static void setDesiredTags(
+ ResourceHandlerRequest request, ResourceModel desiredResourceModel) {
+
+ Map tagsMap = getNewDesiredTags(request);
+ if (!tagsMap.isEmpty()) {
+ desiredResourceModel.setTags(Translator.translateTagMapToTagList(tagsMap));
+ }
+ }
+}
diff --git a/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/translators/Translator.java b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/translators/Translator.java
new file mode 100644
index 0000000..ee85f5a
--- /dev/null
+++ b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/translators/Translator.java
@@ -0,0 +1,144 @@
+package software.amazon.transfer.webapp.translators;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.apache.commons.lang3.StringUtils;
+
+import software.amazon.awssdk.services.transfer.model.DescribeWebAppRequest;
+import software.amazon.awssdk.services.transfer.model.TagResourceRequest;
+import software.amazon.awssdk.services.transfer.model.UntagResourceRequest;
+import software.amazon.transfer.webapp.ResourceModel;
+import software.amazon.transfer.webapp.Tag;
+
+/**
+ * This class is a centralized placeholder for - api request construction - object translation
+ * to/from aws sdk - resource model construction for read/list handlers
+ */
+public final class Translator {
+ private Translator() {}
+
+ public static List translateToSdkTags(List tags) {
+ return streamOfOrEmpty(tags)
+ .map(tag -> software.amazon.awssdk.services.transfer.model.Tag.builder()
+ .key(tag.getKey())
+ .value(tag.getValue())
+ .build())
+ .toList();
+ }
+
+ public static List translateToSdkTags(
+ Map tags) {
+ if (tags == null) {
+ return null;
+ }
+ return tags.entrySet().stream()
+ .map(tag -> software.amazon.awssdk.services.transfer.model.Tag.builder()
+ .key(tag.getKey())
+ .value(tag.getValue())
+ .build())
+ .toList();
+ }
+
+ /**
+ * Request to read a resource
+ *
+ * @param model resource model
+ * @return awsRequest the aws service request to describe a resource
+ */
+ public static DescribeWebAppRequest translateToReadRequest(final ResourceModel model) {
+ return DescribeWebAppRequest.builder().webAppId(model.getWebAppId()).build();
+ }
+
+ public static List translateFromSdkTags(List tags) {
+ if (tags == null) {
+ return null;
+ }
+ return tags.stream()
+ .map(tag -> Tag.builder().key(tag.key()).value(tag.value()).build())
+ .toList();
+ }
+
+ public static List translateTagMapToTagList(Map tagMap) {
+ if (tagMap == null) {
+ return null;
+ }
+ return tagMap.entrySet().stream()
+ .map(entry -> Tag.builder()
+ .key(entry.getKey())
+ .value(entry.getValue())
+ .build())
+ .toList();
+ }
+
+ public static Map translateTagListToTagMap(List tagList) {
+ if (tagList == null) {
+ return null;
+ }
+ return tagList.stream().collect(Collectors.toMap(Tag::getKey, Tag::getValue));
+ }
+
+ public static Stream streamOfOrEmpty(final Collection collection) {
+ if (collection == null) {
+ return Stream.empty();
+ }
+ return collection.stream();
+ }
+
+ public static String emptyStringIfNull(String nullableString) {
+ if (nullableString == null) {
+ return "";
+ }
+ return nullableString;
+ }
+
+ public static List nullIfEmptyList(final List list) {
+ if (list == null || list.isEmpty()) {
+ return null;
+ }
+ return list;
+ }
+
+ public static List emptyListIfNull(final List list) {
+ if (list == null) {
+ return List.of();
+ }
+ return list;
+ }
+
+ /**
+ * Request to add tags to a resource
+ *
+ * @param model resource model
+ * @return awsRequest the aws service request to create a resource
+ */
+ public static TagResourceRequest tagResourceRequest(
+ final ResourceModel model, final Map addedTags) {
+ List tagsToAdd = translateToSdkTags(addedTags);
+ return TagResourceRequest.builder().arn(model.getArn()).tags(tagsToAdd).build();
+ }
+
+ /**
+ * Request to add tags to a resource
+ *
+ * @param model resource model
+ * @return awsRequest the aws service request to create a resource
+ */
+ public static UntagResourceRequest untagResourceRequest(final ResourceModel model, final Set removedTags) {
+ return UntagResourceRequest.builder()
+ .arn(model.getArn())
+ .tagKeys(removedTags)
+ .build();
+ }
+
+ public static void ensureWebAppIdInModel(ResourceModel model) {
+ if (StringUtils.isBlank(model.getWebAppId())) {
+ WebAppArn webAppArn = WebAppArn.fromString(model.getArn());
+ model.setWebAppId(webAppArn.getWebAppId());
+ }
+ }
+}
diff --git a/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/translators/WebAppArn.java b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/translators/WebAppArn.java
new file mode 100644
index 0000000..8dfd7eb
--- /dev/null
+++ b/aws-transfer-webapp/src/main/java/software/amazon/transfer/webapp/translators/WebAppArn.java
@@ -0,0 +1,46 @@
+package software.amazon.transfer.webapp.translators;
+
+import com.amazonaws.arn.Arn;
+import com.amazonaws.regions.Region;
+import com.amazonaws.regions.RegionUtils;
+
+/** ARN representing a web app. */
+public class WebAppArn extends BaseArn {
+ /** Number of parts delimited by {@link #RESOURCE_DELIMITER} in relativeId of ARN. */
+ private static final int RELATIVE_ID_PARTS = 2;
+ /** Web app resource type. */
+ static final String WEB_APP = "webapp";
+
+ private final String webAppId;
+
+ public WebAppArn(Region region, String accountId, String webAppId) {
+ super(region, accountId);
+ this.webAppId = webAppId;
+ }
+
+ public String getResourceType() {
+ return WEB_APP;
+ }
+
+ public String getResourceId() {
+ return webAppId;
+ }
+
+ public String getWebAppId() {
+ return webAppId;
+ }
+
+ public static WebAppArn fromString(String arn) {
+ return fromArn(Arn.fromString(arn));
+ }
+
+ public static WebAppArn fromArn(Arn arn) {
+ String[] relativeIdParts = getRelativeIdParts(arn);
+ if (relativeIdParts.length != RELATIVE_ID_PARTS || !relativeIdParts[0].equals(WEB_APP)) {
+ throw new IllegalArgumentException("Invalid Web App ARN: " + arn);
+ }
+
+ Region r = RegionUtils.getRegion(arn.getRegion());
+ return new WebAppArn(r, arn.getAccountId(), relativeIdParts[1]);
+ }
+}
diff --git a/aws-transfer-webapp/src/resources/log4j2.xml b/aws-transfer-webapp/src/resources/log4j2.xml
new file mode 100644
index 0000000..5657daf
--- /dev/null
+++ b/aws-transfer-webapp/src/resources/log4j2.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/AbstractTestBase.java b/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/AbstractTestBase.java
new file mode 100644
index 0000000..c030291
--- /dev/null
+++ b/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/AbstractTestBase.java
@@ -0,0 +1,271 @@
+package software.amazon.transfer.webapp;
+
+import static org.mockito.Mockito.mock;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.time.Duration;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.function.Function;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.mockito.Mock;
+
+import software.amazon.awssdk.awscore.AwsRequest;
+import software.amazon.awssdk.awscore.AwsResponse;
+import software.amazon.awssdk.core.ResponseBytes;
+import software.amazon.awssdk.core.ResponseInputStream;
+import software.amazon.awssdk.core.SdkBytes;
+import software.amazon.awssdk.core.pagination.sync.SdkIterable;
+import software.amazon.awssdk.services.transfer.TransferClient;
+import software.amazon.awssdk.services.transfer.model.DescribeWebAppCustomizationResponse;
+import software.amazon.awssdk.services.transfer.model.DescribeWebAppResponse;
+import software.amazon.awssdk.services.transfer.model.DescribedWebApp;
+import software.amazon.awssdk.services.transfer.model.DescribedWebAppCustomization;
+import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy;
+import software.amazon.cloudformation.proxy.Credentials;
+import software.amazon.cloudformation.proxy.LoggerProxy;
+import software.amazon.cloudformation.proxy.ProxyClient;
+import software.amazon.cloudformation.proxy.ResourceHandlerRequest;
+import software.amazon.transfer.webapp.translators.Translator;
+
+public abstract class AbstractTestBase {
+ public static final String TEST_APPLICATION_ARN =
+ "arn:aws:sso::123456789012:application/ins-abcd1234efgh5678/apl-1234567890abcdef";
+ public static final String TEST_INSTANCE_ARN = "arn:aws:sso:::instance/ins-abcd1234efgh5678";
+ public static final String TEST_ROLE = "arn:aws:iam::123456789012:role/test-role";
+ public static final String TEST_CUSTOMIZATION_TITLE = "Test Web App Customization Title";
+ public static final String TEST_CUSTOMIZATION_LOGO_FILE = loadTestFile("logo.png");
+ public static final String TEST_CUSTOMIZATION_FAVICON_FILE = loadTestFile("favicon.png");
+ public static final Integer TEST_PROVISIONED = 3;
+ public static final String TEST_ARN = "arn:aws:transfer:us-east-1:123456789012:webapp/webapp-12345678901234567";
+ public static final String TEST_WEB_APP_ID = "webapp-12345678901234567";
+ public static final String TEST_ACCESS_ENDPOINT = "https://x7k9pq4mnt5wy.cloudfront.net";
+ protected static final Map RESOURCE_TAG_MAP = Collections.singletonMap("key", "value");
+ protected static final Map TEST_TAG_MAP = ImmutableMap.of("key2", "value2");
+ protected static final Map SYSTEM_TAG_MAP =
+ Collections.singletonMap("aws:cloudformation:stack-name", "StackName");
+ protected static final List MODEL_TAGS =
+ ImmutableList.of(Tag.builder().key("key").value("value").build());
+ protected static final Map EXTRA_MODEL_TAGS = ImmutableMap.of("extrakey", "extravalue");
+ protected static final software.amazon.awssdk.services.transfer.model.Tag SDK_MODEL_TAG =
+ software.amazon.awssdk.services.transfer.model.Tag.builder()
+ .key("key")
+ .value("value")
+ .build();
+ protected static final software.amazon.awssdk.services.transfer.model.Tag SDK_SYSTEM_TAG =
+ software.amazon.awssdk.services.transfer.model.Tag.builder()
+ .key("aws:cloudformation:stack-name")
+ .value("StackName")
+ .build();
+
+ public static IdentityProviderDetails getIdentityProviderDetails() {
+ return IdentityProviderDetails.builder()
+ .applicationArn(TEST_APPLICATION_ARN)
+ .instanceArn(TEST_INSTANCE_ARN)
+ .role(TEST_ROLE)
+ .build();
+ }
+
+ public static WebAppUnits getWebAppUnits() {
+ return WebAppUnits.builder().provisioned(TEST_PROVISIONED).build();
+ }
+
+ public static WebAppCustomization getWebAppCustomization() {
+ return WebAppCustomization.builder()
+ .title(TEST_CUSTOMIZATION_TITLE)
+ .logoFile(TEST_CUSTOMIZATION_LOGO_FILE)
+ .faviconFile(TEST_CUSTOMIZATION_FAVICON_FILE)
+ .build();
+ }
+
+ protected static final Credentials MOCK_CREDENTIALS;
+ protected static final LoggerProxy logger;
+
+ static {
+ MOCK_CREDENTIALS = new Credentials("accessKey", "secretKey", "token");
+ logger = new LoggerProxy();
+ }
+
+ protected AmazonWebServicesClientProxy proxy;
+
+ protected ProxyClient proxyClient;
+
+ @Mock
+ protected TransferClient client;
+
+ @BeforeEach
+ public void setup() {
+ proxy = new AmazonWebServicesClientProxy(
+ logger, MOCK_CREDENTIALS, () -> Duration.ofSeconds(600).toMillis());
+ client = mock(TransferClient.class);
+ proxyClient = MOCK_PROXY(proxy, client);
+ }
+
+ static ProxyClient MOCK_PROXY(final AmazonWebServicesClientProxy proxy, final T sdkClient) {
+ return new ProxyClient() {
+ @Override
+ public ResponseT injectCredentialsAndInvokeV2(
+ RequestT request, Function requestFunction) {
+ return proxy.injectCredentialsAndInvokeV2(request, requestFunction);
+ }
+
+ @Override
+ public
+ CompletableFuture injectCredentialsAndInvokeV2Async(
+ RequestT request, Function> requestFunction) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public <
+ RequestT extends AwsRequest,
+ ResponseT extends AwsResponse,
+ IterableT extends SdkIterable>
+ IterableT injectCredentialsAndInvokeIterableV2(
+ RequestT request, Function requestFunction) {
+ return proxy.injectCredentialsAndInvokeIterableV2(request, requestFunction);
+ }
+
+ @Override
+ public
+ ResponseInputStream injectCredentialsAndInvokeV2InputStream(
+ RequestT requestT, Function> function) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public
+ ResponseBytes injectCredentialsAndInvokeV2Bytes(
+ RequestT requestT, Function> function) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public T client() {
+ return sdkClient;
+ }
+ };
+ }
+
+ protected static ResourceHandlerRequest.ResourceHandlerRequestBuilder requestBuilder() {
+ return ResourceHandlerRequest.builder()
+ .awsAccountId("123456789012")
+ .awsPartition("aws")
+ .region("us-east-1");
+ }
+
+ protected ResourceModel simpleWebAppModel() {
+ return ResourceModel.builder()
+ .webAppId(TEST_WEB_APP_ID)
+ .identityProviderDetails(getIdentityProviderDetails())
+ .webAppUnits(WebAppUnits.builder().provisioned(1).build())
+ .tags(Collections.emptyList())
+ .build();
+ }
+
+ protected ResourceModel fullyLoadedWebAppModel() {
+ return ResourceModel.builder()
+ .identityProviderDetails(getIdentityProviderDetails())
+ .accessEndpoint(TEST_ACCESS_ENDPOINT)
+ .webAppUnits(getWebAppUnits())
+ .webAppCustomization(getWebAppCustomization())
+ .tags(MODEL_TAGS)
+ .build();
+ }
+
+ protected DescribeWebAppResponse describeWebAppResponseFromModel(ResourceModel model) {
+ software.amazon.awssdk.services.transfer.model.WebAppUnits webAppUnits =
+ software.amazon.awssdk.services.transfer.model.WebAppUnits.builder()
+ .provisioned(
+ model.getWebAppUnits() != null
+ ? model.getWebAppUnits().getProvisioned()
+ : 1) // Default to 1 if not provided
+ .build();
+
+ software.amazon.transfer.webapp.WebAppUnits modelWebAppUnits =
+ software.amazon.transfer.webapp.WebAppUnits.builder()
+ .provisioned(webAppUnits.provisioned())
+ .build();
+ model.setWebAppUnits(modelWebAppUnits);
+
+ IdentityProviderDetails currentModel = model.getIdentityProviderDetails();
+ IdentityProviderDetails identityProviderDetails = IdentityProviderDetails.builder()
+ .applicationArn(TEST_APPLICATION_ARN)
+ .instanceArn(currentModel.getInstanceArn())
+ .role(currentModel.getRole())
+ .build();
+ model.setIdentityProviderDetails(identityProviderDetails);
+
+ return DescribeWebAppResponse.builder()
+ .webApp(
+ DescribedWebApp.builder()
+ .webAppId(model.getWebAppId())
+ .accessEndpoint(model.getAccessEndpoint())
+ .arn(model.getArn())
+ .describedIdentityProviderDetails(
+ software.amazon.awssdk.services.transfer.model
+ .DescribedWebAppIdentityProviderDetails.builder()
+ .identityCenterConfig(software.amazon.awssdk.services.transfer.model
+ .DescribedIdentityCenterConfig.builder()
+ .applicationArn(model.getIdentityProviderDetails()
+ .getApplicationArn())
+ .instanceArn(model.getIdentityProviderDetails()
+ .getInstanceArn())
+ .role(model.getIdentityProviderDetails()
+ .getRole())
+ .build())
+ .build())
+ .webAppUnits(webAppUnits)
+ .tags(Translator.translateToSdkTags(model.getTags()))
+ .build())
+ .build();
+ }
+
+ protected DescribeWebAppCustomizationResponse describeWebAppCustomizationResponseFromModel(ResourceModel model) {
+ return DescribeWebAppCustomizationResponse.builder()
+ .webAppCustomization(DescribedWebAppCustomization.builder()
+ .title(model.getWebAppCustomization().getTitle())
+ .logoFile(convertFromBase64String(
+ model.getWebAppCustomization().getLogoFile()))
+ .faviconFile(convertFromBase64String(
+ model.getWebAppCustomization().getFaviconFile()))
+ .build())
+ .build();
+ }
+
+ protected static String loadTestFile(String resourcePath) {
+ try {
+ // Get file from resources folder
+ ClassLoader classLoader = AbstractTestBase.class.getClassLoader();
+ InputStream inputStream = classLoader.getResourceAsStream(resourcePath);
+
+ if (inputStream == null) {
+ throw new IllegalArgumentException("File not found: " + resourcePath);
+ }
+
+ // Read the file bytes
+ byte[] fileBytes = inputStream.readAllBytes();
+ inputStream.close();
+
+ // Convert to Base64
+ return Base64.getEncoder().encodeToString(fileBytes);
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to load test file: " + resourcePath, e);
+ }
+ }
+
+ private SdkBytes convertFromBase64String(String base64String) {
+ if (base64String == null) {
+ return null;
+ }
+ return SdkBytes.fromByteArray(Base64.getDecoder().decode(base64String));
+ }
+}
diff --git a/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/BaseHandlerStdTest.java b/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/BaseHandlerStdTest.java
new file mode 100644
index 0000000..ba301f1
--- /dev/null
+++ b/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/BaseHandlerStdTest.java
@@ -0,0 +1,56 @@
+package software.amazon.transfer.webapp;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import software.amazon.awssdk.services.transfer.TransferClient;
+import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy;
+import software.amazon.cloudformation.proxy.Logger;
+import software.amazon.cloudformation.proxy.ProgressEvent;
+import software.amazon.cloudformation.proxy.ProxyClient;
+import software.amazon.cloudformation.proxy.ResourceHandlerRequest;
+
+@ExtendWith(MockitoExtension.class)
+public class BaseHandlerStdTest {
+ @Mock
+ private AmazonWebServicesClientProxy proxy;
+
+ static class MockTestHandler extends BaseHandlerStd {
+ @Override
+ public ProgressEvent handleRequest(
+ AmazonWebServicesClientProxy proxy,
+ ResourceHandlerRequest request,
+ CallbackContext callbackContext,
+ ProxyClient proxyClient,
+ Logger logger) {
+ assertNotNull(callbackContext);
+ return null;
+ }
+ }
+
+ @Test
+ void justToCover3LinesOfCode() {
+ var uut = new MockTestHandler();
+
+ // Check with null CallbackContext
+ uut.handleRequest(proxy, null, null, null);
+ verify(proxy, times(1)).newProxy(any());
+ verifyNoMoreInteractions(proxy);
+ reset(proxy);
+
+ // Check with non-null CallbackContext
+ uut.handleRequest(proxy, null, new CallbackContext(), null);
+ verify(proxy, times(1)).newProxy(any());
+ verifyNoMoreInteractions(proxy);
+ reset(proxy);
+ }
+}
diff --git a/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/CreateHandlerTest.java b/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/CreateHandlerTest.java
new file mode 100644
index 0000000..7efaa80
--- /dev/null
+++ b/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/CreateHandlerTest.java
@@ -0,0 +1,141 @@
+package software.amazon.transfer.webapp;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.verify;
+
+import org.assertj.core.api.SoftAssertions;
+import org.assertj.core.api.junit.jupiter.InjectSoftAssertions;
+import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import software.amazon.awssdk.services.transfer.model.CreateWebAppRequest;
+import software.amazon.awssdk.services.transfer.model.CreateWebAppResponse;
+import software.amazon.awssdk.services.transfer.model.DescribeWebAppCustomizationRequest;
+import software.amazon.awssdk.services.transfer.model.DescribeWebAppRequest;
+import software.amazon.awssdk.services.transfer.model.DescribeWebAppResponse;
+import software.amazon.awssdk.services.transfer.model.InvalidRequestException;
+import software.amazon.awssdk.services.transfer.model.ThrottlingException;
+import software.amazon.awssdk.services.transfer.model.UpdateWebAppCustomizationRequest;
+import software.amazon.awssdk.services.transfer.model.UpdateWebAppCustomizationResponse;
+import software.amazon.cloudformation.proxy.OperationStatus;
+import software.amazon.cloudformation.proxy.ProgressEvent;
+import software.amazon.cloudformation.proxy.ResourceHandlerRequest;
+
+@ExtendWith(MockitoExtension.class)
+@ExtendWith(SoftAssertionsExtension.class)
+public class CreateHandlerTest extends AbstractTestBase {
+
+ @InjectSoftAssertions
+ private SoftAssertions softly;
+
+ private final CreateHandler handler = new CreateHandler();
+
+ @Test
+ public void handleRequest_SimpleSuccess() {
+ final ResourceModel model = simpleWebAppModel();
+ model.setWebAppId(TEST_WEB_APP_ID);
+ model.setArn(TEST_ARN);
+ model.setAccessEndpoint(TEST_ACCESS_ENDPOINT);
+
+ final ResourceHandlerRequest request =
+ requestBuilder().desiredResourceState(model).build();
+
+ setupCreateWebAppResponse();
+
+ DescribeWebAppResponse describeResponse = describeWebAppResponseFromModel(model);
+ doReturn(describeResponse).when(client).describeWebApp(any(DescribeWebAppRequest.class));
+
+ ProgressEvent response =
+ handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger);
+
+ assertThat(response).isNotNull();
+ softly.assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS);
+ softly.assertThat(response.getCallbackDelaySeconds()).isEqualTo(0);
+ softly.assertThat(response.getResourceModel()).isEqualTo(model);
+ softly.assertThat(response.getResourceModels()).isNull();
+ softly.assertThat(response.getMessage()).isNull();
+ softly.assertThat(response.getErrorCode()).isNull();
+
+ verify(client, atLeastOnce()).createWebApp(any(CreateWebAppRequest.class));
+ verify(client, atLeastOnce()).describeWebApp(any(DescribeWebAppRequest.class));
+ }
+
+ @Test
+ public void handleRequest_LoadedSuccess() {
+ final ResourceModel model = fullyLoadedWebAppModel();
+ model.setWebAppId(TEST_WEB_APP_ID);
+ model.setArn(TEST_ARN);
+
+ final ResourceHandlerRequest request =
+ requestBuilder().desiredResourceState(model).build();
+
+ setupCreateWebAppResponse();
+
+ UpdateWebAppCustomizationResponse updateResponse = UpdateWebAppCustomizationResponse.builder()
+ .webAppId(TEST_WEB_APP_ID)
+ .build();
+ doReturn(updateResponse).when(client).updateWebAppCustomization(any(UpdateWebAppCustomizationRequest.class));
+
+ DescribeWebAppResponse describeResponse = describeWebAppResponseFromModel(model);
+
+ doReturn(describeResponse).when(client).describeWebApp(any(DescribeWebAppRequest.class));
+
+ doReturn(describeWebAppCustomizationResponseFromModel(model))
+ .when(client)
+ .describeWebAppCustomization(any(DescribeWebAppCustomizationRequest.class));
+
+ ProgressEvent response =
+ handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger);
+
+ assertThat(response).isNotNull();
+ softly.assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS);
+ softly.assertThat(response.getCallbackDelaySeconds()).isEqualTo(0);
+ softly.assertThat(response.getResourceModel()).isEqualTo(model);
+ softly.assertThat(response.getResourceModels()).isNull();
+ softly.assertThat(response.getMessage()).isNull();
+ softly.assertThat(response.getErrorCode()).isNull();
+
+ verify(client, atLeastOnce()).createWebApp(any(CreateWebAppRequest.class));
+ verify(client, atLeastOnce()).updateWebAppCustomization(any(UpdateWebAppCustomizationRequest.class));
+ verify(client, atLeastOnce()).describeWebApp(any(DescribeWebAppRequest.class));
+ verify(client, atLeastOnce()).describeWebAppCustomization(any(DescribeWebAppCustomizationRequest.class));
+ }
+
+ @Test
+ public void errorPathsTest() {
+ final ResourceModel model = simpleWebAppModel();
+
+ final ResourceHandlerRequest request =
+ requestBuilder().desiredResourceState(model).build();
+
+ setupCreateWebAppResponse();
+
+ Exception ex1 = ThrottlingException.builder().build();
+ Exception ex2 = InvalidRequestException.builder().build();
+
+ doThrow(ex1).doThrow(ex2).when(client).describeWebApp(any(DescribeWebAppRequest.class));
+
+ ProgressEvent response =
+ handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger);
+
+ assertThat(response).isNotNull();
+ softly.assertThat(response.getStatus()).isEqualTo(OperationStatus.FAILED);
+
+ response = handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger);
+
+ assertThat(response).isNotNull();
+ softly.assertThat(response.getStatus()).isEqualTo(OperationStatus.FAILED);
+ }
+
+ private void setupCreateWebAppResponse() {
+ CreateWebAppResponse createWebAppResponse =
+ CreateWebAppResponse.builder().webAppId(TEST_WEB_APP_ID).build();
+ doReturn(createWebAppResponse).when(client).createWebApp(any(CreateWebAppRequest.class));
+ }
+}
diff --git a/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/DeleteHandlerTest.java b/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/DeleteHandlerTest.java
new file mode 100644
index 0000000..6aa92b3
--- /dev/null
+++ b/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/DeleteHandlerTest.java
@@ -0,0 +1,49 @@
+package software.amazon.transfer.webapp;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.verify;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import software.amazon.awssdk.services.transfer.model.DeleteWebAppRequest;
+import software.amazon.awssdk.services.transfer.model.DeleteWebAppResponse;
+import software.amazon.cloudformation.proxy.OperationStatus;
+import software.amazon.cloudformation.proxy.ProgressEvent;
+import software.amazon.cloudformation.proxy.ResourceHandlerRequest;
+
+@ExtendWith(MockitoExtension.class)
+public class DeleteHandlerTest extends AbstractTestBase {
+
+ @Test
+ public void handleRequest_SimpleSuccess() {
+ final DeleteHandler handler = new DeleteHandler();
+
+ final ResourceModel model = ResourceModel.builder().arn(TEST_ARN).build();
+
+ final ResourceHandlerRequest request =
+ requestBuilder().desiredResourceState(model).build();
+
+ DeleteWebAppResponse deleteWebAppResponse =
+ DeleteWebAppResponse.builder().build();
+
+ doReturn(deleteWebAppResponse).when(client).deleteWebApp(any(DeleteWebAppRequest.class));
+
+ final ProgressEvent response =
+ handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger);
+
+ assertThat(response).isNotNull();
+ assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS);
+ assertThat(response.getCallbackContext()).isNull();
+ assertThat(response.getCallbackDelaySeconds()).isEqualTo(0);
+ assertThat(response.getResourceModels()).isNull();
+ assertThat(response.getMessage()).isNull();
+ assertThat(response.getErrorCode()).isNull();
+
+ verify(client, atLeastOnce()).deleteWebApp(any(DeleteWebAppRequest.class));
+ }
+}
diff --git a/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/ListHandlerTest.java b/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/ListHandlerTest.java
new file mode 100644
index 0000000..1ac249f
--- /dev/null
+++ b/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/ListHandlerTest.java
@@ -0,0 +1,66 @@
+package software.amazon.transfer.webapp;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.verify;
+
+import org.assertj.core.api.SoftAssertions;
+import org.assertj.core.api.junit.jupiter.InjectSoftAssertions;
+import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import software.amazon.awssdk.services.transfer.model.ListWebAppsRequest;
+import software.amazon.awssdk.services.transfer.model.ListWebAppsResponse;
+import software.amazon.awssdk.services.transfer.model.ListedWebApp;
+import software.amazon.cloudformation.proxy.OperationStatus;
+import software.amazon.cloudformation.proxy.ProgressEvent;
+import software.amazon.cloudformation.proxy.ResourceHandlerRequest;
+
+@ExtendWith(MockitoExtension.class)
+@ExtendWith(SoftAssertionsExtension.class)
+public class ListHandlerTest extends AbstractTestBase {
+
+ @InjectSoftAssertions
+ private SoftAssertions softly;
+
+ @Test
+ public void handleRequest_SimpleSuccess() {
+ final ListHandler handler = new ListHandler();
+
+ final ResourceModel model = simpleWebAppModel();
+
+ final ResourceHandlerRequest request =
+ requestBuilder().desiredResourceState(model).build();
+
+ ListWebAppsResponse listWebAppsResponse = ListWebAppsResponse.builder()
+ .webApps(ListedWebApp.builder()
+ .arn(TEST_ARN)
+ .webAppId(TEST_WEB_APP_ID)
+ .build())
+ .build();
+ doReturn(listWebAppsResponse).when(client).listWebApps(any(ListWebAppsRequest.class));
+
+ final ProgressEvent response =
+ handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger);
+
+ assertThat(response).isNotNull();
+ assertThat(response.getResourceModels()).isNotNull();
+ assertThat(response.getResourceModels()).size().isEqualTo(1);
+ softly.assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS);
+ softly.assertThat(response.getCallbackContext()).isNull();
+ softly.assertThat(response.getCallbackDelaySeconds()).isEqualTo(0);
+ softly.assertThat(response.getResourceModel()).isNull();
+ softly.assertThat(response.getMessage()).isNull();
+ softly.assertThat(response.getErrorCode()).isNull();
+
+ ResourceModel result = response.getResourceModels().get(0);
+ softly.assertThat(result.getArn()).isEqualTo(TEST_ARN);
+ softly.assertThat(result.getWebAppId()).isEqualTo(TEST_WEB_APP_ID);
+
+ verify(client, atLeastOnce()).listWebApps(any(ListWebAppsRequest.class));
+ }
+}
diff --git a/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/ReadHandlerTest.java b/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/ReadHandlerTest.java
new file mode 100644
index 0000000..241e8e8
--- /dev/null
+++ b/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/ReadHandlerTest.java
@@ -0,0 +1,85 @@
+package software.amazon.transfer.webapp;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.verify;
+
+import org.assertj.core.api.SoftAssertions;
+import org.assertj.core.api.junit.jupiter.InjectSoftAssertions;
+import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import software.amazon.awssdk.services.transfer.model.DescribeWebAppCustomizationRequest;
+import software.amazon.awssdk.services.transfer.model.DescribeWebAppRequest;
+import software.amazon.cloudformation.proxy.OperationStatus;
+import software.amazon.cloudformation.proxy.ProgressEvent;
+import software.amazon.cloudformation.proxy.ResourceHandlerRequest;
+
+@ExtendWith(MockitoExtension.class)
+@ExtendWith(SoftAssertionsExtension.class)
+public class ReadHandlerTest extends AbstractTestBase {
+
+ @InjectSoftAssertions
+ private SoftAssertions softly;
+
+ private final ReadHandler handler = new ReadHandler();
+
+ private void assertSuccessfulResponse(
+ ProgressEvent response, ResourceHandlerRequest request) {
+ assertThat(response).isNotNull();
+ softly.assertThat(response.getStatus()).isEqualTo(OperationStatus.SUCCESS);
+ softly.assertThat(response.getCallbackDelaySeconds()).isEqualTo(0);
+ softly.assertThat(response.getResourceModel()).isEqualTo(request.getDesiredResourceState());
+ softly.assertThat(response.getResourceModels()).isNull();
+ softly.assertThat(response.getMessage()).isNull();
+ softly.assertThat(response.getErrorCode()).isNull();
+
+ verify(client, atLeastOnce()).describeWebApp(any(DescribeWebAppRequest.class));
+ }
+
+ @Test
+ public void handleRequest_SimpleSuccess() {
+ ResourceModel model = simpleWebAppModel();
+ model.setWebAppId(TEST_WEB_APP_ID);
+ model.setArn(TEST_ARN);
+ model.setAccessEndpoint(TEST_ACCESS_ENDPOINT);
+
+ ResourceHandlerRequest request =
+ requestBuilder().desiredResourceState(model).build();
+
+ doReturn(describeWebAppResponseFromModel(model)).when(client).describeWebApp(any(DescribeWebAppRequest.class));
+
+ ProgressEvent response =
+ handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger);
+
+ assertSuccessfulResponse(response, request);
+ softly.assertThat(response.getResourceModel().getArn().equals(TEST_ARN));
+ }
+
+ @Test
+ public void handleRequest_LoadedSuccess() {
+ ResourceModel model = fullyLoadedWebAppModel();
+ model.setWebAppId(TEST_WEB_APP_ID);
+ model.setArn(TEST_ARN);
+
+ ResourceHandlerRequest request =
+ requestBuilder().desiredResourceState(model).build();
+
+ doReturn(describeWebAppResponseFromModel(model)).when(client).describeWebApp(any(DescribeWebAppRequest.class));
+
+ doReturn(describeWebAppCustomizationResponseFromModel(model))
+ .when(client)
+ .describeWebAppCustomization(any(DescribeWebAppCustomizationRequest.class));
+
+ ProgressEvent response =
+ handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger);
+
+ assertSuccessfulResponse(response, request);
+ verify(client, atLeastOnce()).describeWebAppCustomization(any(DescribeWebAppCustomizationRequest.class));
+ softly.assertThat(response.getResourceModel().getArn().equals(TEST_ARN));
+ }
+}
diff --git a/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/UpdateHandlerTest.java b/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/UpdateHandlerTest.java
new file mode 100644
index 0000000..6ab6440
--- /dev/null
+++ b/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/UpdateHandlerTest.java
@@ -0,0 +1,324 @@
+package software.amazon.transfer.webapp;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import java.util.List;
+
+import org.assertj.core.api.junit.jupiter.SoftAssertionsExtension;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.stubbing.Stubber;
+
+import software.amazon.awssdk.awscore.exception.AwsErrorDetails;
+import software.amazon.awssdk.awscore.exception.AwsServiceException;
+import software.amazon.awssdk.services.transfer.model.DescribeWebAppCustomizationRequest;
+import software.amazon.awssdk.services.transfer.model.DescribeWebAppRequest;
+import software.amazon.awssdk.services.transfer.model.DescribeWebAppResponse;
+import software.amazon.awssdk.services.transfer.model.TagResourceRequest;
+import software.amazon.awssdk.services.transfer.model.UntagResourceRequest;
+import software.amazon.awssdk.services.transfer.model.UpdateWebAppCustomizationRequest;
+import software.amazon.awssdk.services.transfer.model.UpdateWebAppCustomizationResponse;
+import software.amazon.awssdk.services.transfer.model.UpdateWebAppRequest;
+import software.amazon.awssdk.services.transfer.model.UpdateWebAppResponse;
+import software.amazon.cloudformation.proxy.OperationStatus;
+import software.amazon.cloudformation.proxy.ProgressEvent;
+import software.amazon.cloudformation.proxy.ResourceHandlerRequest;
+import software.amazon.transfer.webapp.translators.Translator;
+
+@ExtendWith(MockitoExtension.class)
+@ExtendWith(SoftAssertionsExtension.class)
+public class UpdateHandlerTest extends AbstractTestBase {
+
+ final UpdateHandler handler = new UpdateHandler();
+
+ @Test
+ public void handleRequest_SimpleSuccess() {
+ ResourceModel model = simpleWebAppModel();
+
+ final ResourceHandlerRequest request = requestBuilder()
+ .previousResourceState(model)
+ .desiredResourceState(model)
+ .build();
+
+ ProgressEvent response = updateWebAppAndAssertSuccess(request);
+ assertThat(response.getResourceModel()).isEqualTo(model);
+
+ verify(client, atLeastOnce()).updateWebApp(any(UpdateWebAppRequest.class));
+ }
+
+ @Test
+ public void handleRequest_NullIdentityProviderDetails() {
+ ResourceModel model = simpleWebAppModel();
+ ResourceModel newModel = simpleWebAppModel();
+ newModel.setIdentityProviderDetails(null);
+
+ final ResourceHandlerRequest request = requestBuilder()
+ .previousResourceState(model)
+ .desiredResourceState(newModel)
+ .build();
+
+ ProgressEvent response = updateWebAppAndAssertSuccess(request);
+ assertThat(response.getResourceModel().getIdentityProviderDetails())
+ .isEqualTo(model.getIdentityProviderDetails());
+
+ verify(client, atLeastOnce()).updateWebApp(any(UpdateWebAppRequest.class));
+ }
+
+ @Test
+ public void handleRequest_NullWebAppCustomization() {
+ ResourceModel model = fullyLoadedWebAppModel();
+ model.setWebAppId(TEST_WEB_APP_ID);
+ ResourceModel newModel = fullyLoadedWebAppModel();
+ newModel.setWebAppId(TEST_WEB_APP_ID);
+ newModel.setWebAppCustomization(null);
+
+ final ResourceHandlerRequest request = requestBuilder()
+ .previousResourceState(model)
+ .desiredResourceState(newModel)
+ .build();
+
+ ProgressEvent response = updateWebAppAndAssertSuccess(request);
+ assertThat(response.getResourceModel().getWebAppCustomization()).isEqualTo(model.getWebAppCustomization());
+
+ verify(client, atLeastOnce()).updateWebApp(any(UpdateWebAppRequest.class));
+ }
+
+ @Test
+ public void handleRequest_SimpleUpdate_Tagging_Failed() {
+ ResourceModel model = simpleWebAppModel();
+ ResourceModel newModel = simpleWebAppModel();
+ newModel.setTags(Translator.translateTagMapToTagList(EXTRA_MODEL_TAGS));
+
+ ResourceHandlerRequest request = requestBuilder()
+ .previousResourceState(model)
+ .desiredResourceState(newModel)
+ .desiredResourceTags(EXTRA_MODEL_TAGS)
+ .build();
+
+ AwsServiceException ex = AwsServiceException.builder()
+ .awsErrorDetails(
+ AwsErrorDetails.builder().errorCode("AccessDenied").build())
+ .build();
+ doThrow(ex).when(client).tagResource(any(TagResourceRequest.class));
+
+ updateWebAppAndAssertStatus(request, OperationStatus.FAILED);
+
+ verify(client, atLeastOnce()).updateWebApp(any(UpdateWebAppRequest.class));
+ verify(client, atLeastOnce()).tagResource(any(TagResourceRequest.class));
+
+ request = requestBuilder()
+ .previousResourceState(newModel)
+ .desiredResourceState(model)
+ .previousResourceTags(EXTRA_MODEL_TAGS)
+ .build();
+ doThrow(ex).when(client).untagResource(any(UntagResourceRequest.class));
+
+ updateWebAppAndAssertStatus(request, OperationStatus.FAILED);
+
+ verify(client, atLeastOnce()).updateWebApp(any(UpdateWebAppRequest.class));
+ verify(client, atLeastOnce()).untagResource(any(UntagResourceRequest.class));
+ }
+
+ @Test
+ public void handleRequest_VerifyAddingAndRemovingTags() {
+ Tag tag1 = Tag.builder().key("key1").value("value1").build();
+ Tag tag2 = Tag.builder().key("key2").value("value2").build();
+ Tag tag3 = Tag.builder().key("key3").value("value3").build();
+
+ ResourceModel currentModel = simpleWebAppModel();
+ ResourceModel desiredModel = simpleWebAppModel();
+
+ desiredModel.setTags(List.of(tag1, tag2, tag3));
+
+ ResourceHandlerRequest request = requestBuilder()
+ .previousResourceState(currentModel)
+ .desiredResourceState(desiredModel)
+ .build();
+
+ ProgressEvent response = updateWebAppAndAssertSuccess(request);
+
+ assertThat(response.getResourceModel()).isEqualTo(desiredModel);
+
+ currentModel.setTags(List.of(tag1, tag2, tag3));
+ desiredModel.setTags(List.of());
+
+ request = requestBuilder()
+ .previousResourceState(currentModel)
+ .desiredResourceState(desiredModel)
+ .build();
+
+ response = updateWebAppAndAssertSuccess(request);
+
+ currentModel.setTags(List.of(tag1, tag3));
+ desiredModel.setTags(List.of(tag1, tag2, tag3));
+
+ request = requestBuilder()
+ .previousResourceState(currentModel)
+ .desiredResourceState(desiredModel)
+ .build();
+
+ response = updateWebAppAndAssertSuccess(request);
+
+ currentModel.setTags(List.of(tag1, tag2, tag3));
+ desiredModel.setTags(List.of(tag1, tag3));
+
+ request = requestBuilder()
+ .previousResourceState(currentModel)
+ .desiredResourceState(desiredModel)
+ .build();
+
+ response = updateWebAppAndAssertSuccess(request);
+
+ assertThat(response.getResourceModel()).isEqualTo(desiredModel);
+ verify(client, times(4)).updateWebApp(any(UpdateWebAppRequest.class));
+ }
+
+ @Test
+ public void handleRequest_VerifyChangingWebAppUnits() {
+ ResourceModel currentModel = simpleWebAppModel();
+ ResourceModel desiredModel = simpleWebAppModel();
+
+ desiredModel.setWebAppUnits(WebAppUnits.builder().provisioned(3).build());
+
+ ResourceHandlerRequest request = requestBuilder()
+ .previousResourceState(currentModel)
+ .desiredResourceState(desiredModel)
+ .build();
+
+ ProgressEvent response = updateWebAppAndAssertSuccess(request);
+
+ assertThat(response.getResourceModel()).isEqualTo(desiredModel);
+
+ verify(client, atLeastOnce()).updateWebApp(any(UpdateWebAppRequest.class));
+ }
+
+ @Test
+ public void handleRequest_VerifyChangingWebAppCustomization() {
+ ResourceModel currentModel = simpleWebAppModel();
+ ResourceModel desiredModel = simpleWebAppModel();
+
+ desiredModel.setWebAppCustomization(WebAppCustomization.builder()
+ .title("new title")
+ .logoFile(loadTestFile("logo.jpeg"))
+ .faviconFile(null)
+ .build());
+
+ ResourceHandlerRequest request = requestBuilder()
+ .previousResourceState(currentModel)
+ .desiredResourceState(desiredModel)
+ .build();
+
+ ProgressEvent response = updateWebAppAndAssertSuccess(request);
+
+ assertThat(response.getResourceModel()).isEqualTo(desiredModel);
+ verify(client, atLeastOnce()).updateWebApp(any(UpdateWebAppRequest.class));
+ verify(client, atLeastOnce()).updateWebAppCustomization(any(UpdateWebAppCustomizationRequest.class));
+
+ desiredModel.setWebAppCustomization(WebAppCustomization.builder()
+ .title("new title")
+ .logoFile(null)
+ .faviconFile(loadTestFile("favicon.jpeg"))
+ .build());
+
+ request = requestBuilder()
+ .previousResourceState(currentModel)
+ .desiredResourceState(desiredModel)
+ .build();
+
+ response = updateWebAppAndAssertSuccess(request);
+
+ assertThat(response.getResourceModel()).isEqualTo(desiredModel);
+ verify(client, atLeastOnce()).updateWebApp(any(UpdateWebAppRequest.class));
+ verify(client, atLeastOnce()).updateWebAppCustomization(any(UpdateWebAppCustomizationRequest.class));
+ }
+
+ private ProgressEvent updateWebAppAndAssertSuccess(
+ ResourceHandlerRequest request) {
+ return updateWebAppAndAssertStatus(request, OperationStatus.SUCCESS);
+ }
+
+ private ProgressEvent updateWebAppAndAssertStatus(
+ ResourceHandlerRequest request, OperationStatus status) {
+ // Create a copy to avoid the update handler mutating the original.
+ request = request.toBuilder()
+ .previousResourceState(
+ request.getPreviousResourceState().toBuilder().build())
+ .desiredResourceState(
+ request.getDesiredResourceState().toBuilder().build())
+ .build();
+
+ ResourceModel currentModel = request.getPreviousResourceState();
+ ResourceModel desiredModel = request.getDesiredResourceState();
+
+ setupUpdateWebAppResponse(null);
+
+ if (status == OperationStatus.SUCCESS) {
+ DescribeWebAppResponse describeResponse;
+ if (desiredModel.getIdentityProviderDetails() == null) {
+ describeResponse = describeWebAppResponseFromModel(currentModel);
+ } else {
+ describeResponse = describeWebAppResponseFromModel(desiredModel);
+ }
+ doReturn(describeResponse).when(client).describeWebApp(any(DescribeWebAppRequest.class));
+ }
+
+ // Only setup customization response if the model has customization
+ if (desiredModel.getWebAppCustomization() != null) {
+ setupUpdateWebAppCustomizationResponse(null);
+
+ doReturn(describeWebAppCustomizationResponseFromModel(desiredModel))
+ .when(client)
+ .describeWebAppCustomization(any(DescribeWebAppCustomizationRequest.class));
+ } else if (currentModel.getWebAppCustomization() != null) {
+ setupUpdateWebAppCustomizationResponse(null);
+
+ doReturn(describeWebAppCustomizationResponseFromModel(currentModel))
+ .when(client)
+ .describeWebAppCustomization(any(DescribeWebAppCustomizationRequest.class));
+ }
+
+ final ProgressEvent response =
+ handler.handleRequest(proxy, request, new CallbackContext(), proxyClient, logger);
+
+ assertThat(response).isNotNull();
+ assertThat(response.getStatus()).isEqualTo(status);
+
+ return response;
+ }
+
+ private void setupUpdateWebAppResponse(Exception ex) {
+ UpdateWebAppResponse response =
+ UpdateWebAppResponse.builder().webAppId(TEST_WEB_APP_ID).build();
+
+ Stubber stubber;
+ if (ex != null) {
+ stubber = doThrow(ex).doReturn(response);
+ } else {
+ stubber = doReturn(response);
+ }
+
+ stubber.when(client).updateWebApp(any(UpdateWebAppRequest.class));
+ }
+
+ private void setupUpdateWebAppCustomizationResponse(Exception ex) {
+ UpdateWebAppCustomizationResponse response = UpdateWebAppCustomizationResponse.builder()
+ .webAppId(TEST_WEB_APP_ID)
+ .build();
+
+ Stubber stubber;
+ if (ex != null) {
+ stubber = doThrow(ex).doReturn(response);
+ } else {
+ stubber = doReturn(response);
+ }
+
+ stubber.when(client).updateWebAppCustomization(any(UpdateWebAppCustomizationRequest.class));
+ }
+}
diff --git a/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/translators/ExtraCoverageTest.java b/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/translators/ExtraCoverageTest.java
new file mode 100644
index 0000000..dc7647a
--- /dev/null
+++ b/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/translators/ExtraCoverageTest.java
@@ -0,0 +1,42 @@
+package software.amazon.transfer.webapp.translators;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.List;
+import java.util.Map;
+
+import com.google.common.collect.ImmutableMap;
+
+import org.junit.jupiter.api.Test;
+
+import software.amazon.awssdk.services.transfer.model.Tag;
+import software.amazon.cloudformation.proxy.ResourceHandlerRequest;
+import software.amazon.transfer.webapp.ResourceModel;
+
+public class ExtraCoverageTest {
+ @Test
+ public void nullCheckingCodeCoverage() {
+ assertThat(Translator.translateToSdkTags((Map) null)).isNull();
+ assertThat(Translator.translateFromSdkTags((List) null)).isNull();
+ assertThat(Translator.translateTagMapToTagList((Map) null))
+ .isNull();
+ assertThat(Translator.translateTagListToTagMap((List) null))
+ .isNull();
+ assertThat(Translator.nullIfEmptyList((List) null)).isNull();
+ assertThat(Translator.emptyListIfNull((List) null)).isEmpty();
+ assertThat(Translator.emptyStringIfNull((String) null)).isEmpty();
+
+ ResourceModel state = ResourceModel.builder().build();
+ ImmutableMap tags = ImmutableMap.of("a", "b");
+ ResourceHandlerRequest request = ResourceHandlerRequest.builder()
+ .previousResourceState(state)
+ .desiredResourceState(state)
+ .previousResourceTags(tags)
+ .desiredResourceTags(tags)
+ .previousSystemTags(tags)
+ .systemTags(tags)
+ .build();
+ assertThat(TagHelper.getNewDesiredTags(request)).isEqualTo(tags);
+ assertThat(TagHelper.getPreviouslyAttachedTags(request)).isEqualTo(tags);
+ }
+}
diff --git a/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/translators/WebAppArnTest.java b/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/translators/WebAppArnTest.java
new file mode 100644
index 0000000..e7a3c40
--- /dev/null
+++ b/aws-transfer-webapp/src/test/java/software/amazon/transfer/webapp/translators/WebAppArnTest.java
@@ -0,0 +1,21 @@
+package software.amazon.transfer.webapp.translators;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+
+public class WebAppArnTest {
+
+ @Test
+ public void testFromString() {
+ String arn = "arn:aws:transfer:us-east-1:123456789012:webapp/webapp-123456789012";
+ WebAppArn webAppArn = WebAppArn.fromString(arn);
+ assertEquals(arn, webAppArn.getArn());
+ assertEquals("transfer", webAppArn.getVendor());
+ assertEquals("us-east-1", webAppArn.getRegion().getName());
+ assertEquals("123456789012", webAppArn.getAccountId());
+ assertEquals("webapp", webAppArn.getResourceType());
+ assertEquals("webapp-123456789012", webAppArn.getWebAppId());
+ assertEquals("webapp-123456789012", webAppArn.getResourceId());
+ }
+}
diff --git a/aws-transfer-webapp/src/test/resources/favicon.jpeg b/aws-transfer-webapp/src/test/resources/favicon.jpeg
new file mode 100644
index 0000000000000000000000000000000000000000..50f8cdb12f5fe0952fa5996b83c23fe8b79bfc2d
GIT binary patch
literal 745
zcmex=^(PF6}rMnOeST|r4lSw=>~TvNxu(8R<c1}I=;VrF4wW9Q)H;sz?%
zD!{d!pzFb!U9xX3zTPI5o8roG<0MW4oqZMDikqloVbuf*=gfJ(V&YTRE(2~
znmD<{#3dx9RMpfqG__1j&CD$#!>q*YRht(X$9CR+%khz|%`9e4XZbYl?U{)a
zdk;)1{>;_!StYCeH=of0-br!C&g?rf{mJjExpI-}A4Rtqow(%kP1t(zoYm&@*gW19
zc${BodNuZCR8icy)rnam9tRk?9`O9tQdC)T=eqg&ulWZ;)!$s6_Hyq&@3{Mo|8D{S
DfbCDYcFCtT37-M6&)5!Ty%Bm5ZGZOq{3)k;40F*!6wgQ=i
z9!;5HSI*9&1IYS+{RBMhvo5kc$`Hr|O{xO0;0sc?AXF#eR{>PZ7N-njMrGLH_TLE|hgx_BkPDd3Ooz8D2x!e})
z6kD0t8Evfy?ke1(1PeEvx7fdsAYT3H(J+P&Q`LfW1-eFuob)V^D|h1o59!h$s5M##
zibJ&pos9OU*r<>14`FH%tN|NjVDkjcEc@RK|Id-jTN1bIDF5DcywV0AiOD1|7`~Fd
Yspu$>SUyGu3jhEB07*qoM6N<$g18T!-2eap
literal 0
HcmV?d00001
diff --git a/aws-transfer-webapp/src/test/resources/logo.jpeg b/aws-transfer-webapp/src/test/resources/logo.jpeg
new file mode 100644
index 0000000000000000000000000000000000000000..50f8cdb12f5fe0952fa5996b83c23fe8b79bfc2d
GIT binary patch
literal 745
zcmex=^(PF6}rMnOeST|r4lSw=>~TvNxu(8R<c1}I=;VrF4wW9Q)H;sz?%
zD!{d!pzFb!U9xX3zTPI5o8roG<0MW4oqZMDikqloVbuf*=gfJ(V&YTRE(2~
znmD<{#3dx9RMpfqG__1j&CD$#!>q*YRht(X$9CR+%khz|%`9e4XZbYl?U{)a
zdk;)1{>;_!StYCeH=of0-br!C&g?rf{mJjExpI-}A4Rtqow(%kP1t(zoYm&@*gW19
zc${BodNuZCR8icy)rnam9tRk?9`O9tQdC)T=eqg&ulWZ;)!$s6_Hyq&@3{Mo|8D{S
DfbCDYcFCtT37-M6&)5!Ty%Bm5ZGZOq{3)k;40F*!6wgQ=i
z9!;5HSI*9&1IYS+{RBMhvo5kc$`Hr|O{xO0;0sc?AXF#eR{>PZ7N-njMrGLH_TLE|hgx_BkPDd3Ooz8D2x!e})
z6kD0t8Evfy?ke1(1PeEvx7fdsAYT3H(J+P&Q`LfW1-eFuob)V^D|h1o59!h$s5M##
zibJ&pos9OU*r<>14`FH%tN|NjVDkjcEc@RK|Id-jTN1bIDF5DcywV0AiOD1|7`~Fd
Yspu$>SUyGu3jhEB07*qoM6N<$g18T!-2eap
literal 0
HcmV?d00001
diff --git a/aws-transfer-webapp/template.yml b/aws-transfer-webapp/template.yml
new file mode 100644
index 0000000..b523cd0
--- /dev/null
+++ b/aws-transfer-webapp/template.yml
@@ -0,0 +1,24 @@
+AWSTemplateFormatVersion: "2010-09-09"
+Transform: AWS::Serverless-2016-10-31
+Description: AWS SAM template for the AWS::Transfer::WebApp resource type
+
+Globals:
+ Function:
+ Timeout: 180 # docker start-up times can be long for SAM CLI
+ MemorySize: 256
+
+Resources:
+ TypeFunction:
+ Type: AWS::Serverless::Function
+ Properties:
+ Handler: software.amazon.transfer.webapp.HandlerWrapper::handleRequest
+ Runtime: java17
+ CodeUri: ./target/aws-transfer-webapp-handler-1.0-SNAPSHOT.jar
+
+ TestEntrypoint:
+ Type: AWS::Serverless::Function
+ Properties:
+ Handler: software.amazon.transfer.webapp.HandlerWrapper::testEntrypoint
+ Runtime: java17
+ CodeUri: ./target/aws-transfer-webapp-handler-1.0-SNAPSHOT.jar
+
diff --git a/pom.xml b/pom.xml
index 13ad499..1baa6ee 100644
--- a/pom.xml
+++ b/pom.xml
@@ -18,6 +18,7 @@
aws-transfer-profile
aws-transfer-server
aws-transfer-user
+ aws-transfer-webapp
aws-transfer-workflow