DEV Community

Eloy Pérez
Eloy Pérez

Posted on • Updated on

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

Part 1 | Part 2

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (1)

Collapse
 
katafrakt profile image
Paweł Świątkowski

I like the use case. Writing software to make your life easier is great motivation to learn. Also, nice use of new Data abstraction 👍