In the Epic Store there is a selection of free games that changes each week. If you want to get them you have to manually access the web page of the store each week, could we build something to get notified?
Turns out there is an endpoint to get the current free games so we can build a Ruby app to fetch it and notify us if there is anything new. Let’s build it!
mkdir free-game-watcher
cd free-game-watcher
bundle init
This will create a simple Gemfile
pointing to RubyGems and nothing more. I like to always have installed a static analysis library, so I encourage you to add standard to your development dependencies.
bundle add standard --groups development
And configure your editor to run it on save and/or tell you about issues.
The main selling point of the application is to get notified of free games, for that we need to query the Epic Games Store to fetch the current selection.
The endpoint that the Store uses is public so we will use it as well. It is a GET request without query params and doesn’t require any authentication. Let’s have a look.
$ curl https://store-site-backend-static.ak.epicgames.com/freeGamesPromotions | jq '.data.Catalog.searchStore.elements[1] | {title: .title, promotions: .promotio
ns.promotionalOffers, upcoming: .promotions.upcomingPromotionalOffers, images: .keyImages[3]}'
{
"title": "Breathedge",
"promotions": [
{
"promotionalOffers": [
{
"startDate": "2023-04-27T15:00:00.000Z",
"endDate": "2023-05-04T15:00:00.000Z",
"discountSetting": {
"discountType": "PERCENTAGE",
"discountPercentage": 0
}
}
]
}
],
"upcoming": [],
"images": {
"type": "Thumbnail",
"url": "https://cdn1.epicgames.com/08ae29e4f70a4b62aa055e383381aa82/offer/EGS_Breathedge_RedRuinsSoftworks_S2-1200x1600-c0559585221ea11c9d48273c3a79b1ba.jpg"
}
}
I’ve used jq to show you the information that we will care about (only for one game), feel free to inspect the full JSON response.
We have to implement this query in Ruby, for that we will use Faraday. There are many different http libraries but I’ve chosen Faraday because, besides being a great tool, the gem that we will use later to connect to telegram has Faraday as a dependency so installing the same library saves us from having different libraries for the same purpose.
bundle add faraday
Now we are going to create an EpicStoreAdapter class that will contain the method to query the endpoint. Where should we place it?
The application logic should go into its own app
folder. If later we want to add a simple web page we can create a standalone web
directory.
So, create the new app
folder and place a new file called epic_store_adapter.rb
inside it with our new class that needs to receive the base URL of the endpoint to configure the Faraday client.
# app/epic_store_adapter.rb
+ class EpicStoreAdapter
+ def initialize(base_url)
+ @connection = Faraday.new(
+ url: base_url,
+ headers: {"Content-Type": "application/json"},
+ request: {timeout: 15}
+ )
+ end
+ end
And a method to query free games:
# app/epic_store_adapter.rb
class EpicStoreAdapter
def initialize(base_url)
@connection = Faraday.new(
url: base_url,
headers: {"Content-Type": "application/json"},
request: {timeout: 15}
)
end
+
+ def get_free_games
+ response = @connection.get("/freeGamesPromotions")
+ JSON.parse(response.body)
+ end
end
Let’s check that this class does what we want. To create basic for it we’ll use rspec.
# Install RSpec
$ bundle add rspec --group test
Fetching gem metadata from https://rubygems.org/...
...
# Init RSpec configuration files
$ bundle exec rspec --init
create .rspec
create spec/spec_helper.rb
# Check that it works
$ bundle exec rspec
No examples found.
Finished in 0.00051 seconds (files took 0.06503 seconds to load)
0 examples, 0 failures
Before creating our spec we are going to add VCR to store API responses so everytime we run tests we avoid making new real requests.
# Install VCR
$ bundle add vcr --group test
VCR needs to be configured first so in our spec_helper.rb
file (which was created previously with rspec --init
) set the cassette library directory and the HTTP library:
# spec/spec_helper.rb
+ require "vcr"
+
+ VCR.configure do |config|
+ config.cassette_library_dir = "spec/fixtures/vcr_cassettes"
+ config.hook_into(:faraday)
+ end
RSpec.configure do |config|
# ...
end
And finally we can write a simple test to check that we have correctly fetched games.
# spec/app/epic_store_adapter_spec.rb
require_relative "../../app/epic_store_adapter"
RSpec.describe EpicStoreAdapter do
let(:adapter) { EpicStoreAdapter.new("https://store-site-backend-static.ak.epicgames.com") }
it "fetches current selection of free games" do
VCR.use_cassette("free_games") do
response = adapter.get_free_games
games = response["data"]["Catalog"]["searchStore"]["elements"]
expect(games.first["title"]).to eq("Borderlands 3 Season Pass")
end
end
end
We will clean up that require_relative
later
You should find the saved response from the API in the folder we specified before.
Now, instead of returning the parsed JSON from the response we could create a couple of data structures to store the information we care about. For that we can use the new Data core class.
class EpicStoreAdapter
+ GameData = Data.define(
+ :title,
+ :product_slug,
+ :url_slug,
+ :image_url,
+ :promotions
+ )
+ PromotionData = Data.define(
+ :start_date,
+ :end_date,
+ :discount_type,
+ :discount_percentage
+ )
def initialize(base_url)
end
...
The Data
class is similar to Struct
and it shares most of their implementation. We’ll store only a selection of attributes from games and their promotions.
We are going to create a new private method to map the JSON response to an array of Games.
+ private
+
+ def build_games(games_data)
+ games_data.map do |game|
+ thumbnail_image = game["keyImages"].find { |image| image["type"] == "Thumbnail" }
+
+ GameData.new(
+ title: game["title"],
+ product_slug: game["productSlug"],
+ url_slug: game["urlSlug"],
+ image_url: thumbnail_image&.fetch("url", nil)
+ )
+ end
+ end
As you can see is pretty straight-forward, the only attribute a bit more complex to fetch is the thumbnail image that we have to find in the array of images.
There is still one attribute missing, promotions
. Promotions have their own structure so we are going to create another to map them into Data
structures.
+ def build_promotions(promotional_offers)
+ promotions = []
+ promotional_offers.each do |offer|
+ offer["promotionalOffers"].each do |promotional_offer|
+ promotions << PromotionData.new(
+ start_date: Time.parse(promotional_offer["startDate"]),
+ end_date: Time.parse(promotional_offer["endDate"]),
+ discount_type: promotional_offer["discountSetting"]["discountType"],
+ discount_percentage: promotional_offer["discountSetting"]["discountPercentage"]
+ )
+ end
+ end
+ promotions
+ end
Here we traverse the JSON to get the data we want (check the full JSON you have saved for details)
Now we can update our build_games
method to add promotions. The JSON contains both current promotions and future ones, we can save both.
def build_games(games_data)
games_data.map do |game|
+ active_promotions = build_promotions(game.dig("promotions", "promotionalOffers") || [])
+ upcoming_promotions = build_promotions(game.dig("promotions", "upcomingPromotionalOffers") || [])
thumbnail_image = game["keyImages"].find { |image| image["type"] == "Thumbnail" }
GameData.new(
title: game["title"],
product_slug: game["productSlug"],
url_slug: game["urlSlug"],
image_url: thumbnail_image&.fetch("url", nil),
+ promotions: active_promotions + upcoming_promotions
)
end
end
Finally we have to update our get_free_games
method to, instead of returning the parsed JSON, create the needed structures.
def get_free_games
response = @connection.get("/freeGamesPromotions")
- JSON.parse(response.body)
+ parsed_response = JSON.parse(response.body)
+ games_data = parsed_response["data"]["Catalog"]["searchStore"]["elements"]
+ build_games(games_data)
end
But now our spec is failing, we have to update it accordingly:
# spec/app/epic_store_adapter_spec.rb
require_relative "../../app/epic_store_adapter"
RSpec.describe EpicStoreAdapter do
let(:adapter) { EpicStoreAdapter.new("https://store-site-backend-static.ak.epicgames.com") }
it "fetches current selection of free games" do
VCR.use_cassette("free_games") do
- response = adapter.get_free_games
- games = response["data"]["Catalog"]["searchStore"]["elements"]
- expect(games.first["title"]).to eq("Borderlands 3 Season Pass")
+ games = adapter.get_free_games
+ expect(games.first.title).to eq("Borderlands 3 Season Pass")
end
end
end
And that’s it for now, we have implemented the request to the Epic Store and mapped the data into our own data structures.
In the following part we will save the data into a database.