on
Automating my gym bookings with a serverless assistant
My assistant as imagined by GPT-4o
Table of contents
- Foreword
- Traffic analysis
- Requirements
- Implementation pillars
- Architecture overview
- Show me the code!
- Final thoughts
Foreword
Have you ever been subscribed to a gym and felt frustrated by the difficulty of booking a class? That’s exactly how I felt when I decided to re-subscribe last year.
To overcome this frustration, I decided to build a virtual booking assistant to help me never miss my favorite classes again. What started as a late-night coding experiment quickly evolved: As I added feature after feature, I realized that this project wasn’t just a practical solution for me but especially a fun and engaging challenge. This blog post will take you through my coding journey.
To the best of my knowledge, and after checking my gym's contract, I don't think the tool I've built is ultimately against my gym's rules. However, using a script to book my classes does not seem fair, especially if the targeted classes are often overbooked (which is not my case anyway). Therefore, I’m not suggesting readers emulate what I’ve built. I’m simply sharing my experience because I find it interesting from a software engineering standpoint, hoping it might inspire something even better!
Traffic analysis
Before starting any kind of work I had to first understand how my gym mobile application exchanged data with the API server. To do this, I simulated a Man-In-The-Middle attack on my home network, using mitmproxy as an HTTPS proxy running on my laptop.
Simulated MITM setup
Once routed all my Iphone internet traffic through my local proxy and installed Mitmproxy's CA certificate, I was able to see in plain all the HTTPS traffic generated by my gym mobile application, while using it. After fine-tuning the proxy filters I could easily extract the API interactions I was mostly interested in: login, class search, class book and un-book...
Request intercepted on Mitmproxy CLI
Once identified the interested API interactions, I've exported them as a set of httpie requests, so that I can familiarize with those. I ultimately also discoverd an OpenAPI spec, but that turned out to be neither accurate nor complete.
That being said, with the help of mitmproxy I was able to collect all the information I need to start playing with the API and understand how to automate the API calls that were required to book a class. I could then start to envision the next steps!
Requirements
What I wanted build was fairly simple: a daemon running on a schedule that would have booked one or more classes matching some configured search criteria. Once booked a class, the daemon should have informed about the completed booking via an SMS delivered to my mobile number.
As per non-functional requirements, I just wanted the solution to be as simple as it could be and with serverless setup. As I never had the chance to get my hands dirty with a bunch of fully managed AWS services that I could have helped this time, I decided to use AWS Lambda and EventBridge as the backbone of my solution. After some minimal investigation I realized that a few more secondary services like Secrets Manager, Systems manager and Simple Notification Services (SNS) would have fully satisfied my needs.
As for the infrastructure, I was not really interested in coding and versioning it (eg. with the help of Terraform or similar). For this reason, the solution that I will present shortly required a bit of manual setup on the AWS console.
Implementation pillars
Let's shed a light on the main implementation choices I did.
Choice of language
Choosing what language to use for my project was by far the most difficult decision to take, due to a mix of language-specific oddities and as a consequence of having chosen Lambda as container for my code.
When writing the first lines of code I had opted for pure Javascript, as I knew that the support for NodeJS on Lambda was good. Also, I did not really need a strongly-typed language and wanted something that was a good fit for scripting.
But then... When I decided to do embark on a slightly more complex journey, I thought that using a strongly-typed language would have helped me not hallucinating. So I went for Typescript: I'm definitely not an expert in the field, but I did not want to rewrite completely the code I had already written.
Very sadly, I quickly ran into several obstacles that led me to revert to plain JavaScript. I would have expected that creating a new project using the latest version of NodeJs and a well-known test framework like Mocha would be straightforward and take only a few seconds. However, my assumption turned out to be very wrong, as also testified by the many threads available online of people struggling even with the most basic setup. After some head-banging and also thanks to some previous art kindly shared on Github, I finally managed to get what I needed. That sense of relief did not last long though: Typescript is not natively supported by Lambda and trying to understand how to compile and package my code, so that I could leverage Lambda layers really got me frustrated, at the point that I ultimately decided to go back to pure Javascript.
Lesson learned: when planning to use Lambda take the time needed to evaluate your candidate language upfront!
No Lambda, no party
As mentioned, I've decided to go with AWS Lambda functions to host my code. More precisely, I split my code into 2 different Lambda functions: one for scanning the available classes and one for booking them. I decided to segregate responsibilities into 2 different units to keep the code more readable, and also because the triggers for the 2 actions (searching and booking classes) were very different events in my mind: scanning would have worked on a schedule, booking would have been a one-off, on-demand, task.
Organizing my code into 2 different Lambda functions pushed me to think how to not duplicate dependencies, and utilities (like the HTTP client to interact with the Gym API, or other logging related functions) on the 2 deployments. Lambda layers came to help, although not without headaches as we'll see later.
Standing on the shoulders of EventBridge
I decided to rely on events to let the 2 separate functions interoperate, for multiple reasons:
- I wanted to keep coupling limited;
- I wanted something easy to observe/monitor, and I thought that events would have helped achieving that;
- I wanted something that I could easily extend.
As I've learned while preparing for the AWS Certified Solutions Architect exam and realized even more while doing this small project, there are many ways to implement event-based solutions on AWS.
However, in my case I didn't need just a message broker:
- I needed the class search task to be triggered on a regular schedule;
- I needed a way to dynamically schedule one-off runs for classes that should have been booked at a future date compared to the one when running the class search.
As I've learned, both requirements are greatly fulfilled by EventBridge *
Finally, with the help of an extra rule there (and 0 code!) I could connect a Simple Notification Service (SNS) topic and deliver an SMS to my mobile number, with a customized message and the information of the class just booked.
* there's just one caveat: the scheduler on EventBridge has a 60 seconds precision! This means that if you're scheduling something at 3:15:00PM there's a chance (in the worst case) that it gets fired at 3:15:59PM. In my case, I decided that this was an acceptable caveat.
Configuration and secrets management
Both the scan and book functions needed configurations - some of which sensitive.
I decided to go with Systems manager for non-sensitive and generic values. The code would have looked them up on AWS with a known key (eg. facilityId
).
For sensitive configurations I've instead used Secrets manager. The key difference with regular configuration was that they were user-specific (they included personal credentials to login on the Gym's API) and I wanted to potentially easily reuse my solution with different logins. So I've defined a user-specific secret on Secrets manager identified by a user alias (eg. andrea
) that would have been referenced by the code hosted on the Lambda functions.
Events
After some thinking I decided to define 3 events:
ClassBookingAvailable
ClassBookingCompleted
ClassBookingFailed
The last two (completed and failed) could have been merged into a single event with some extra fields representing the success/failure outcome and the error if any. However, doing so would have forced me to put some undesired, extra complexity on the EventBridge rule that is responsible for delivering the SMS.
Each of these events share a common base structure: they come with an userAlias
string field, representing the user to which the event is connected, and a class
object containing details about the class.
Note that the above mentioned events are just defined within the code (Eg. the ClassBookingAvailable event) and on default event bus on EventBridge. I could have followed a more structured approach, and used both a custom Schema registry and a custom bus. However, both looked to me a bit overhead for the purpose.
Let's see more in details what these events represent and their fields.
ClassBookingAvailable
This event is published whenever a class matching the configured search criteria is ready to be booked, with the following structure
{
"userAlias":"andrea",
"class":{
"id":"84ddef10-45df-42d7-b45e-fac603fe01c7",
"name":"Cycle Spirit",
"partitionDate":"20240727",
"startDate":"2024-07-27T19:00:00",
"bookingInfo":{
"cancellationMinutesInAdvance":120,
"bookingUserStatus":"CanBook"
}
}
}
When scanning for classes on the Gym's API, a class can be found in different states: if a class is found in the CanBook
state, it means that the booking for that class can happen already, if the class has a WaitingBookingOpensPremium
state it means that the class cannot be booked before abookingInfo.bookingOpensOn
datetime, available on the class item on the Gym's API response.
For a class in the CanBook
state on the Gym's API, the Scan function immediately pushes a ClassBookingAvailable
event on the EventBridge's default bus.
If a class is in the WaitingBookingOpensPremium
state, the Scan function schedules a ClassBookingAvailable
event, with the help of a dynamic rule on EventBridge that
will fire at a datetime of value bookingInfo.bookingOpensOn
. We'll see more about these differences when looking at the code later.
Note that both immediate and future class available events will trigger the booking step in the same way. The Book function basically does not care whether an event was received straight after being found on the Gym's API or after some time, on a predefined schedule.
A few additional things worth highlighting from the above JSON sample:
- the
partitionDate
gives an indication of the day when the class will happen and complements theid
field. Theid
field on its own is not enough to uniquely identify an instance of class: a class'id
represent a particular type of class happening always at the same time on the same day of the week. However, thepartitionDate
is required to discriminated amongst different instances of the same type of class; - the
startDate
helps in 2 ways at booking time: along with thecancellationMinutesInAdvance
included in thebookingInfo
object, It helps avoiding to book classes that can't be un-booked. This is particularly useful to avoid penalties. The booking code - as visible later - skips booking the classes that are happening in less than a configured time. Moreover, it has a cosmetic purpose, as it used in the SMS payload that is sent to notify about the completed booking.
ClassBookingCompleted
This event is published when a class has been booked successfully. It comes with the following shape:
{
"userAlias":"andrea",
"class":{
"id":"95788cd4-c15a-4fe3-a6da-95b7764b4cba",
"name":"Cycle Spirit",
"startDate":"2024-07-27T19:00:00"
}
}
The userAlias
field here helps routing the notification to the expected user mobile number, if any is configured! The other information are only used to build a human friendly SMS payload.
ClassBookingFailed
This event is published whenever there's a failure while booking a class:
{
"userAlias":"andrea",
"class":{
"id":"95788cd4-c15a-4fe3-a6da-95b7764b4cba",
"name":"Cycle Spirit",
"startDate":"2024-07-27T19:00:00"
},
"errors":[
{
"field":"BookingApiException.TooEarlyToBookParticipantException",
"type":"Validation",
"details":"",
"errorMessage":"The class is not open for booking yet",
"message":"The class is not open for booking yet"
}
]
}
The errors
field is an array of error objects, that maps 1:1 with what is returned by the Gym's API.
At the time of writing this event just helps monitoring, it is not connected to any SMS notification.
Architecture overview
Overall, at a high level the solution I had in mind was as follows:
- Every 6 hours an EventBridge rule would have triggered the
Scan
Lambda function, that would have searched for available classes and either immediately publish aClassBookingAvailable
event or schedule one for the future; - Every time
ClassBookingAvailable
event would have been published on EventBridge, theBook
Lambda function would have been invoked and it would have eventually produced aClassBookingCompleted
orClassBookingFailed
event. In the first case, an SMS would have been delivered as well to a configured mobile number.
Here's a high-level diagram of the architecture:
Assistant's architecture high level overview
Show me the code!
The whole source code is available on my Github. Here I'll just focus on the main parts.
Scan function
Let's start by having a look at the Scan function code:
1 exports.handler = async (event) => {
2 const userAlias = event.detail.userAlias;
3 if (!userAlias) {
4 const errorMsg = "Received even without userAlias. Aborting";
5 await logging.error(errorMsg);
6
7 throw new Error(errorMsg);
8 }
9
10 const userCredentials = await utils.getUserCredentials(userAlias);
11 let loginData = await gymApiClient.login(
12 userCredentials.loginUsername,
13 userCredentials.loginPassword,
14 );
15
16 const facilityId = await utils.getConfig("facilityId");
17
18 // Search all classes that match my criteria of interest
19 const searchClassesRequest = {
20 method: "GET",
21 url: `${CALENDAR_API_BASE_URI}/enduser/class/search`,
22 headers: {
23 Authorization: `Bearer ${loginData.token}`,
24 },
25 params: {
26 facilityId: facilityId,
27 fromDate: utils.nowCET().format("yyyyMMDD"),
28 eventType: "Class",
29 },
30 };
31 const searchClassesResponse = await gymApiClient
32 .getHttpClient()
33 .request(searchClassesRequest);
34
35 if (gymApiClient.isResponseError(searchClassesResponse)) {
36 const errorMsg = `Unable to get classes: ${JSON.stringify(searchClassesResponse.data)}. Aborting`;
37 logging.error(errorMsg);
38
39 throw new Error(errorMsg);
40 }
41
42 // It seems not possible to filter classes of interest via an API call. So we need to fetch them first
43 // and retrospectively ignore some of those.
44 const filteredEvents = searchClassesResponse.data
45 .filter(
46 (e) =>
47 // excludes the classes booked already
48 e.isParticipant != true &&
49 // excludes the classes that can't be booked for some reason
50 e.bookingInfo.bookingUserStatus != "CannotBook" &&
51 e.bookingInfo.bookingUserStatus != "BookingClosed",
52 )
53 .filter((e) =>
54 SEARCH_CRITERIA.classNames.some((c) =>
55 e.name.toLowerCase().includes(c.toLowerCase()),
56 ),
57 )
58 .filter((e) =>
59 // Class should be taken in one of the days of interest
60 SEARCH_CRITERIA.days.includes(utils.stringToDateCET(e.startDate).day()),
61 )
62 .filter((e) => {
63 // startDate time should fall in one of the hour ranges
64 const timeFormat = "HH:mm:ss";
65 // this parses the class start timestamp in $timeFormat
66 const classStartDateTime = utils
67 .stringToDateCET(e.startDate)
68 .format(timeFormat);
69 // this one builds a new date attaching the above time portion to today's date portion
70 // this makes sure that when comparing timestamps results are not spoiled by different days
71 const adjustedClassStartDate = moment(classStartDateTime, timeFormat);
72
73 // this returns true if the class' startDateTime if there's at least a match
74 return SEARCH_CRITERIA.hourRangesCET.some((hr) => {
75 const rangeStartTime = moment(hr.start, timeFormat);
76 const rangeEndTime = moment(hr.end, timeFormat);
77
78 return adjustedClassStartDate.isBetween(rangeStartTime, rangeEndTime);
79 });
80 });
81
82 logging.debug(
83 `Found ${filteredEvents.length} events of the categories of interest.`,
84 );
85
86 for (const e of filteredEvents) {
87 switch (e.bookingInfo.bookingUserStatus) {
88 case "CanBook":
89 logging.debug(
90 `Booking for class ${e.name} with id=${e.id} should happen immediately.`,
91 );
92 await publishBookingAvailableEvent(userAlias, e);
93 break;
94 case "WaitingBookingOpensPremium":
95 logging.debug(
96 `Booking for class ${e.name} with id=${e.id} should be scheduled on ${e.bookingInfo.bookingOpensOn}`,
97 );
98 await scheduleFutureBooking(userAlias, e);
99 break;
100 default:
101 logging.error(
102 `Unexpected status for class ${e.name} with id ${e.id}: ${e.bookingInfo.bookingUserStatus}. Skipping.`,
103 );
104 return;
105 }
106 }
107 };
The first lines (2-8
) are simply for validation. If the userAlias
is not part of the event an error is thrown.
Lines 10-40
include the code required to create and fire the search classes requests. There are a few references to the utils
and gymApiClient
modules that are hosted within the common layer, which I'll talk about more later in the dedicated chapter.
Unfortunately the Gym's API does not offer great filtering features. Hence, we have to retrospectively exclude events that are out of interest from the Gym's API response (lines 42-80
). The SEARCH_CRITERIA
referenced in the code is a constant defined on the same file, of the following shape:
const SEARCH_CRITERIA = {
classNames: ["Pilates"],
hourRangesCET: [
{
start: "08:00:00",
end: "10:00:30",
},
{
start: "18:00:00",
end: "21:00:00",
},
],
// 0 Sunday, 6 Saturday
days: [1, 2, 3, 4, 5],
};
Thanks to this constant, the code narrows the focus only on particular type of classes happening on specific days and during predefined time windows.
After some logging (lines 82-84
), the code finally takes care of what should happen with the classes that are of interest. It loops through each of those items, and:
- invokes a
publishBookingAvailableEvent
function if the class has aCanBook
state; - calls a
scheduleFutureBooking
function if the class' state isWaitingBookingOpensPremium
; - Otherwise, does nothing but log an error and return.
The publishBookingAvailableEvent
function takes the userAlias
and the event details as parameters, and immediately publishes a ClassBookingAvailable
on the default EventBridge event bus:
1 async function publishBookingAvailableEvent(userAlias, classDetails) {
2 const classBookingAvailableEvent = {
3 Entries: [
4 {
5 Time: new Date(),
6 Source: "GymBookingAssistant.scan",
7 DetailType: "ClassBookingAvailable",
8 Detail: JSON.stringify(
9 craftClassBookingAvailableEventPayload(userAlias, classDetails),
10 ),
11 },
12 ],
13 };
14
15 const putEventResponse = await eventBridgeClient.send(
16 new PutEventsCommand(classBookingAvailableEvent),
17 );
18
19 if (
20 putEventResponse["$metadata"].httpStatusCode != 200 ||
21 putEventResponse.FailedEntryCount > 0
22 ) {
23 logging.error(
24 "There were one or more errors while publishing a ClassBookingAvailable event.",
25 );
26 }
27 }
Whereas, the scheduleFutureBooking
creates a schedule to publish a ClassBookingAvailable event in future, precisely at bookingInfo.bookingOpensOn
:
1 async function scheduleFutureBooking(userAlias, classDetails) {
2 let bookingOpensOnUTC = new Date(classDetails.bookingInfo.bookingOpensOn)
3 .toISOString()
4 .slice(0, -5);
5
6 const scheduleRequest = {
7 Name: `ScheduleBooking_${classDetails.id}`,
8 Description: `Class: ${classDetails.name} - Starts at: ${classDetails.startDate}`,
9 ScheduleExpression: `at(${bookingOpensOnUTC})`,
10 Target: {
11 Arn: "arn:aws:events:eu-south-1:097176176455:event-bus/default",
12 RoleArn:
13 "arn:aws:iam::097176176455:role/service-role/GymBookingAssistantEventBridgeRole",
14 EventBridgeParameters: {
15 DetailType: "ClassBookingAvailable",
16 Source: "GymBookingAssistant.scan",
17 },
18 // The schedule event is one that contains the userAlias and a slimmed-down
19 // version of the class object coming from the Gym API
20 Input: JSON.stringify(
21 craftClassBookingAvailableEventPayload(userAlias, classDetails),
22 ),
23 },
24 ActionAfterCompletion: ActionAfterCompletion.DELETE,
25 FlexibleTimeWindow: {
26 Mode: FlexibleTimeWindowMode.OFF,
27 },
28 };
29
30 const createScheduleResponse = await schedulerClient.send(
31 new CreateScheduleCommand(scheduleRequest),
32 );
33
34 if (createScheduleResponse["$metadata"].httpStatusCode != 200) {
35 logging.error(
36 "There were one or more errors while creating a booking schedule.",
37 );
38 }
39 }
As EventBridge (by default) requires schedules to be declared in UTC, some minimal massaging is required on the original datetime as it's returned in CET timezone by the Gym's API (lines 2-4
). The schedule has a fairly strict requirement for the execution time, therefore it does not allow a flexible time window (lines 25-27
). Moreover, it self deletes from EventBridge (line 24
) once executed.
Figuring out what values to use for the Arn
and RoleArn
included in the Target
object (lines 11-12
) was not straightforward, as the official docs are only suggesting what values could be used. Luckily, others had stumbled across a similar problem already.
Both the publishBookingAvailableEvent
and scheduleFutureBooking
leverage the same internal craftClassBookingAvailableEventPayload
utility to build the event payload to be published or scheduled:
function craftClassBookingAvailableEventPayload(userAlias, classDetails) {
return {
userAlias: userAlias,
class: {
id: classDetails.id,
name: classDetails.name,
partitionDate: classDetails.partitionDate,
startDate: classDetails.startDate,
bookingInfo: {
cancellationMinutesInAdvance:
classDetails.bookingInfo.cancellationMinutesInAdvance,
bookingUserStatus: classDetails.bookingInfo.bookingUserStatus,
},
},
};
}
That's all for the Scan function. Let's now focus on the code required to book a class.
Book function
The Book function is invoked with events of type ClassBookingAvailable
events, without differences between immediate and scheduled events:
1 exports.handler = async (event) => {
2 const userAlias = event.detail.userAlias;
3 if (!userAlias) {
4 const errorMsg = "Received even without userAlias. Aborting";
5 await logging.error(errorMsg);
6
7 throw new Error(errorMsg);
8 }
9
10 const classDetails = event.detail.class;
11
12 await logging.debug(
13 `Received event of type=${event["detail-type"]} from source=${event.source} with id=${event.id}.\nTrying to book class with id=${classDetails.id} and partitionDate=${classDetails.partitionDate} for userAlias=${userAlias} ...`,
14 );
15
16 // Check class booking status. This should never be different from CanBook or WaitingBookingOpensPremium, but let's double check
17 if (
18 classDetails.bookingInfo.bookingUserStatus != "CanBook" &&
19 classDetails.bookingInfo.bookingUserStatus != "WaitingBookingOpensPremium"
20 ) {
21 await logging.warn(
22 `Booking rejected its status is${classDetails.bookingInfo.bookingUserStatus}`,
23 );
24 return;
25 }
26
27 // Check cancellationMinutesInAdvance. We should avoid booking for classes than can't be un-booked to avoid penalties!
28 const startDateCET = utils.stringToDateCET(classDetails.startDate);
29 const timeToClassStartInMinutes = startDateCET.diff(
30 utils.nowCET(),
31 "minutes",
32 );
33
34 const timeToCancelBookingMinutes =
35 classDetails.bookingInfo.cancellationMinutesInAdvance +
36 EXTRA_TIME_CANCEL_BOOKING_IN_MINUTES;
37 const classCanBeCancelled =
38 timeToClassStartInMinutes > timeToCancelBookingMinutes;
39
40 if (!classCanBeCancelled) {
41 await logging.warn(
42 `Booking rejected to avoid penalties, because class could not be un-booked. startDate=${startDateCET} timeToClassStartInMinutes=${timeToClassStartInMinutes} timeToCancelBookingMinutes=${timeToCancelBookingMinutes}`,
43 );
44
45 return;
46 }
47
48 const userCredentials = await utils.getUserCredentials(userAlias);
49
50 let loginData = await gymApiClient.login(
51 userCredentials.loginUsername,
52 userCredentials.loginPassword,
53 );
54
55 const bookClassRequest = {
56 method: "POST",
57 url: `${BOOKING_API_BASE_URI}/core/calendarevent/${classDetails.id}/book`,
58 headers: {
59 Authorization: `Bearer ${loginData.token}`,
60 },
61 data: {
62 partitionDate: classDetails.partitionDate,
63 userId: userCredentials.userId,
64 },
65 };
66
67 const bookClassResponse = await gymApiClient
68 .getHttpClient()
69 .request(bookClassRequest);
70
71 if (gymApiClient.isResponseError(bookClassResponse)) {
72 logging.error(
73 `Unable to book class with id=${classDetails.id} and partitionDate=${classDetails.partitionDate}. Errors=${JSON.stringify(bookClassResponse.data.errors)}`,
74 );
75
76 await publishBookingFailedEvent(
77 userAlias,
78 classDetails,
79 bookClassResponse.data.errors,
80 );
81
82 return;
83 }
84
85 logging.debug(
86 `Successfully booked class with id=${classDetails.id} and partitionDate=${classDetails.partitionDate}`,
87 );
88 await publishBookingCompletedEvent(userAlias, classDetails);
89 };
Lines 2-46
are meant to run some extended validation on the ClassBookingAvailable
event received. After the usual userAlias check, the code makes sure that the class is in a valid state for booking (lines 16-25
), and that booking is avoided if there's no chance or a limited time window to un-bok it soon after (lines 27-46
). This is meant to mitigate the risk of penalties for the Gym's API user: if for 3 times in a row the user does not join a class that was previously booked, they are temporarily banned from booking new classes.
From line 50
onwards the code takes care of logging the user in, booking the class specified on the received ClassBookingAvailable
event, and ultimately publishing either a ClassBookingCompleted
or ClassBookingFailed
event.
What could go wrong
EventBridge guarantees at least once delivery. This means that my functions could be invoked multiple times for the same event.
While this is a problem for both the Scan and Book function, it's probably more relevant for the latter:
What happens if the Book function gets invoked multiple times with the same ClassBookingAvailable
event? Even if the booking operation is idempotent on the Gym's API (it seems that they have built some sort of idempotency mechanism based on the id
and partitionDate
of the booking), how to avoid that the same SMS gets sent multiple times?
To mitigate these hiccups I should have made all my functions idempotent. Considering the overall context, I decided that this flaw was acceptable.
Another problem is that EventBridge comes with 60 seconds precision, and sometimes the Book function could be invoked with a delay up to that value: this is usually not enough for the overcrowded classes that usually gets fully booked a bunch of seconds after the bookings are open. Again, since this was not the case of the class I was interested in, I decided to proceed.
Let's now zoom on the common modules.
Common layer
The common layer includes 3 modules:
- one that includes all the utilities, called
utils
; - one that bundles the HTTP client to interact with the Gym's API, named
gymApiClient
; - and finally, a
logging
module for custom logging purposes.
While the whole code is available at my github repository, here I want to highlight a few important functions bundled in the above mentioned modules.
The utils
modules exports 2 key methods (among the others) that are used to fetch generic configurations from Systems Manager (getConfig
), or sensitive and user-specific credentials from Secrets Manager (getUserCredentials
):
module.exports = {
getConfig: async (name) => {
if (!config) {
const parametersStoreResponse = await serviceSystemManagerClient.send(
new GetParameterCommand({
Name: "GymBookingAssistant",
}),
);
config = JSON.parse(parametersStoreResponse.Parameter.Value);
}
const value = config[name];
if (!value) {
throw new Error(`Config "${name}" not found.`);
}
return value;
},
getUserCredentials: async (userAlias) => {
const credentials = await secretsManagerClient.send(
new GetSecretValueCommand({
SecretId: `GymBookingAssistant/Credentials/${userAlias}`,
}),
);
return JSON.parse(credentials.SecretString);
},
// other functions...
};
The gymApiClient
modules bundles all the logic that interacts with the Gym's API server and exports 3 methods:
- a
getHttpClient
method that yields an instance of the HTTP client (built on top of Axios) to be used with the Gym's API, already enriched with a bunch of interceptors meant for request/response logging or to set some defaults HTTP headers. The returned HTTP client instance can be used to fire multiple different API calls, as the search and book interactions that we've seen above. - an
isResponseError
utility that takes an Axios HTTP response as parameters and tells whether it contains an error or not; - And a
login
method, that reuses the abovegetHttpClient
, and fires a login call to the Gym's server.
1 module.exports = {
2 login: async function (username, password) {
3 const APPLICATION_ID = await utils.getConfig("applicationId");
4 const LOGIN_DOMAIN = await utils.getConfig("loginDomain");
5
6 const loginRequest = {
7 method: "POST",
8 url: `${CORE_API_BASE_URI}/Application/${APPLICATION_ID}/Login`,
9 data: {
10 domain: LOGIN_DOMAIN,
11 keepMeLoggedIn: true,
12 username: username,
13 password: password,
14 },
15 };
16
17 const loginResponse = await module.exports
18 .getHttpClient()
19 .request(loginRequest);
20
21 if (module.exports.isResponseError(loginResponse)) {
22 const errorMsg = `Unable to login: ${JSON.stringify(loginResponse.data)}. Aborting`;
23 await logging.error(errorMsg);
24
25 throw new Error(errorMsg);
26 }
27
28 return loginResponse.data;
29 },
30
31 getHttpClient: function () {
32 let client = axios.create();
33
34 client.interceptors.request.use(async (request) => {
35 const CLIENT_ID = await utils.getConfig("clientId");
36 request.headers["x-mwapps-client"] = CLIENT_ID;
37 return request;
38 });
39
40 client.interceptors.request.use(async (request) => {
41 const maskedPayload = maskData.maskJSON2(
42 request.data,
43 JSON_MASKING_CONFIG,
44 );
45 await logging.debug(
46 `>>> ${request.method.toUpperCase()} ${request.url}
47 \nParams: ${JSON.stringify(request.params, null, 2)}
48 \nBody:
49 \n${JSON.stringify(maskedPayload, null, 2)}`,
50 );
51 return request;
52 });
53
54 client.interceptors.response.use(async (response) => {
55 const maskedPayload = maskData.maskJSON2(
56 response.data,
57 JSON_MASKING_CONFIG,
58 );
59 await logging.debug(
60 `<<< ${response.status} ${response.request.method.toUpperCase()} ${response.config.url}
61 \nBody:
62 \n${utils.truncateString(JSON.stringify(maskedPayload, null, 2), RESPONSE_BODY_MAX_SIZE_LOGGED)}
63 \n\n`,
64 );
65 return response;
66 });
67
68 return client;
69 },
70
71 isResponseError: (response) => {
72 return (
73 response.status < 200 ||
74 response.status >= 300 ||
75 (response.data != null && response.data.errors != null)
76 );
77 },
78 };
As visible on lines 41-44
and 55-58
, while logging HTTP request and responses the code is leveraging a masking module (maskdata) to avoid leaking PII or other sensitive information, with the help of the following configuration:
const JSON_MASKING_CONFIG = {
passwordFields: ["password"],
uuidFields: ["data.userContext.credentialId", "data.credentialId"],
emailFields: [
"username",
"data.userContext.accountUsername",
"data.userContext.email",
],
phoneFields: ["data.userContext.mobilePhoneNumber"],
genericStrings: [
{
fields: [
"token",
"data.userContext.firstName",
"data.userContext.address1",
"data.userContext.lastName",
"data.userContext.nickName",
"data.userContext.birthDate",
"data.userContext.displayBirthDate",
"data.userContext.pictureUrl",
"data.userContext.thumbPictureUrl",
],
},
],
};
All that glitters is not gold
While great in theory, Common layers came with a few traps.
Firstly, I had to structure the code to mimic what the Lambda wrapper was expecting: as visible in the source code, an extra nodejs
directory is required to bundle the common modules. Why one should bother with this Lambda-specific implementation detail while developing locally?
Most importantly, the fact that the code was expecting common modules to be globally available on the host made the development experience a bit frustrating. To make the code work locally, I had to play with symbolic links 🫤.
Finally, let's have a look at what I did for delivering the SMS with the information about the completed booking.
SMS notification
As mentioned already in a previous chapter I decided to implement the notification step simply with the help of an EventBridge rule and SNS Topic:
The rule targets an SNS Topic, whose input is manipulated by an EventBridge Input transformer on AWS, to create a human friendly message:
Before reaching the SNS topic the input is transformed with the help of an EventBridge Input transformer on AWS, to create a human friendly message. The transformer creates an intermediate JSON object with the following structure and values:
{
"classId": "$.detail.class.id",
"className": "$.detail.class.name",
"classStartDate": "$.detail.class.startDate"
}
and ultimately builds a human friendly SMS payload with the following template:
La classe <className> in programma il <classStartDate> è stata prenotata! Se non vuoi partecipare, ricorda di annullare la prenotazione per evitare penalizzazioni.
Quota request increase what?
After a bunch of successful deliveries, all of sudden all my SMSes started failing. After enabling some basic logging (Why isn't that enabled by default?) I realized that I had reached my monthly budget for SMSes!
How could that be true, considering that:
- I was using the Sandbox environment and only sending to a trusted target (my mobile number)
- I had sent between 5 and 10 SMSes
Very surprisingly, it turned out that the default monthly budget for sending SMS is 1 USD! 😅
So, I had to raise a budget increase request and after a few ping-pong chats with the AWS support I was ultimately able to convince them that there's wasn't a company, there wasn't a site and much less there wasn't a process to opt-out for those messages...
Final thoughts
That's it!
While the project I’ve built may not be memorable or flawless, I genuinely enjoyed the process of creating it. Though I may not use it regularly to book my classes, it provided me with an opportunity to dive deeper into the serverless space on AWS, beyond what I typically encounter at work. Writing about this experience also encouraged me to reflect critically on the decisions I made, considering both their benefits and limitations. I may not be entirely proud of the final product, but I’m certainly happy with the valuable lessons I’ve learned along the way.