Campsite Booking API : Revisited
Originally the Campsite Booking API project was a coding challenge for a developer position I applied to at Upgrade Inc.. Back then, in 2018, the coding challenge was followed by a series of interviews, but in the end, I didn’t receive an offer. I recently revisited this project, and in this post, I go over the various improvements and new features that have been implemented.
The initial task was to develop a Spring Boot-based REST API that meets the system requirements outlined in this README. In 2019, I switched from Java software development to DevOps and worked in this field for a year and a half. During this period, I mainly developed and maintained CI/CD pipelines using Jenkins and Azure DevOps. So when I came back to this project two years later, the main goal was to complement it from a DevOps perspective, particularly containerization and CI/CD.
Let’s look at what was accomplished in more detail. The source code is available here.
SonarCloud
Whenever I code, either work or my pet projects, I always strive to produce clean, high-quality code. One of the tools I use to write better and safer code is the SonarLint plugin, which helps detect and fix code smells, bugs, and vulnerabilities while working in IntelliJ IDEA.
In case you want to enable continuous code inspections within your development workflow, you can use on-premise SonarQube. Previously, the on-premise option was the only option from SonarSource. But they recently offered a cloud-based option called SonarCloud, which is free for public projects. So I decided to enrich the project’s continuous integration workflow with SonarCloud scanning.
For an initial GitHub integration with SonarCloud, you can follow this getting started guide. To integrate this Maven-based project, I made the following changes to the pom.xml file:
- Added SonarCloud configuration properties:
<properties>
<sonar.organization>igor-baiborodine-github</sonar.organization>
<sonar.projectKey>igor-baiborodine_campsite-booking</sonar.projectKey>
<sonar.host.url>https://sonarcloud.io</sonar.host.url>
<sonar.inclusions>src/main/java/**,src/main/resources/**</sonar.inclusions>
<sonar.issue.ignore.block>generated</sonar.issue.ignore.block>
<sonar.issue.ignore.block.generated.beginBlockRegexp>@Generated</sonar.issue.ignore.block.generated.beginBlockRegexp>
<sonar.issue.ignore.block.generated.endBlockRegexp/>
<sonar.coverage.jacoco.xmlReportPaths>${project.build.directory}/site/jacoco/jacoco.xml</sonar.coverage.jacoco.xmlReportPaths>
</properties>
To exclude certain Java classes (like DTOs or model classes) from scanning, you can use a set of sonar.issue.ignore.block
properties. Excluded classes must be annotated with a Generated
annotation, for example, lombok.Generated
, javax.annotation.processing.Generated
etc.
- Added
coverage
profile based on the JaCoCo Maven build plugin:
<profiles>
<profile>
<id>coverage</id>
<build>
<plugins>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>0.8.6</version>
<executions>
<execution>
<id>prepare-agent</id>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<goals>
<goal>report</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</profile>
</profiles>
After generating an access token in the Security
section of My Account
at sonarcloud.io,
you can launch the scanning with the following command:
$ mvn clean verify sonar:sonar -Dsonar.login=<SONAR_TOKEN> -Pcoverage
Code Enhancements
The SonarCloud scanning revealed some code smells and vulnerabilities.
After fixing
code smells, I addressed the main vulnerability, which was detected in the BookingController
class: persistent
entities were used as arguments of @RequestMapping
methods. I overlooked this during the initial implementation, and
it was corrected
by replacing the Booking
persistent entity with the BookingDTO
object. Also,
I improved the
test coverage, which is now
at 89.1%.
Another significant improvement was UUID’s introduction for the Booking
entity while keeping the database’s ID. The
main advantage here is that entity’s unique ID can be created without connecting to the database. You can read more on
the pros and cons of using UUID vs. database ID in this
Stackoverflow thread
.
Additionally, the following adjustments were made:
- Upgraded Spring Boot and other dependencies
- Complemented the
Booking
entity with date auditing fields - Switched to OpenAPI v3
Containerization
Since deploying applications, especially microservices, using containers has become the de facto standard, I added Dockerfile and Docker Compose files.
Dockerfile
I used a multi-stage build approach to implement
the Dockerfile. With this approach, a
Dockerfile consists of different sections or stages, each of which refers to its own base image.
In my case, the Dockerfile has two stages. The first stage, or builder, is based on the maven: 3-jdk-11
Docker image.
At this point, the project is built and packaged into a JAR artifact using the mvn package
command.
FROM maven:3-jdk-11 AS builder
WORKDIR /usr/src/app
COPY . .
RUN mvn --batch-mode package -DskipTests -DskipITs; \
mv /usr/src/app/target/campsite-booking-$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout).jar \
/usr/src/app/target/app.jar
The second stage is the stage when the actual Campsite Booking API Docker image is built. It’s based on OpenJDK’s buster-slim
image.
FROM openjdk:11-jre-slim
ENV APP_HOME /opt/campsite
ENV APP_USER campsite
ENV APP_GROUP campsite
RUN groupadd ${APP_GROUP}; \
useradd -g ${APP_GROUP} ${APP_USER}
RUN set -ex; \
apt-get update; \
apt-get install -y --no-install-recommends \
# su tool for easy step-down from root
gosu; \
rm -rf /var/lib/apt/lists/*; \
gosu nobody true
COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /usr/src/app/target/app.jar ${APP_HOME}/app.jar
COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod a+x /usr/local/bin/docker-entrypoint.sh
WORKDIR ${APP_HOME}
ENTRYPOINT ["docker-entrypoint.sh"]
EXPOSE 8080
CMD ["bash", "-c", "java -jar $APP_HOME/app.jar"]
When building the image, the user that will be used to run the image defaults to root
. The step-down from root
to a non-privileged user campsite
is done during the execution of the docker-entrypoint.sh.
if [[ "$3" == java* && "$(id -u)" = '0' ]]; then
echo "Switching user from root to $APP_USER..."
chown -R "$APP_USER:$APP_GROUP" "$APP_HOME"
exec gosu "$APP_USER" "$@"
fi
Docker Compose
The Docker Compose file consists
of two services. The db
service is based on the mysql:5.7
Docker image. Since its configuration contains
the ./mysql/initdb.d:/docker-entrypoint-initdb.d
volume definition, the mysql/initdb.d/init-campsite-db.sql
file
will be executed when a container starts for the first time. This script creates the campsite
database and
the campsite
user with all necessary privileges. The database data is stored outside of the container since
the /var/lib/mysql
directory is mapped to the named volume db-data
defined in the volumes
section.
db:
image: mysql:5.7
environment:
# Setting this variable to yes is not recommended
# unless you really know what you are doing
- MYSQL_ALLOW_EMPTY_PASSWORD=yes
ports:
- "3316:3306"
- "33070:33060"
volumes:
- db-data:/var/lib/mysql
- ./mysql/conf.d:/etc/mysql/conf.d
- ./mysql/initdb.d:/docker-entrypoint-initdb.d
The api
service is based on the build context, which is the project’s root. Alternatively, you can use
an image published on Docker Hub, for
example, ibaiborodine/campsite-booking:latest
. To do this, comment out the build
config option and uncomment
the image
option.
api:
build: .
# image: ibaiborodine/campsite-booking:latest
depends_on:
- db
environment:
# sleep for 20 seconds while the database is being initialized
- WAIT_FOR_DB=20
- SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/campsite?useUnicode=true
ports:
- "80:8080"
Since the depends_on
option does not wait for the db
service to be ready, that is, when the campsite
database
and campsite
user are initialized, I added a delay that is configurable through the WAIT_FOR_DB
environment
variable. The wait is triggered by the following condition in the docker-entrypoint.sh:
if [[ -n "$WAIT_FOR_DB" ]]; then
echo "Sleeping for $WAIT_FOR_DB seconds while the database is being initialized..."
sleep "$WAIT_FOR_DB"
fi
CI/CD Workflows
The original CI workflow was implemented using Travis CI, a hosted continuous integration
service. This workflow was relatively straightforward and only consisted of executing the mvn clean integration-test
command triggered by any commit on the master
branch.
language: java
jdk:
- openjdk11
script: mvn clean integration-test
notifications:
email: false
So far, I have had experience developing pipelines using Jenkins, Travis CI, Azure DevOps, and Bitbucket Pipelines. Since 2019, GitHub offers support for full-fledged CI/CD pipelines, free for public repositories. Therefore, I decided to use GitHub Actions for re-implementing the project’s CI/CD.
For this project, I adopted the trunk-based development as a source-control branching model. With this model, all development is done either by committing directly to the trunk or through short-lived feature branches merged to the trunk using pull requests combined with the automated builds. Also, the master branch is meant to be deployable at any commit. With all this in mind, I developed the following GitHub Action workflows: Build Master Branch , Build on Pull Request, and Perform Release.
Pull Request
This is an automatic workflow that starts whenever a new pull request is made to the master branch. It contains two jobs: Unit & Integration Tests and SonarCloud Scan. The SonarCloud Scan job is dependent on the successful completion of the Unit & Integration Tests job.
name: Build on Pull Request
on:
pull_request:
branches:
- 'master'
jobs:
test:
name: Unit & Integration Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: Cache local Maven repository
uses: actions/cache@v2
with:
path: ~/.m2/repository
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
restore-keys: ${{ runner.os }}-maven-
- name: Set up JDK 11
uses: actions/setup-java@v1
with:
java-version: 11
- name: Run Maven Verify
run: mvn -B clean verify
sonar:
name: SonarCloud Scan
runs-on: ubuntu-latest
needs: [ test ]
steps:
- uses: actions/checkout@v1
- name: Set up JDK 11
uses: actions/setup-java@v1
with:
java-version: 11
- name: Run SonarCloud scan
run: mvn -B clean verify sonar:sonar -Pcoverage -Dsonar.login=${{ secrets.SONAR_TOKEN }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Master Branch
This is also an automatic workflow, and it runs whenever a commit is pushed to the master branch. It contains the Snapshot Publishing job, which will package a snapshot JAR and upload it to the GitHub Packages, and the SonarCloud job.
name: Build Master Branch
on:
push:
branches:
- 'master'
jobs:
jobs:
sonar:
name: SonarCloud Scan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: Set up JDK 11
uses: actions/setup-java@v1
with:
java-version: 11
- name: Run SonarCloud scan
run: mvn -B clean verify sonar:sonar -Pcoverage -Dsonar.login=${{ secrets.SONAR_TOKEN }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
snapshot:
name: Snapshot Publishing
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: Set up JDK 11
uses: actions/setup-java@v1
with:
java-version: 11
- name: Publish snapshot on GitHub Packages
run: mvn -B clean deploy -DskipTests
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Release
This is a workflow that is triggered manually whenever the code in the master branch is ready to be released. It contains two jobs: Maven Release and Docker Image. The Maven Release job performs a release and uploads produced release JAR to GitHub Packages. Its successful completion is a prerequisite for the Docker Image job’s subsequent execution when the Docker image is built and uploaded to Docker Hub.
name: Perform Release
on:
workflow_dispatch:
inputs:
releaseVersion:
description: Release Version
required: true
env:
IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/campsite-booking
IMAGE_TAG: ${{ github.event.inputs.releaseVersion }}
jobs:
maven_release:
name: Maven Release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Cache local Maven repository
uses: actions/cache@v2
with:
path: ~/.m2/repository
key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
restore-keys: ${{ runner.os }}-maven-
- name: Set up JDK 11
uses: actions/setup-java@v1
with:
java-version: 11
server-id: github
- name: Configure Git user
run: |
git config user.email "actions@github.com"
git config user.name "GitHub Actions"
- name: Perform release & publish artifacts
run: ./mvnw -B release:prepare release:perform -DreleaseVersion=${{ github.event.inputs.releaseVersion }} -DskipTests -DskipITs
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
docker_image:
name: Docker Image
runs-on: ubuntu-latest
needs: [ maven_release ]
steps:
- uses: actions/checkout@v2
with:
ref: v${{ github.event.inputs.releaseVersion }}
- name: Build image
run: |
docker build . --file Dockerfile --tag $IMAGE_NAME:$IMAGE_TAG
docker tag $IMAGE_NAME:$IMAGE_TAG $IMAGE_NAME:latest
- name: Log into registry
run: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
- name: Push image
run: |
docker push $IMAGE_NAME:$IMAGE_TAG
docker push $IMAGE_NAME:latest
The Release Version
parameter value should be provided before executing this workflow:
And in conclusion, I want to say that coming back to the project after two years and working on it again was quite refreshing and enjoyable. After improving the existing code base and adding new features such as containerization and CI/CD, the project is now more robust and complete.
Continue reading the series [“Campsite Booking API (Java)”] (/series/campsite-booking-api-java/):