21 August 2023 ~ 28 min read
Setting up SwaggerUI in AWS with S3/API Gateway/GitHub Actions

Benjamin Clark
Introduction

In a recent blog post I mentioned how I wanted to refactor my original monsternames-api from a horrible, expensive (relatively), monolith into a lovely Serverless application. Well, after setting up AWS from scratch for the Sudoblark AWS Account I set to work and… a couple of thousands of words later I realised covering all of that in one blog was perhaps a bit too much.
So I’ve decided to split it up, and treat each step independently in case others find the various bits of refactoring useful.
The Goal
Within this blog post I aim to:
-
Setup a static website in S3
-
Setup an OpenAPI definition of monsternames-api, and use GitHub actions to:
-
Turn that into a pretty SwaggerUI
-
Push it into the bucket in a GitOps fashion
-
-
Put an API Gateway in front of the S3 bucket to have consistent naming across this and the future implementation of the API itself
The actual setup
Prerequisites
I’m assuming:
-
You’re familiar with AWS
-
You’re familiar with your own flavour of CI/CD platform
-
You at least have a cursory knowledge of GitHub actions
-
You’re aware of the GitOps workflow
-
You’re aware of Terraform and IaC
If not, I’ve hyperlinked various useful articles below:
In addition, you’ll need the following software on your laptop to follow along:
-
tfenv to use the terraform snippets, using terraform version 1.2.7
-
Docker engine to run SwaggerUI locally
-
npm to install the swagger-ui for validation
I’ve already covered combining AWS/GitHub Actions/Terraform/Infrastructure-as-Code in some detail, so maybe read how I setup Terraform from scratch for AWS then used GitHub actions to perform CI/CD if these things are relatively new to you.
S3 bucket creation
This was perhaps the easiest part, involving just a two step process:
- Creating a monsternames module within our terraform mono repo:
% pwd
terraform.aws/modules/monsternames
% ls -la
total 40
drwxr-xr-x 7 bclark staff 224 Aug 16 21:04 .
drwxr-xr-x 3 bclark staff 96 Aug 7 13:23 ..
-rw-r--r-- 1 bclark staff 542 Aug 16 21:04 aws_iam_group.tf
-rw-r--r-- 1 bclark staff 735 Aug 16 20:56 aws_iam_policy.tf
-rw-r--r-- 1 bclark staff 715 Aug 16 20:43 aws_s3_bucket.tf
-rw-r--r-- 1 bclark staff 363 Aug 16 20:35 locals.tf
-rw-r--r-- 1 bclark staff 1278 Aug 16 21:02 variables.tf
Terraform themselves cover what modules are and how to create them, so rather than repeat this I’ll just cover the contents of each file below:
locals.tf
This simply defines consistent, constant, data across our module.
locals {
service_name = "monsternames"
static_bucket_name = lower("${var.account}-${var.environment}-${local.service_name}-static-content")
default_tags = {
account: var.account,
environment: var.environment,
source: var.source_repository,
service: local.service_name
}
base_path = lower("/${var.environment}/${local.service_name}/")
}
variables.tf
This defines the inputs that our module can have. As always, it’s good practice to:
-
Explicitly define inputs
-
Explicitly define types
-
Explicitly define permissible values
variable "environment" {
description = "The environment within which we're instantiating this module."
type = string
validation {
condition = anytrue([
var.environment == "Prod",
var.environment == "Stage",
var.environment == "Dev"
])
error_message = "Only the following environments are supported: Prod/Stage/Dev"
}
}
variable "account" {
description = "The AWS account within which we're instantiating this module."
type = string
}
variable "static_content_versioning" {
description = "Whether to enable versioning on the static content S3 bucket."
type = string
default = "Enabled"
validation {
condition = anytrue([
var.static_content_versioning == "Enabled",
var.static_content_versioning == "Suspended",
var.static_content_versioning == "Disabled"
])
error_message = "Only the following environments are supported: Enabled/Suspended/Disabled"
}
}
variable "source_repository" {
description = "Terraform repository managing this module, used for tagging purposes."
type = string
default = "terraform.aws"
}
variable "static_content_upload_users" {
description = "IAM users whom should be allowed to assume static content upload role"
type = list(string)
default = []
}
aws_s3_bucket.tf
This file actually creates our s3 bucket, with appropriate versioning and tagging for cost tracking:
resource "aws_s3_bucket" "static_content" {
bucket = local.static_bucket_name
tags = local.default_tags
}
resource aws_s3_bucket_versioning "static_content_versioning" {
depends_on = [aws_s3_bucket.static_content]
bucket = aws_s3_bucket.static_content.id
versioning_configuration {
status = var.static_content_versioning
}
}
resource "aws_s3_bucket_ownership_controls" "static_content_ownership" {
bucket = aws_s3_bucket.static_content.id
rule {
object_ownership = "BucketOwnerPreferred"
}
}
resource "aws_s3_bucket_acl" "static_content_acl" {
depends_on = [aws_s3_bucket_ownership_controls.static_content_ownership]
bucket = aws_s3_bucket.static_content.id
acl = "private"
}
aws_iam_policy.tf
This the policy needed to allow select users - in our case just the CI/CD user - to actually update the contents of the bucket:
data "aws_iam_policy_document" "upload" {
statement {
sid = "1"
effect = "Allow"
actions = [
"s3:ListBucket"
]
resources = [
"arn:aws:s3:::${aws_s3_bucket.static_content.id}"
]
}
statement {
sid = "2"
effect = "Allow"
actions = [
"s3:Put*",
"s3:Get*",
"s3:DeleteObject"
]
resources = [
"arn:aws:s3:::${aws_s3_bucket.static_content.id}/*"
]
}
}
resource "aws_iam_policy" "upload" {
name = "${local.static_bucket_name}-upload-policy"
path = "${local.base_path}iam/"
description = "Policy which allows upload access to ${local.static_bucket_name} - managed by Terraform"
policy = data.aws_iam_policy_document.upload.json
}
aws_iam_group.tf
Here we setup a new IAM group - which will have the previous policy attached - in order to manage user upload access to the bucket.
resource "aws_iam_group" "static_content_upload" {
name = "${local.static_bucket_name}-static-content-upload-group"
path = "${local.base_path}iam/"
}
resource "aws_iam_group_membership" "static_content_upload" {
group = aws_iam_group.static_content_upload.id
name = "${aws_iam_group.static_content_upload.name}-membership"
users = var.static_content_upload_users
}
resource "aws_iam_group_policy_attachment" "attachments" {
group = aws_iam_group.static_content_upload.id
policy_arn = aws_iam_policy.upload.arn
}
- Instantiating the module:
We simply create a .tf
file in our account folder:
% ls -ls | grep monster
8 -rw-r--r-- 1 bclark staff 200 Aug 16 21:02 monsternames.tf
Which just calls our module and provides suitable variable values:
module "monsternames" {
source = "../modules/monsternames"
environment = local.environment
account = local.account
source_repository = local.current_repo
static_content_upload_users = [aws_iam_user.users["github_actions"].name]
}
With pertinent locals.tf
values just being:
locals {
account = "sudoblark"
environment = "Prod"
current_repo = "github.com/sudoblark/terraform.aws"
}
A simple plan then produces the following…
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
+ create
<= read (data resources)
Terraform will perform the following actions:
# module.monsternames.data.aws_iam_policy_document.upload will be read during apply
# (config refers to values not yet known)
<= data "aws_iam_policy_document" "upload" {
+ id = (known after apply)
+ json = (known after apply)
+ statement {
+ actions = [
+ "s3:ListBucket",
]
+ effect = "Allow"
+ resources = [
+ (known after apply),
]
+ sid = "1"
}
+ statement {
+ actions = [
+ "s3:DeleteObject",
+ "s3:Get*",
+ "s3:Put*",
]
+ effect = "Allow"
+ resources = [
+ (known after apply),
]
+ sid = "2"
}
}
# module.monsternames.aws_iam_group.static_content_upload will be created
+ resource "aws_iam_group" "static_content_upload" {
+ arn = (known after apply)
+ id = (known after apply)
+ name = "sudoblark-prod-monsternames-static-content-static-content-upload-group"
+ path = "/prod/monsternames/iam/"
+ unique_id = (known after apply)
}
# module.monsternames.aws_iam_group_membership.static_content_upload will be created
+ resource "aws_iam_group_membership" "static_content_upload" {
+ group = (known after apply)
+ id = (known after apply)
+ name = "sudoblark-prod-monsternames-static-content-static-content-upload-group-membership"
+ users = [
+ "github_actions",
]
}
# module.monsternames.aws_iam_group_policy_attachment.attachments will be created
+ resource "aws_iam_group_policy_attachment" "attachments" {
+ group = (known after apply)
+ id = (known after apply)
+ policy_arn = (known after apply)
}
# module.monsternames.aws_iam_policy.upload will be created
+ resource "aws_iam_policy" "upload" {
+ arn = (known after apply)
+ description = "Policy which allows upload access to sudoblark-prod-monsternames-static-content - managed by Terraform"
+ id = (known after apply)
+ name = "sudoblark-prod-monsternames-static-content-upload-policy"
+ path = "/prod/monsternames/iam/"
+ policy = (known after apply)
+ policy_id = (known after apply)
+ tags_all = (known after apply)
}
# module.monsternames.aws_s3_bucket.static_content will be created
+ resource "aws_s3_bucket" "static_content" {
+ acceleration_status = (known after apply)
+ acl = "private"
+ arn = (known after apply)
+ bucket = "sudoblark-prod-monsternames-static-content"
+ bucket_domain_name = (known after apply)
+ bucket_regional_domain_name = (known after apply)
+ force_destroy = false
+ hosted_zone_id = (known after apply)
+ id = (known after apply)
+ object_lock_enabled = (known after apply)
+ region = (known after apply)
+ request_payer = (known after apply)
+ tags = {
+ "account" = "sudoblark"
+ "environment" = "Prod"
+ "service" = "monsternames"
+ "source" = "github.com/sudoblark/terraform.aws"
}
+ tags_all = {
+ "account" = "sudoblark"
+ "environment" = "Prod"
+ "service" = "monsternames"
+ "source" = "github.com/sudoblark/terraform.aws"
}
+ website_domain = (known after apply)
+ website_endpoint = (known after apply)
+ object_lock_configuration {
+ object_lock_enabled = (known after apply)
+ rule {
+ default_retention {
+ days = (known after apply)
+ mode = (known after apply)
+ years = (known after apply)
}
}
}
+ server_side_encryption_configuration {
+ rule {
+ bucket_key_enabled = (known after apply)
+ apply_server_side_encryption_by_default {
+ kms_master_key_id = (known after apply)
+ sse_algorithm = (known after apply)
}
}
}
+ versioning {
+ enabled = (known after apply)
+ mfa_delete = (known after apply)
}
}
# module.monsternames.aws_s3_bucket_acl.static_content_acl will be created
+ resource "aws_s3_bucket_acl" "static_content_acl" {
+ acl = "private"
+ bucket = (known after apply)
+ id = (known after apply)
+ access_control_policy {
+ grant {
+ permission = (known after apply)
+ grantee {
+ display_name = (known after apply)
+ email_address = (known after apply)
+ id = (known after apply)
+ type = (known after apply)
+ uri = (known after apply)
}
}
+ owner {
+ display_name = (known after apply)
+ id = (known after apply)
}
}
}
# module.monsternames.aws_s3_bucket_ownership_controls.static_content_ownership will be created
+ resource "aws_s3_bucket_ownership_controls" "static_content_ownership" {
+ bucket = (known after apply)
+ id = (known after apply)
+ rule {
+ object_ownership = "BucketOwnerPreferred"
}
}
# module.monsternames.aws_s3_bucket_versioning.static_content_versioning will be created
+ resource "aws_s3_bucket_versioning" "static_content_versioning" {
+ bucket = (known after apply)
+ id = (known after apply)
+ versioning_configuration {
+ mfa_delete = (known after apply)
+ status = "Enabled"
}
}
Plan: 8 to add, 0 to change, 0 to destroy.
Thus, once applied we have our bucket in S3, with versioning, and our GitHub Actions user has the right IAM permissions to upload items as part of an automated workflow:
% aws s3 ls | grep monsternames
2023-08-16 21:29:18 sudoblark-prod-monsternames-static-content
OpenAPI Definition
With our S3 bucket in place, the next step is to create an OpenAPI definition. Reviewing the guidelines for naming projects from OpenAPI I decided upon monsternames.open-api
as a suitable repo name and got to work.
As I’m using this project to actually learn OpenAPI, and get hands-on with SwaggerUI, I used the Swagger Editor to craft my OpenAPI definition. After many hours of learning I managed to cobble together a definition for the monsternames API:
OpenAPI Definition
# Copyright (c) 2023, Sudoblark Ltd
#
# All rights reserved.
#
# This source code is licensed under the BSD 3 clause license found in the
# LICENSE file in the root directory of this source tree.
openapi: 3.0.1
info:
title: Monsternames - OpenAPI 3.0
description: |-
This is a relatively simple RESTAPI, based on the OpenAPI 3.0 specification, which generates pseudo-random names for
common fantasy monsters.
license:
name: BSD 3 clause
url: https://github.com/sudoblark/monsternames.open-api/blob/main/LICENSE.txt
version: 1.0.11
externalDocs:
description: Source API definition
url: https://github.com/sudoblark/monsternames.open-api/
servers:
- url: https://monsternames-api.com/api/v1.0
tags:
- name: goatmen
description: Names themed on cute fluffy animals
- name: goblin
description: Names themed on washed out wrestlers/average blokes
- name: ogre
description: Names themed on cavemen like speech
- name: orc
description: Names themed on {noun} + {silly moniker}
- name: skeleton
description: Names themed on 18th Century British Gentlemen
- name: troll
description: Names themed on a Scandinavian twist
paths:
/goatmen:
post:
summary: Add pseudo-random name options
security:
- ApiKeyAuth: [ApiKeyAuth]
tags:
- goatmen
requestBody:
description: Add first name
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/FirstNameModel"
examples:
first_name:
$ref: "#/components/examples/FirstName"
responses:
"200":
$ref: "#/components/responses/200PostFirstName"
"400":
$ref: "#/components/responses/400PostInvalidBody"
"409":
$ref: "#/components/responses/409PostAlreadyExists"
get:
summary: Retrieve a pseudo-random name
tags:
- goatmen
responses:
"200":
$ref: "#/components/responses/200GetFirstName"
/goblin:
post:
summary: Add pseudo-random name options
security:
- ApiKeyAuth: [ApiKeyAuth]
tags:
- goblin
requestBody:
description: Add first and/or last name
required: true
content:
application/json:
schema:
anyOf:
- $ref: "#/components/schemas/FirstNameModel"
- $ref: "#/components/schemas/LastNameModel"
- $ref: "#/components/schemas/FirstAndLastNameModel"
examples:
first_name:
$ref: "#/components/examples/FirstName"
last_name:
$ref: "#/components/examples/LastName"
first_and_last_name:
$ref: "#/components/examples/FirstAndLastName"
responses:
"200":
$ref: "#/components/responses/200PostFirstAndLastName"
"400":
$ref: "#/components/responses/400PostInvalidBody"
"409":
$ref: "#/components/responses/409PostAlreadyExists"
get:
summary: Retrieve a pseudo-random name
tags:
- goblin
responses:
"200":
$ref: "#/components/responses/200GetFirstAndLastName"
/ogre:
post:
summary: Add pseudo-random name options
security:
- ApiKeyAuth: [ApiKeyAuth]
tags:
- ogre
requestBody:
description: Add first name
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/FirstNameModel"
examples:
first_name:
$ref: "#/components/examples/FirstName"
responses:
"200":
$ref: "#/components/responses/200PostFirstName"
"400":
$ref: "#/components/responses/400PostInvalidBody"
"409":
$ref: "#/components/responses/409PostAlreadyExists"
get:
summary: Retrieve a pseudo-random name
tags:
- ogre
responses:
"200":
$ref: "#/components/responses/200GetFirstName"
/orc:
post:
summary: Add pseudo-random name options
security:
- ApiKeyAuth: [ApiKeyAuth]
tags:
- orc
requestBody:
description: Add first and/or last name
required: true
content:
application/json:
schema:
anyOf:
- $ref: "#/components/schemas/FirstNameModel"
- $ref: "#/components/schemas/LastNameModel"
- $ref: "#/components/schemas/FirstAndLastNameModel"
examples:
first_name:
$ref: "#/components/examples/FirstName"
last_name:
$ref: "#/components/examples/LastName"
first_and_last_name:
$ref: "#/components/examples/FirstAndLastName"
responses:
"200":
$ref: "#/components/responses/200PostFirstAndLastName"
"400":
$ref: "#/components/responses/400PostInvalidBody"
"409":
$ref: "#/components/responses/409PostAlreadyExists"
get:
summary: Retrieve a pseudo-random name
tags:
- orc
responses:
"200":
$ref: "#/components/responses/200GetFirstAndLastName"
/skeleton:
post:
summary: Add pseudo-random name options
security:
- ApiKeyAuth: [ApiKeyAuth]
tags:
- skeleton
requestBody:
description: Add first and/or last name
required: true
content:
application/json:
schema:
anyOf:
- $ref: "#/components/schemas/FirstNameModel"
- $ref: "#/components/schemas/LastNameModel"
- $ref: "#/components/schemas/FirstAndLastNameModel"
examples:
first_name:
$ref: "#/components/examples/FirstName"
last_name:
$ref: "#/components/examples/LastName"
first_and_last_name:
$ref: "#/components/examples/FirstAndLastName"
responses:
"200":
$ref: "#/components/responses/200PostFirstAndLastName"
"400":
$ref: "#/components/responses/400PostInvalidBody"
"409":
$ref: "#/components/responses/409PostAlreadyExists"
get:
summary: Retrieve a pseudo-random name
tags:
- skeleton
responses:
"200":
$ref: "#/components/responses/200GetFirstAndLastName"
/troll:
post:
summary: Add pseudo-random name options
security:
- ApiKeyAuth: [ApiKeyAuth]
tags:
- troll
requestBody:
description: Add first and/or last name
required: true
content:
application/json:
schema:
anyOf:
- $ref: "#/components/schemas/FirstNameModel"
- $ref: "#/components/schemas/LastNameModel"
- $ref: "#/components/schemas/FirstAndLastNameModel"
examples:
first_name:
$ref: "#/components/examples/FirstName"
last_name:
$ref: "#/components/examples/LastName"
first_and_last_name:
$ref: "#/components/examples/FirstAndLastName"
responses:
"200":
$ref: "#/components/responses/200PostFirstAndLastName"
"400":
$ref: "#/components/responses/400PostInvalidBody"
"409":
$ref: "#/components/responses/409PostAlreadyExists"
get:
summary: Retrieve a pseudo-random name
tags:
- troll
responses:
"200":
$ref: "#/components/responses/200GetFirstAndLastName"
components:
examples:
FirstName:
summary: Add a new first name
value:
first_name: Barnaby
LastName:
summary: Add a new last name
value:
last_name: The brass dragon
FirstAndLastName:
summary: Add a new first and last name
value:
first_name: Barnaby
last_name: The brass dragon
securitySchemes:
ApiKeyAuth:
type: apiKey
in: header
name: X-API-Key
schemas:
ErrorModel:
type: object
required:
- error
- message
properties:
error:
type: string
message:
type: string
FirstNameModel:
type: object
required:
- first_name
properties:
first_name:
type: string
LastNameModel:
type: object
required:
- last_name
properties:
last_name:
type: string
FirstAndLastNameModel:
type: object
required:
- first_name
- last_name
properties:
first_name:
type: string
last_name:
type: string
responses:
200GetFirstName:
description: "Successful response"
content:
application/json:
schema:
type: object
required:
- first_name
- full_name
properties:
first_name:
type: string
full_name:
type: string
200PostFirstName:
description: "Successful response"
content:
application/json:
schema:
type: object
required:
- first_name
- message
properties:
first_name:
type: string
message:
type: string
200GetFirstAndLastName:
description: "Successful response"
content:
application/json:
schema:
type: object
required:
- first_name
- last_name
- full_name
properties:
first_name:
type: string
last_name:
type: string
full_name:
type: string
200PostFirstAndLastName:
description: "Successful response"
content:
application/json:
schema:
type: object
required:
- message
properties:
first_name:
type: string
last_name:
type: string
message:
type: string
400PostInvalidBody:
description: "Invalid body in POST"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorModel"
409PostAlreadyExists:
description: "Duplicate record"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorModel"
The setup here is quite complex (at least for my first pass at OpenAPI), so I’ll explain it below in a (rather large) collapsable excert:
The heck is the OpenAPI definition doing?
The section of the YAML before the tags is fairly straight forward, as we’re just assigning metadata and references:
openapi: 3.0.1
info:
title: Monsternames - OpenAPI 3.0
description: |-
This is a relatively simple RESTAPI, based on the OpenAPI 3.0 specification, which generates pseudo-random names for
common fantasy monsters.
license:
name: BSD 3 clause
url: https://github.com/sudoblark/monsternames.open-api/blob/main/LICENSE.txt
version: 1.0.11
externalDocs:
description: Source API definition
url: https://github.com/sudoblark/monsternames.open-api/
servers:
- url: https://monsternames-api.com/api/v1.0
The tags section is, again, metadata but this time for OpenAPI itself; it’ll let us group individual operations rather than just present the user with a wall of text:
tags:
- name: goatmen
description: Names themed on cute fluffy animals
- name: goblin
description: Names themed on washed out wrestlers/average blokes
- name: ogre
description: Names themed on cavemen like speech
- name: orc
description: Names themed on <noun> + <silly moniker>
- name: skeleton
description: Names themed on 18th Century British Gentlemen
- name: troll
description: Names themed on a Scandinavian twist
Next we have a series of paths which actually define our API. If we examine the first endpoint we can see what’s going on:
paths:
/goatmen:
post:
summary: Add pseudo-random name options
security:
- ApiKeyAuth: [ApiKeyAuth]
tags:
- goatmen
requestBody:
description: Add first name
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/FirstNameModel"
examples:
first_name:
$ref: "#/components/examples/FirstName"
responses:
"200":
$ref: "#/components/responses/200PostFirstName"
"400":
$ref: "#/components/responses/400PostInvalidBody"
"409":
$ref: "#/components/responses/409PostAlreadyExists"
get:
summary: Retrieve a pseudo-random name
tags:
- goatmen
responses:
"200":
$ref: "#/components/responses/200GetFirstName"
Here you can see that:
-
We define a base path (
/goatmen
) for the API. This will get combined with theservers.url
value to get the full path to the endpoint -
For the endpoint, we’ve defined a
post
andget
method -
For the
post
method, we:-
Map through to a securitySchema (more on that later) to require authorisation for this endpoint
-
Use the tag to group this together with other methods
-
Explicitly define the allowed request body
-
Explicitly provide an example of a valid request
-
Explicitly define what responses a user can expect
-
-
Whilst the
get
method, being simpler, just defines the expected response
Thus, it’s in our components:
that we do the heavy lifting. Here we:
- Create reusable examples we can reference in our endpoint definitions, for example:
examples:
FirstName:
summary: Add a new first name
value:
first_name: Barnaby
- Define a security schema. This can be used the API/operation level. As per above - we just use it secure the POST endpoints:
securitySchemes:
ApiKeyAuth:
type: apiKey
in: header
name: X-API-Key
- Define data models, explicitly defining what data item(s) are required and their types. Which for this example are used across response components and to define allowed arguments within our requests bodies. For example:
schemas:
...
FirstNameModel:
type: object
required:
- first_name
properties:
first_name:
type: string
- Define response components, allowing us to define a set of common responses across the API and then reference in our endpoint definitions… rather than type it all out by hand every time:
responses:
200GetFirstName:
description: "Successful response"
content:
application/json:
schema:
type: object
required:
- first_name
- full_name
properties:
first_name:
type: string
full_name:
type: string
And that’s it. Those are the basic building blocks of the OpenAPI definition, which we’ve cobbled together to create an explicit definition of what the monsternames-api looks like.
Installing the swagger cli lets us validate, locally, we have a valid file:
/monsternames.open-api % ls -la
total 32
drwxr-xr-x 8 bclark staff 256 Jul 29 15:58 .
drwxr-xr-x@ 49 bclark staff 1568 Jul 29 13:01 ..
drwxr-xr-x 12 bclark staff 384 Jul 29 15:47 .git
drwxr-xr-x 10 bclark staff 320 Jul 29 16:00 .idea
-rw-r--r-- 1 bclark staff 1490 Jul 29 13:13 LICENSE.txt
-rw-r--r-- 1 bclark staff 2505 Jul 29 15:56 README.md
drwxr-xr-x 3 bclark staff 96 Jul 29 13:41 docs
-rw-r--r-- 1 bclark staff 6444 Jul 29 15:58 open-api.yaml
/monsternames.open-api % npm install -g @apidevtools/swagger-cli
/monsternames.open-api % swagger-cli validate open-api.yaml
open-api.yaml is valid
And then using the swagger-ui docker image we can stand-up a local SwaggerUI and confirm it works with the old (soon to be replaced) API:
docker run -it \
--mount type=bind,source="$(pwd)"/open-api.yaml,target=/usr/share/nginx/html/open-api.yaml \
-p 8080:8080 \
-e API_URL=open-api.yaml \
swaggerapi/swagger-ui
CI/CD with GitHub Actions
Firstly, I wanted to define exactly what the CI/CD should look like. Thankfully, mermaid is a wonderful language that lets you treat diagrams as code… using relatively plain-english to define your logical workflows and get a pretty result. So, I added a CI/CD section to the readme:
<!-- CI/CD -->
## CI/CD setup
```mermaid
flowchart TD
subgraph open_pull_request[Open Pull Request]
validator[Validate Swagger]
end
subgraph on_merge_to_main[On merge to main]
github_deployment[Update GitHub deployment]
deploy_artefact[Deploy to S3]
deploy_artefact --> github_deployment
end
And a pretty diagram was born, letting me have a discrete target to focus on

Thankfully, the rich ecosystem for GitHub actions made this quite easy.
On pull request
A commit on an open pull request validates our Swagger and posts the results to our pull request:
name: OpenAPI checks on pull request
env:
OPEN_API_YAML_PATH: open-api.yaml
on: [pull_request]
jobs:
validation:
name: OpenAPI validate
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v3
- name: Swagger-cli validate
uses: mpetrunic/swagger-cli-action@v1.0.0
with:
command: "validate ${{ env.OPEN_API_YAML_PATH }}"

On merge to main
Initially this step was a lot more complicating, with permutations of:
-
Compiling a static SwaggerUI website using in-built GitHub actions
-
Attempting to hand-craft a SwaggerUI website via usage of its swagger-dist npm module
-
etc…
But in the philosophy of KISS (Keep it simple, stupid!) I just made a basic index.html
file using some CDNs to have the Internet do the magic for me:
<!-- HTML for static distribution bundle build -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Swagger UI</title>
<link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@3/swagger-ui.css" >
<style>
html
{
box-sizing: border-box;
overflow: -moz-scrollbars-vertical;
overflow-y: scroll;
}
*,
*:before,
*:after
{
box-sizing: inherit;
}
body
{
margin:0;
background: #fafafa;
}
.errors-wrapper {
display: none !IMPORTANT;
}
</style>
</head>
<body>
<div id="swagger-ui"></div>
https://unpkg.com/swagger-ui-dist@3/swagger-ui-bundle.js
https://unpkg.com/swagger-ui-dist@3/swagger-ui-standalone-preset.js <script>
window.onload = function() {
// Begin Swagger UI call region
const ui = SwaggerUIBundle({
"dom_id": "#swagger-ui",
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl
],
layout: "StandaloneLayout",
validatorUrl: "https://validator.swagger.io/validator",
urls: [
{url: "https://raw.githubusercontent.com/sudoblark/monsternames.open-api/main/open-api.yaml", name: "monsternames"}
],
defaultModelsExpandDepth: -1,
"urls.primaryName": "monsternames"
})
window.ui = ui
}
</script>
</body>
</html>
Thus, a merge to main triggers: marking a GitHub deployment (deleting any old ones present), pushing our index.html
to the S3 bucket we defined earlier, and then updating our GitHub deployment with an appropriate status:
name: Publish OpenAPI to S3 on merge to main
env:
AWS_ACCESS_KEY_ID: ${{ secrets.SUDOBLARK_AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.SUDOBLARK_AWS_ACCESS_KEY_VALUE }}
AWS_DEFAULT_REGION: eu-west-2
MONSTERNAMES_OPENAPI_STATIC_CONTENT_BUCKET: sudoblark-prod-monsternames-static-content
ENVIRONMENT: Prod
# Automatically generated token unique to this repo per workflow execution
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
on:
workflow_dispatch:
push:
branches:
- main
paths-ignore:
- '.github/**'
- 'LICENSE.txt'
permissions:
contents: read
deployments: write
jobs:
deploy:
name: Deploy SwaggerUI
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v3
- name: Delete GitHub deployments
uses: strumwolf/delete-deployment-environment@v2.2.3
with:
token: ${{ secrets.GITHUB_TOKEN }}
environment: ${{ env.ENVIRONMENT }}
onlyRemoveDeployments: true
- name: Create GitHub deployment
uses: chrnorm/deployment-action@releases/v1
id: deployment
with:
token: ${{ secrets.GITHUB_TOKEN}}
description: 'monsternames OpenAPI SwaggerUI'
environment: ${{ env.ENVIRONMENT }}
- name: Sync artefact to S3
uses: jakejarvis/s3-sync-action@master
with:
# As per https://github.com/jakejarvis/s3-sync-action/issues/26
args: --follow-symlinks --delete --exclude '*' --include 'index.html'
env:
AWS_S3_BUCKET: ${{ env.MONSTERNAMES_OPENAPI_STATIC_CONTENT_BUCKET }}
AWS_ACCESS_KEY_ID: ${{ env.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ env.AWS_SECRET_ACCESS_KEY }}
AWS_REGION: ${{ env.AWS_DEFAULT_REGION }}
- name: Update deployment status (success)
if: success()
uses: chrnorm/deployment-status@releases/v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
target_url: https://monsternames.sudoblark.com
state: 'success'
deployment_id: ${{ steps.deployment.outputs.deployment_id }}
- name: Update deployment status (failed)
if: failure()
uses: chrnorm/deployment-status@releases/v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
target_url: https://monsternames.sudoblark.com
state: 'failure'
deployment_id: ${{ steps.deployment.outputs.deployment_id }}
Simple and to the point. Plus, we get a pretty little deployment indicator on GitHub:

Note: the eagle eyed of you may have spotted workflow_dispatch
as an allowable trigger. This is solely for debugging, and to allow manual deployments.
API Gateway
With:
-
An s3 bucket to host our SwaggerUI
-
An OpenAPI definition which follows GitOps workflows to deploy to said bucket automatically
The only thing left is to make the UI available to users. We could make this available via a static website, but then we’d need to:
-
Make objects publicly readable
-
Have complicated DNS
So instead, I’m placing this behind an API Gateway such that:
-
monsternames.sudoblark.com directs to this general-purpose gateway
-
The gateway responds to root requests via a redirect to the S3 bucket
-
When we refactor the actual API, it can hang off the same API Gateway for a consistent naming schema
So lets begin.
Terraform module refactor
To accommodate the new API Gateway, our Terraform module needed a few changes.
How many changes you ask?
% git status
On branch feature/mosnternames-api-static-content
Your branch is up to date with 'origin/feature/mosnternames-api-static-content'.
Changes to be committed:
(use "git restore --staged <file>..." to unstage)
new file: modules/monsternames/aws_api_gateway.tf
modified: modules/monsternames/aws_iam_group.tf
modified: modules/monsternames/aws_iam_policy.tf
new file: modules/monsternames/aws_iam_role.tf
new file: modules/monsternames/swagger_ui.tf
modified: modules/monsternames/variables.tf
A few. Lets take a peak.
aws_api_gateway.tf
Here we simply create a new API Gateway for all of our methods etc to hang off of.
resource "aws_api_gateway_rest_api" "api_gateway" {
name = lower("${var.account}-${var.environment}-${local.service_name}-api-gateway")
description = "API Gatway for ${local.service_name} in ${var.environment} for ${var.account} - managed by Terraform"
}
aws_iam_group.tf
Here, the only change is to ensure our policy attachment has the correct syntax to reference the upload policy made as part of a for-each (more on that next)
resource "aws_iam_group" "static_content_upload" {
name = "${local.static_bucket_name}-static-content-upload-group"
path = "${local.base_path}iam/"
}
resource "aws_iam_group_membership" "static_content_upload" {
group = aws_iam_group.static_content_upload.id
name = "${aws_iam_group.static_content_upload.name}-membership"
users = var.static_content_upload_users
}
resource "aws_iam_group_policy_attachment" "attachments" {
group = aws_iam_group.static_content_upload.id
policy_arn = aws_iam_policy.policies["upload"].arn
}
aws_iam_policy.tf
Here we do two things:
-
Refactor the existing policies to be created as part of a for-each to allow ease of management
-
Add a new
read
policy for a new role we’ll create later for the API Gateway to assume… so it can actually read bucket contents
locals {
iam_policies = {
"read" = {
name: "${local.static_bucket_name}-read-policy"
description: "Policy which allows read access to ${local.static_bucket_name} - managed by Terraform"
policy: data.aws_iam_policy_document.static_content_read.json
},
"upload" = {
name: "${local.static_bucket_name}-upload-policy"
description: "Policy which allows upload access to ${local.static_bucket_name} - managed by Terraform"
policy: data.aws_iam_policy_document.static_content_upload.json
}
}
}
data "aws_iam_policy_document" "static_content_upload" {
statement {
sid = "1"
effect = "Allow"
actions = [
"s3:ListBucket"
]
resources = [
"arn:aws:s3:::${aws_s3_bucket.static_content.id}"
]
}
statement {
sid = "2"
effect = "Allow"
actions = [
"s3:Put*",
"s3:Get*",
"s3:DeleteObject"
]
resources = [
"arn:aws:s3:::${aws_s3_bucket.static_content.id}/*"
]
}
}
data "aws_iam_policy_document" "static_content_read" {
statement {
sid = "1"
effect = "Allow"
actions = [
"s3:ListBucket"
]
resources = [
"arn:aws:s3:::${aws_s3_bucket.static_content.id}"
]
}
statement {
sid = "2"
effect = "Allow"
actions = [
"s3:Get*"
]
resources = [
"arn:aws:s3:::${aws_s3_bucket.static_content.id}/*"
]
}
}
resource "aws_iam_policy" "policies" {
for_each = local.iam_policies
name = each.value["name"]
path = "${local.base_path}iam/"
description = each.value["description"]
policy = each.value["policy"]
tags = local.default_tags
}
aws_iam_role.tf
Here we create a new role - ensuring the assumption policy allows the API Gateway to assume it - which will grant the API Gateway read access to the s3 bucket.
Later on we can use this role to fine-tune permissions for lambdas etc if we hang other endpoints off of the gateway.
data "aws_iam_policy_document" "api_gateway_assumption" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["apigateway.amazonaws.com"]
}
}
}
resource "aws_iam_role" "api_gateway" {
name = lower("${var.account}-${var.environment}-${local.service_name}-api-gateway")
assume_role_policy = data.aws_iam_policy_document.api_gateway_assumption.json
tags = local.default_tags
}
resource "aws_iam_role_policy_attachment" "api_gateway_read" {
role = aws_iam_role.api_gateway.name
policy_arn = aws_iam_policy.policies["read"].arn
}
swagger_ui.tf
This is in effect the meat and potatoes of what we’re trying to achieve. It’s quite verbose, so maybe the visualisation the AWS console provides may help us understand what’s going on:

-
The method request is our
aws_api_gateway_method
and thus defines permissible methods allowed on the resource (in this case the root\
path) and if auth is required or not -
The integration request is our
aws_api_gateway_integration
and thus defines what the API Gateway should actually do if it received a valid method. In this instance, we’ve:-
Given the full path to the
index.html
file in our S3 bucket and told it toGET
this item -
Told the API Gateway to use the IAM role from earlier via the
credentials
parameter, thus ensuring it assumptions this role to perform the action
-
-
The integration response is our
`aws_api_gateway_integration_response`
which defines a the response from the integration request we’ve setup, here we’re explicitly defining that the header ofContent-Type
must betext/html
to ensure a browser renders this correctly -
The method response is our
aws_api_gateway_method_response
which defines a valid response from the integration, here we state that a header ofContent-Type
must be provided for the 200 response to be valid
resource "aws_api_gateway_method" "swagger_ui" {
rest_api_id = aws_api_gateway_rest_api.api_gateway.id
resource_id = aws_api_gateway_rest_api.api_gateway.root_resource_id
http_method = "GET"
authorization = "NONE"
}
resource "aws_api_gateway_integration" "swagger_ui" {
rest_api_id = aws_api_gateway_rest_api.api_gateway.id
resource_id = aws_api_gateway_rest_api.api_gateway.root_resource_id
http_method = aws_api_gateway_method.swagger_ui.http_method
# How the integration will interact with the backend of the resource
integration_http_method = "GET"
credentials = aws_iam_role.api_gateway.arn
type = "AWS"
# See https://docs.aws.amazon.com/apigateway/api-reference/resource/integration/
uri = "arn:aws:apigateway:${var.aws_region}:s3:path//${aws_s3_bucket.static_content.id}/index.html"
}
resource "aws_api_gateway_method_response" "swagger_ui_200" {
rest_api_id = aws_api_gateway_rest_api.api_gateway.id
resource_id = aws_api_gateway_rest_api.api_gateway.root_resource_id
http_method = aws_api_gateway_method.swagger_ui.http_method
status_code = "200"
response_parameters = {
"method.response.header.Content-Type" = true
}
}
resource "aws_api_gateway_integration_response" "swagger_ui_200" {
rest_api_id = aws_api_gateway_rest_api.api_gateway.id
resource_id = aws_api_gateway_rest_api.api_gateway.root_resource_id
http_method = aws_api_gateway_method.swagger_ui.http_method
status_code = "200"
response_parameters = {
"method.response.header.Content-Type" = "'text/html'"
}
}
variables.tf
Here we’re stating that aws_region
may be passed through to the module instantiation to define the S3 endpoint that our API Gateway will query.
variable "environment" {
description = "The environment within which we're instantiating this module."
type = string
validation {
condition = anytrue([
var.environment == "Prod",
var.environment == "Stage",
var.environment == "Dev"
])
error_message = "Only the following environments are supported: Prod/Stage/Dev"
}
}
variable "account" {
description = "The AWS account within which we're instantiating this module."
type = string
}
variable "static_content_versioning" {
description = "Whether to enable versioning on the static content S3 bucket."
type = string
default = "Enabled"
validation {
condition = anytrue([
var.static_content_versioning == "Enabled",
var.static_content_versioning == "Suspended",
var.static_content_versioning == "Disabled"
])
error_message = "Only the following environments are supported: Enabled/Suspended/Disabled"
}
}
variable "source_repository" {
description = "Terraform repository managing this module, used for tagging purposes."
type = string
default = "terraform.aws"
}
variable "static_content_upload_users" {
description = "IAM users whom should be allowed to assume static content upload role"
type = list(string)
default = []
}
variable "aws_region" {
description = "The AWS region we are deploying the service into, used for API Gateway routing"
type = string
default = "eu-west-2"
validation {
condition = contains([
"eu-west-2"
], var.aws_region)
error_message = format("Invalid region provided, supported regions are as follows: /%s",
jsonencode([
"eu-west-2"
]))
}
}
Deployment
Once deployed with terraform - with no code changes needed in call to the underlying module - the API Gateway appears in AWS. All we then do is simply mark a deployment to make this live and we’re done. Lets see how it looks.
Pretty good I’d say. Just a few small tweaks to make.
DNS
That URL is a bit rubbish. So I just changed it to point to https://monsternames.sudoblark.com which seems nicer.
But how I hear you ask? Well as my DNS is (not yet) managed by AWS just some simple GUI changes:
-
Setting up a custom domain name with DNS validation as per AWS’ own documentation
-
Registering an SSL certificate with AWS’ own documentation
-
Associating the custom domain name with our API Gateway for monsternames
-
Setting up at the
monsternames
subdomain forsudoblark.com
to point to the custom domain name’s API Gateway via a CNAME record -
Switching off the default endpoint for the base monsternames API Gateway
In a future blog post I might go to the effort of migrating my DNS over to Route53, in which case I will most certainly update this article to cover how to do these steps via Terraform.
The Result
Going to https://monsternames.sudoblark.com reveals…
It all works as expected. How lovely is that.
Conclusion
So that’s it, we’ve managed to successfully:
-
Define an OpenAPI definition for monsternames and then use this to generate a static SwaggerUI
-
Push this SwaggerUI to S3 in a GitOps fashion
-
Standup an API Gateway in front of the S3 bucket to act as a proxy for our SwaggerUI
-
Hang all of this off of the monsternames.sudoblark.com subdomain, thus providing a consistent naming schema we can use when we refactor the actual API itself
I hope you’ve learned something new from this, or otherwise just enjoyed my ramblings. I’m certainly happy the refactor of monsternames is getting legs, and am now very excited to begin the hard-work of completely revamping the backend API itself.