Eloy Perez

Just random things

Creating a Ruby App to get notified about Epic Free Games. Fetching Data

Posted at — Jun 7, 2023

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!

Creating the project

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.

Making HTTP Requests

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.