John Tipper
Navigate back to the homepage

A static website with API backend using AWS CDK and Java

John Tipper
September 12th, 2020 · 5 min read

Creating a static website with AWS CDK and Java

Let’s take a very quick canter through creating a minimal static website with a REST API backend using AWS CDK. We’ll host the website in S3 (meaning it’s cheap), but we’ll put a CloudFront CDN distribution in front of it (meaning that your S3 data costs can be minimised and you can offer a low latency read experience for your readers, wherever in the world they may be). We’ll also add a TLS certificate so that visitors can have confidence in your site.

TLS

Let’s start with the plumbing - we assume that you’ve got a domain name that you’ve registered somewhere. This doesn’t have to be AWS. We’ll assume that you have created a Route53 Hosted Zone for your domain in the AWS console separately: there’s a guide here if you’re unsure of how to do this.

1// Route53 hosted zone created out-of-band
2IHostedZone hostedZone = HostedZone.fromLookup(this, "HostedZone", HostedZoneProviderProps.builder()
3 .domainName(stackConfig.getDomainName())
4 .build());
5
6DnsValidatedCertificate websiteCertificate = DnsValidatedCertificate.Builder.create(this, "WebsiteCertificate")
7 .hostedZone(hostedZone)
8 .region("us-east-1")
9 .domainName(stackConfig.getDomainName())
10 .subjectAlternativeNames(List.of(String.format("www.%s", stackConfig.getDomainName())))
11 .build();

Because we are creating the Hosted Zone out of band (i.e. not as part of the tutorial) and we wish to refer to it within our CDK construct, we need to do a lookup of it. This particular call will interrogate AWS during CDK synthesis, meaning AWS credentials will be required from this step. We then create a TLS certificate from AWS Certificate Manager, validated by DNS, in just 6 lines of code! Note that we create the certificate in us-east-1 because we want to use it with CloudFront, and we also define a Subject Alternative Name (SAN) for the certificate. This means that users can refer to our website with a www. prefix and their browser will still trust the certificate we serve up.

When CDK creates the certificate, it may take a little while to complete. This is because under the covers, AWS will create the certificate, create DNS entries in our Hosted Zone that prove to AWS that we control our domain, then wait for AWS to recognise those certificate entries and thus provide us with a validated certificate.

S3 static website with CloudFront CDN

Let’s create an S3 bucket for use as a website container. CDK makes this super easy to do and the API is really nice to work with.

1Bucket websiteBucket = Bucket.Builder.create(this, "WebsiteBucket")
2 .bucketName(String.format("website-%s", props.getEnv().getAccount()))
3 .encryption(BucketEncryption.UNENCRYPTED)
4 .websiteIndexDocument("index.html")
5 .removalPolicy(RemovalPolicy.DESTROY)
6 .build();

Now let’s think about adding a CloudFront CDN in front of the bucket, so that the website contents are cached geographically close to readers.

1OriginAccessIdentity webOai = OriginAccessIdentity.Builder.create(this, "WebOai")
2 .comment(String.format("OriginAccessIdentity for %s", stackConfig.getDomainName()))
3 .build();
4
5
6websiteBucket.grantRead(webOai);
7
8CloudFrontWebDistribution cloudFrontWebDistribution = CloudFrontWebDistribution.Builder.create(this, "CloudFrontWebDistribution")
9 .comment(String.format("CloudFront distribution for %s", stackConfig.getDomainName()))
10 .viewerCertificate(ViewerCertificate.fromAcmCertificate(websiteCertificate, ViewerCertificateOptions.builder()
11 .aliases(List.of(stackConfig.getDomainName()))
12 .build()))
13 .originConfigs(List.of(SourceConfiguration.builder()
14 .behaviors(List.of(Behavior.builder()
15 .isDefaultBehavior(true)
16 .defaultTtl(Duration.minutes(5))
17 .maxTtl(Duration.minutes(5))
18 .build()))
19 .s3OriginSource(S3OriginConfig.builder()
20 .originAccessIdentity(webOai)
21 .s3BucketSource(websiteBucket)
22 .build())
23 .build()
24 ))
25 .priceClass(PriceClass.PRICE_CLASS_100)
26 .viewerProtocolPolicy(ViewerProtocolPolicy.REDIRECT_TO_HTTPS)
27 .errorConfigurations(List.of(CfnDistribution.CustomErrorResponseProperty.builder()
28 .errorCode(403)
29 .responseCode(200)
30 .responsePagePath("/index.html")
31 .build(),
32 CfnDistribution.CustomErrorResponseProperty.builder()
33 .errorCode(404)
34 .responseCode(200)
35 .responsePagePath("/index.html")
36 .build()))
37 .build();

We start by defining an Origin Access Identity, which is basically a way of defining who or what may read from our bucket - it’s a special CloudFront user. It means that ClodFront can get our files from S3, but a user can’t just access the S3 bucket directly. We need to grant permission to the OAI to access our bucket. This “grant” pattern is pretty common in the CDK for defining who or what may access a resource, it’s not just S3 resources: you’ll see it for many other resources such as SQS, Parameter Store etc.

We use the TLS certificate we created earlier and we define that users should be redirected to HTTPS if they try to access using HTTP. Note that the price class of the CDN defines where in the world you want your static data cached. PRICE_CLASS_100 is the cheapest caching option but you can choose others, see here for details.

Now let’s wrap this up by some housekeeping: we want the CDN to redirect HTTP to HTTPS and CloudFront requires a DNS entry at the apex level, so we create a DNS entry that points at our CDN distribution.

1HttpsRedirect webHttpsRedirect = HttpsRedirect.Builder.create(this, "WebHttpsRedirect")
2 .certificate(websiteCertificate)
3 .recordNames(List.of(String.format("www.%s", stackConfig.getDomainName())))
4 .targetDomain(stackConfig.getDomainName())
5 .zone(hostedZone)
6 .build();
7
8
9ARecord apexARecord = ARecord.Builder.create(this, "ApexARecord")
10 .recordName(stackConfig.getDomainName())
11 .zone(hostedZone)
12 .target(RecordTarget.fromAlias(new CloudFrontTarget(cloudFrontWebDistribution)))
13 .build();

Adding an API

So far, we’ve created infrastructure suitable for hosting a static website. Let’s add support for a REST API.

To keep thongs neat, we’ll start by creating a separate CDK Construct to hold all of our API infrastructure. We do this by creating a Java class that extends from Construct:

1public class HelloWorldApi extends Construct {
2
3 private final IRestApi restApi;
4
5 public HelloWorldApi(@NotNull Construct scope, @NotNull String id, StackProps props, WebBackendStackConfig stackConfig) throws IOException {
6 super(scope, id);
7
8 public HelloWorldApi(@NotNull Construct scope, @NotNull String id, StackProps props, WebBackendStackConfig stackConfig) throws IOException {
9 super(scope, id);
10 // we'll add more here in a moment...
11
12 }
13
14 public IRestApi getRestApi() {
15 return restApi;
16 }

You’ll notice that we have a class member variable called restApi that refers to a CDK construct, with an associated getter. We will initiate that variable in the constructor. This is a pattern by which we can expose CDK constructs to other parts of our stack, without needing to having one large monolith. Despite the fact that those constructs will actually represent infrastructure, to the CDK they’re just a Java variable. This is the paradigm of CDK: just program normally.

Now we can define a lambda inside our HelloWorldApi construct. Best practice is to not have a single lambda that does everything: you should split these out. We only have one function, so that makes things easy for us.

1SingletonFunction helloWorldLambda = SingletonFunction.Builder.create(this, "HelloWorldLambda")
2 .description("HelloWorld lambda to demonstrate integration with ApiGateway")
3 .code(Code.fromAsset(stackConfig.getApiLambdaPath()))
4 .handler(String.format("%s::handleRequest", "org.johntipper.blog.lambda.HelloWorldHandler"))
5 .timeout(Duration.seconds(10))
6 .runtime(Runtime.JAVA_11)
7 .memorySize(256)
8 .uuid(UUID.randomUUID()
9 .toString())
10 .logRetention(RetentionDays.ONE_WEEK)
11 .build();

We need a unique identifier for the function and we don’t define the name of the function, otherwise we’ll get into trouble if we try to redeploy a different version. The CDK API makes it simple to define how we want out function to behave with respect to timeouts, memory usage, log retention etc. Note that we define a path to where our executable code for the function should be found that we’ll pass in.

Now that we have a function defined, we need to turn to defining permissions. We want to ensure that it may be executed by both ApiGateway as well as from the AWS console.

1// allow lambda to write logs, allow APIG & console to call the lambda
2CfnPermission helloWorldRestPermission = CfnPermission.Builder.create(this, "HelloWorldRestPermission")
3 .action("lambda:InvokeFunction")
4 .principal("apigateway.amazonaws.com")
5 .sourceArn(String.format(
6 "arn:aws:execute-api:%s:%s:*",
7 props.getEnv()
8 .getRegion(),
9 props.getEnv()
10 .getAccount()))
11 .functionName(helloWorldLambda.getFunctionName())
12 .build();
13
14helloWorldLambda.grantInvoke(ServicePrincipal.Builder.create("apigateway.amazonaws.com")
15 .build());
16
17
18Role apiGatewayRole = Role.Builder.create(this, "ApiGatewayRole")
19 .assumedBy(ServicePrincipal.Builder.create("apigateway.amazonaws.com")
20 .build())
21 .roleName("ApiGatewayLambdaExecutionRole")
22 .build();
23
24apiGatewayRole.addToPolicy(PolicyStatement.Builder.create()
25 .resources(List.of("*"))
26 .actions(List.of("logs:*"))
27 .effect(Effect.ALLOW)
28 .build());
29
30apiGatewayRole.addToPolicy(PolicyStatement.Builder.create()
31 .resources(List.of(helloWorldLambda.getFunctionArn()))
32 .actions(List.of("lambda:InvokeFunction"))
33 .effect(Effect.ALLOW)
34 .build());

Let’s define our API using OpenAPI, where we define an endpoint hat the user will call and receive a hello world response (we define the api endpoints to reside behind an initial path prefix of /api).

1openapi: 3.0.0
2info:
3 title: Demo API
4 description: REST API demonstrating how to integrate into a CloudFront distribution.
5 version: 0.1.0
6
7servers:
8 - url: /api
9
10x-amazon-apigateway-gateway-responses:
11 DEFAULT_4XX:
12 ResponseParameters:
13 gatewayresponse.header.Access-Control-Allow-Origin: "'*'"
14 gatewayresponse.header.Access-Control-Allow-Headers: "'*'"
15 gatewayresponse.header.Access-Control-Allow-Methods: '''*'''
16 UNAUTHORIZED:
17 ResponseParameters:
18 gatewayresponse.header.Access-Control-Allow-Origin: "'*'"
19 gatewayresponse.header.Access-Control-Allow-Headers: "'*'"
20 gatewayresponse.header.Access-Control-Allow-Methods: '''*'''
21 StatusCode: '401'
22 EXPIRED_TOKEN:
23 ResponseParameters:
24 gatewayresponse.header.Access-Control-Allow-Origin: "'*'"
25 gatewayresponse.header.Access-Control-Allow-Headers: "'*'"
26 gatewayresponse.header.Access-Control-Allow-Methods: '''*'''
27 StatusCode: '401'
28
29components:
30 schemas:
31 HelloWorldResponse:
32 properties:
33 message:
34 type: string
35
36paths:
37 /hello:
38 get:
39 summary: Hello world endpoint.
40 responses:
41 '200':
42 description: OK
43 headers:
44 Access-Control-Allow-Origin:
45 schema:
46 type: string
47 Access-Control-Allow-Methods:
48 schema:
49 type: string
50 Access-Control-Allow-Headers:
51 schema:
52 type: string
53 content:
54 application/json:
55 schema:
56 $ref: '#/components/schemas/HelloWorldResponse'
57
58 x-amazon-apigateway-integration:
59 uri: "{{helloworld-lambda}}"
60 passthroughBehavior: "when_no_match"
61 httpMethod: "POST"
62 type: "aws_proxy"

We can edit this file to our heart’s content in an OpenAPI editor such as Swagger Editor. Note that there’s a bit of a pain point here: we need to define via the x-amazon-apigateway-integration stanza what our Lambda execution endpoint will be, but we won’t know this until the API is deployed. This forms a bit of a chicken and egg problem. When you Google the problem, others solve this by deploying twice, or some manual post-deploy configuration. We’ll get around this by templating the value into the OpenAPI spec as part of the CDK synthesis. That’s what the {{helloworld-lambda}} placeholder is, it’s a Mustache template placeholder. We template the API spec and inject our value which we get from the Lambda we created earlier, then pass that templated API to CDK to create an ApiGateway for us.

1// need to inject lambda execution ARN into OpenAPI spec, so we use mustache to template, then parse templated spec
2Map<String, Object> variables = new HashMap<>();
3variables.put("helloworld-lambda", String.format(
4 "arn:aws:apigateway:%s:lambda:path/2015-03-31/functions/%s/invocations",
5 props.getEnv()
6 .getRegion(),
7 helloWorldLambda.getFunctionArn()));
8
9Writer writer = new StringWriter();
10MustacheFactory mmf = new DefaultMustacheFactory();
11
12Object openapiSpecAsObject;
13try (Reader reader = new FileReader(new File("api.yaml"))) {
14 Mustache mustache = mmf.compile(reader, "OAS");
15 mustache.execute(writer, variables);
16 writer.flush();
17
18 ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory());
19
20 openapiSpecAsObject = yamlMapper.readValue(writer.toString(), Object.class);
21
22}
23
24restApi = SpecRestApi.Builder.create(this, "OpenapiRestApi")
25 .restApiName("HelloWorld")
26 .apiDefinition(ApiDefinition.fromInline(openapiSpecAsObject))
27 .deploy(true)
28 .deployOptions(StageOptions.builder()
29 .stageName("api")
30 .build())
31 .build();
32restApi.getNode()
33 .addDependency(apiGatewayRole);
34
35restApi.getNode()
36 .addDependency(helloWorldRestPermission);
37
38restApi.getDeploymentStage()
39 .getNode()
40 .addDependency(helloWorldRestPermission);
41
42helloWorldLambda.addPermission(
43 "AllowApiGatewayInvocation",
44 Permission.builder()
45 .action("lambda:InvokeFunction")
46 .principal(ServicePrincipal.Builder.create("apigateway.amazonaws.com")
47 .build())
48 .sourceArn(restApi.arnForExecuteApi())
49 .build());

The beauty of the CDK is then that we can refer to this REST API defined within this construct, containing whatever arbitrary complexity we define via multiple Lambdas etc, by means of referring to simply the construct. By this I mean we just need to add the construct into our stack:

1HelloWorldApi helloWorldApi = new HelloWorldApi(this, "HelloWorldApi", props, stackConfig);

Finally, if you access the API via a browser, you’ll have Cross Origin Resource issues if you wish to call your API via a different domain name, for example api.example.com. To get around this, we want to be able to hit our API via the same domain name as we use for our website, just via a different path. For example, reequest paths matching example.com/api/* should perhaps result in our API being called and all others just go through to our static website in S3. To achieve this, we can modify our CloudFront distribution to do this for us.

1CloudFrontWebDistribution cloudFrontWebDistribution = CloudFrontWebDistribution.Builder.create(this, "CloudFrontWebDistribution")
2 .comment(String.format("CloudFront distribution for %s", stackConfig.getDomainName()))
3 .viewerCertificate(ViewerCertificate.fromAcmCertificate(websiteCertificate, ViewerCertificateOptions.builder()
4 .aliases(List.of(stackConfig.getDomainName()))
5 .build()))
6 .originConfigs(List.of(SourceConfiguration.builder()
7 .behaviors(List.of(Behavior.builder()
8 .isDefaultBehavior(true)
9 .defaultTtl(Duration.minutes(5))
10 .maxTtl(Duration.minutes(5))
11 .build()))
12 .s3OriginSource(S3OriginConfig.builder()
13 .originAccessIdentity(webOai)
14 .s3BucketSource(websiteBucket)
15 .build())
16 .build(),
17 SourceConfiguration.builder()
18 .behaviors(List.of(Behavior.builder()
19 .pathPattern("api/*")
20 .allowedMethods(CloudFrontAllowedMethods.ALL)
21 .build()))
22 .customOriginSource(CustomOriginConfig.builder()
23 .domainName(String.format("%s.execute-api.%s.amazonaws.com", helloWorldApi.getRestApi().getRestApiId(), stackConfig.getRegion()))
24 .build())
25 .build()
26 ))
27 .priceClass(PriceClass.PRICE_CLASS_100)
28 .viewerProtocolPolicy(ViewerProtocolPolicy.REDIRECT_TO_HTTPS)
29 .errorConfigurations(List.of(CfnDistribution.CustomErrorResponseProperty.builder()
30 .errorCode(403)
31 .responseCode(200)
32 .responsePagePath("/index.html")
33 .build(),
34 CfnDistribution.CustomErrorResponseProperty.builder()
35 .errorCode(404)
36 .responseCode(200)
37 .responsePagePath("/index.html")
38 .build()))
39 .build();

Notice that we define an S3 origin and a custom origin. The S3 origin points at our bucket and our API is pointed to by the custom origin. CloudFront takes care of working out which requests should go where.

Finally, let’s define the content of our website - we want changes made in this repository to not only change the underlying infrastructure, but also for the content of our website to change too.

1BucketDeployment websiteContent = BucketDeployment.Builder.create(this, "WebsiteContent")
2 .destinationBucket(websiteBucket)
3 .sources(List.of(Source.asset(stackConfig.getWebsiteAssetsPath())))
4 .distribution(cloudFrontWebDistribution)
5 .distributionPaths(List.of("/*"))
6 .memoryLimit(2048)
7 .build();

This CDK construct is all we need to have our code updated. We define a reasonable memory size for the lambda that will perform the copy of the data as if it’s too small and we have a large website with lots of files to copy, the Lambda will timeout before completing.

Full details of this are in GitHub where the source to my blog is stored here. The next post will cover integrating the build and deploy of the website into CI/CD using GitHub Actions.

More articles from John Tipper

Setting up the AWS CDK with Java & Gradle

How the AWS CDK can be used with Java, with an introduction to Gradle en-route.

August 29th, 2020 · 6 min read

Introduction to the Cloud Resume Challenge

Introduction to the Cloud Resume Challenge using AWS CDK & Java.

August 27th, 2020 · 5 min read
© 2018–2021 John Tipper
Link to $https://twitter.com/john_tipperLink to $https://github.com/john-tipperLink to $https://www.linkedin.com/in/john-tipper-5076395