We’re Building a Marvel Catalog Reader! Avengers, Assemble!

Wern Ancheta
Share

In this tutorial, we’re going to take a look at the Marvel API, a tool provided by Marvel for developers to get access to the 70-plus years of Marvel comics data.

Marvel logo

First, we’ll walk through the steps in which one can acquire the keys needed to make requests to the API. Then, we’ll look at the tools we can use to test out the API. Finally, we’re going to build a website that uses the API.

Signing Up

The first thing we need to do is go to the Marvel Developer Website and click the Get a Key link. We’ll then be provided with the public and private key which we can use to perform requests to the API. If you plan to use the project in production, you can also add your website’s domain name to the list of authorized referrers. This provides a security layer in case you accidentally push your public and private keys to Github.

Important Notes Regarding the Use of the API

For limits and rules, please see the attribution, linking and rate limits page of their documentation. Also be sure to read the Marvel API Terms of Use if you’re planning to use the API in production. I’ve summarized it below:

  • Beware of the API rate limit. At the time of writing of this tutorial the rate limit is 3000 calls per day. That’s applicable to all the endpoints.
  • Always attribute Marvel as the source when displaying API data and images. Marvel recommends the use of this text: Data provided by Marvel. © 2016 Marvel.

Playing with the API

The API comes with an interactive documentation which allows you to easily test all the available API endpoints.

There’s information on the data one can expect:

expected data

… text fields to specify the different parameters to be submitted for the request:

parameters

… error status codes:

error codes

… the request URL, response body, response code and the response headers:

request and response

Do note that the request URL won’t actually return anything if you access it outside of the API testing tool. This is because it lacks the required parameters for the request. At the bare minimum, you’ll have to supply the public key, the current unix timestamp, and an md5 hash of the timestamp, and private and public key combined.

<?php
$ts = time();
$public_key = 'your public key';
$private_key = 'your private key';
$hash = md5($ts . $private_key . $public_key);

Once you have those, only then can you perform a request to the API:

<?php
$query_params = [
    'apikey' => $public_key,
    'ts' => $ts,
    'hash' => $hash
];

//convert array into query parameters
$query = http_build_query($query_params);

//make the request
$response = file_get_contents('http://gateway.marvel.com/v1/public/comics?' . $query);

//convert the json string to an array
$response_data = json_decode($response, true);

We’ll take a look at this in more detail when we get to the section where we start making requests for the app that we’ll be building.

Building the Website

Hopefully you’ve taken the time to play around with the API tool because now we’re going to start building the website. For this tutorial, we’re going to use Laravel.

composer create-project --prefer-dist laravel/laravel marvel-viewer

The command above will install Laravel in the marvel-viewer directory.

Once it’s done, install Guzzle.

php composer require guzzlehttp/guzzle

Create a .env file in the root of the project directory and put in the following:

APP_ENV=local
APP_DEBUG=true
APP_KEY=fxxq7HcbypI36Cil5cmOxO7vWFZu0QOD 

CACHE_DRIVER=file

Next, create a marvel.php file under the config directory. Then, return an array containing the private and public key that you got earlier from the Marvel developer website:

<?php
return [
    'private_key' => 'xxx',
    'public_key' => 'yyy'
];

This allows us to call the config function to get those values:

echo config('marvel.private_key');
echo config('marvel.public_key');

Caching API Results

From the Marvel developer website itself, one of the tips for staying within the rate limits is to cache results. This makes the app more performant, especially for API calls that return lots of data.

To start, let’s create a new console command using artisan:

php artisan make:console CacheApi

This will create the app/Console/Commands/CacheApi.php file. Open that up and change the values for the signature and description with the following:

protected $signature = 'cache:api';
protected $description = 'Saves data from marvel API into the cache';

To register the command for use, open up app/Console/Kernel.php and add it as an item under the $commands array.

protected $commands = [
    Commands\Inspire::class,
    Commands\CacheApi::class // add this
];

This will let us execute it on the command line like so:

php artisan cache:api

To define what the command will do, go back to the CacheApi.php file and add the following inside the handle function:

$ts = time();
$hash = md5($ts . config('marvel.private_key') . config('marvel.public_key'));

$client = new Client([
    'base_uri' => 'http://gateway.marvel.com/v1/public/',
    'query' => [
        'apikey' => config('marvel.public_key'),
        'ts' => $ts,
        'hash' => $hash
    ]
]);

$endpoints = [
    'characters',
    'comics'
];

$results_per_page = 20;
$total_page_count = 10;

$minutes_to_cache = 1440; // 1 day

foreach($endpoints as $ep){

    $data = [];

    for($x = 0; $x <= $total_page_count; $x++){

        $query = $client->getConfig('query');
        $query['offset'] = $results_per_page * $x;

        $response = $client->get('http://gateway.marvel.com/v1/public/' . $ep, ['query' => $query]);
        $response = json_decode($response->getBody(), true);
        $current_data = $response['data']['results'];
        $data = array_merge($data, $current_data);
    }

    Cache::put($ep, $data, $minutes_to_cache);

}

Let’s break it down. First, we assign all the data required by the Marvel API to a variable. The first one being the current unix timestamp ($ts) and the second being the md5 hash of the concatenated values of $ts, the Marvel private key, and the public key.

$ts = time();
$hash = md5($ts . config('marvel.private_key') . config('marvel.public_key'));

Then, a new Guzzle client is created wherein we supply the base url used by the API and the data required all requests.

$client = new Client([
    'base_uri' => 'http://gateway.marvel.com/v1/public/',
    'query' => [
        'apikey' => config('marvel.public_key'),
        'ts' => $ts,
        'hash' => $hash
    ]
]);

Define the endpoints whose data will be cached. In this case, we only need to cache the characters and comics data.

$endpoints = [
    'characters',
    'comics'
];

Define the total results to be returned by the API for every request, the total number of pages to be requested for each endpoint, and the total number of minutes for which to cache the results. In this case, we only want it to be stored for a day.

$results_per_page = 20;
$total_page_count = 10; // there's a bazillion characters and comics, we don't want to take all day

$minutes_to_cache = 1440; // 1 day

Loop through all the endpoints. For every endpoint, we have a for loop that would execute based on the value for $total_page_count. For every iteration, we get the current query configuration and assign an offset. On the first iteration this would be 0 (20 * 0), on the second it’s 20 (20 * 1), and so on. Then we make the request to the endpoint, convert the response object to an array, and then merge the result with the $data array. Lastly, we put the data into the cache.

foreach($endpoints as $ep){

    $data = [];

    for($x = 0; $x <= $total_page_count; $x++){

        $query = $client->getConfig('query');
        $query['offset'] = $results_per_page * $x;

        $response = $client->get('http://gateway.marvel.com/v1/public/' . $ep, ['query' => $query]);
        $response = json_decode($response->getBody(), true);
        $current_data = $response['data']['results'];
        $data = array_merge($data, $current_data);
    }

    Cache::put($ep, $data, $minutes_to_cache);

}

Once that’s done, we can execute the command to begin caching:

php artisan cache:api

Routes

The app is only going to have three pages: a page for viewing random comics, a page for viewing the details of a comic, and a page for viewing different characters.

<?php
Route::get('/comics', 'HomeController@comics');
Route::get('/comics/{id}', 'HomeController@comic');
Route::get('/characters', 'HomeController@characters');

Controller

The controller handles the requests that are coming from the routes. Execute the following command to generate a controller file:

php artisan make:controller HomeController

This creates a app/Http/Controllers/HomeController.php file. Add the following to it:

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use GuzzleHttp\Client;
use Cache;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;

class HomeController extends Controller
{
    private $client;

    public function __construct(){

        $ts = time();
        $hash = md5($ts . config('marvel.private_key') . config('marvel.public_key'));

        $this->client = new Client([
            'base_uri' => 'http://gateway.marvel.com/v1/public/',
            'query' => [
                'apikey' => config('marvel.public_key'),
                'ts' => $ts,
                'hash' => $hash
            ]
        ]);

    }

    public function comics(Request $request){

        $search_term = '';
        if($request->has('query')){

            $search_term = $request->input('query');

            $query = $this->client->getConfig('query');
            $query['titleStartsWith'] = $search_term;

            $response = $this->client->get('comics', ['query' => $query]);
            $response = json_decode($response->getBody(), true);

            $comics = $response['data']['results'];

        }else{
            $comics = Cache::get('comics');
            shuffle($comics);
            $comics = array_slice($comics, 0, 20);
        }

        return view('comics', ['comics' => $comics, 'query' => $search_term]);

    }

    public function comic($id){

        $page_data = [];

        $response = $this->client->get('comics/' . $id);
        $response = json_decode($response->getBody(), true);

        $comic = $response['data']['results'][0];
        $page_data['comic'] = $comic;

        if(!empty($comic['series'])){

            $series_response = $this->client->get($comic['series']['resourceURI']);
            $series_response = json_decode($series_response->getBody(), true);

            $page_data['series'] = $series_response['data']['results'][0];
        }

        return view('comic', $page_data);
    }

    public function characters(Request $request){

        $characters = Cache::get('characters');  

        $current_page = LengthAwarePaginator::resolveCurrentPage();

        if(is_null($current_page)){
            $current_page = 1;
        }

        $characters_collection = new Collection($characters);

        $items_per_page = 8;

        $current_page_results = $characters_collection->slice(($current_page - 1) * $items_per_page, $items_per_page)->all();
        $paginated_results = new LengthAwarePaginator($current_page_results, count($characters_collection), $items_per_page);

        return view('characters', ['paginated_results' => $paginated_results, 'characters' => $characters]);

    }

}

Breaking it down, first we define and initialize the Guzzle client. This is pretty much the same thing we did earlier in CacheApi.php.

private $client;

public function __construct(){

    $ts = time();
    $hash = md5($ts . config('marvel.private_key') . config('marvel.public_key'));

    $this->client = new Client([
        'base_uri' => 'http://gateway.marvel.com/v1/public/',
        'query' => [
            'apikey' => config('marvel.public_key'),
            'ts' => $ts,
            'hash' => $hash
        ]
    ]);

}

The comics function is responsible for serving the comics page. If no query is supplied, this lists out random items fetched from the cache. If a query is supplied it will make a request to the API using that query. Here, we’re supplying the titleStartsWith parameter and then supplying the user input. If you’re feeling lucky, you could also use the title parameter which only returns the direct match of the user input.

public function comics(Request $request){

    $search_term = '';
    if($request->has('query')){

        $search_term = $request->input('query');

        $query = $this->client->getConfig('query');
        $query['titleStartsWith'] = $search_term; //supply the query

        //make a request to the api
        $response = $this->client->get('comics', ['query' => $query]);
        $response = json_decode($response->getBody(), true); //convert response object to array

        $comics = $response['data']['results']; //extract the item/s

    }else{
        $comics = Cache::get('comics'); //get items from the cache
        shuffle($comics); //jumble the array
        $comics = array_slice($comics, 0, 20); //extract the first 20 items from the jumbled array
    }

    //return the page with the data
    return view('comics', ['comics' => $comics, 'query' => $search_term]);

}

The comic function is responsible for serving detailed comic pages. This still uses the comics endpoint, but this time it has the comic ID appended to the end of the URL, so it will only ask for a single item from the API. Once the individual comic data has been fetched, it uses the series item to fetch more details for that specific series. Think of a series as a collection of stories. We’re using that to get data like creators, characters, and stories. You’ll see how these data are used later on in the views section.

public function comic($id){

    $page_data = [];

    //get a specific comic
    $response = $this->client->get('comics/' . $id);
    $response = json_decode($response->getBody(), true);

    $comic = $response['data']['results'][0];
    $page_data['comic'] = $comic;

    if(!empty($comic['series'])){
        //get series data
        $series_response = $this->client->get($comic['series']['resourceURI']);
        $series_response = json_decode($series_response->getBody(), true);

        $page_data['series'] = $series_response['data']['results'][0];
    }

    return view('comic', $page_data);
}

The characters function is responsible for serving the characters page. Unlike the comics and comic function, this doesn’t directly request data from the API. It only uses the character data that was stored in the cache. Also, it doesn’t select random items but paginates all the available items instead.

public function characters(Request $request){

    $characters = Cache::get('characters');  

    $current_page = LengthAwarePaginator::resolveCurrentPage();

    if(is_null($current_page)){
        $current_page = 1;
    }

    $characters_collection = new Collection($characters);

    $items_per_page = 8;

    $current_page_results = $characters_collection->slice(($current_page - 1) * $items_per_page, $items_per_page)->all();
    $paginated_results = new LengthAwarePaginator($current_page_results, count($characters_collection), $items_per_page);

    return view('characters', ['paginated_results' => $paginated_results, 'characters' => $characters]);

}

Let’s break it down – first, we get the characters from the cache:

$characters = Cache::get('characters');  

Get the current paginator page:

$current_page = LengthAwarePaginator::resolveCurrentPage();

If there’s none, we assume that it’s the first page:

if(is_null($current_page)){
    $current_page = 1;
}

Create a collection from the array of characters. This allows us to manipulate the data in a more convenient way. It basically turns your array into something like the results that you get when using Eloquent or the query builder.

$characters_collection = new Collection($characters);

Specify the number of items to show per page.

$items_per_page = 8;

Extract the items for the current page. Here, we’re calling the slice method to extract a slice of the array.

$current_page_results = $characters_collection
    ->slice(($current_page - 1) * $items_per_page, $items_per_page)
    ->all();

Create the paginated results.

$paginated_results = new LengthAwarePaginator($current_page_results, count($characters_collection), $items_per_page);

Views

We only have four views: the comics view for displaying a random list of comics, the comic view which displays a single comic, the characters view which displays a list of characters, and the parent view which all the other views inherit from.

Comics View

The comics view has a form for entering the user query, and below the form are the results. For each item, the photo, title, and the description of the comic is displayed.

@extends('layouts.default')

@section('content')
<div id="content">
    <h2>Comics</h2>
    <form action="/comics">
        <p>
            <label for="query">Query</label>
            <input type="text" name="query" id="query" value="{{ $query }}">
            <button>Search</button>
        </p>
    </form>

    <div id="comics" class="results">
        @foreach($comics as $com)
        <article class="card">
          <img src="{{ $com['thumbnail']['path'] }}/portrait_incredible.jpg" alt="{{ $com['title'] }} thumbnail">
          <footer>
            <h5>
                <a href="/comics/{{ $com['id'] }}" class="card-title">{{ $com['title'] }}</a>
            </h5>
            <p>
                {{ str_limit($com['description'], 160) }}
            </p>
          </footer>
        </article>
        @endforeach
    </div>
</div>
@stop

The link to the photo is constructed by using the thumbnail path then we concatenate the type of image that we want. Here, we’re using the portrait_incredible variant. There are many other image variants that are available – check out the images documentation if you want to know more.

<img src="{{ $com['thumbnail']['path'] }}/portrait_incredible.jpg" alt="{{ $com['title'] }} thumbnail">

The title links to the individual comic page:

<h5>
    <a href="/comics/{{ $com['id'] }}" class="card-title">{{ $com['title'] }}</a>
</h5>

The description is truncated so it wouldn’t mess with the styling for longer descriptions:

<p>
    {{ str_limit($com['description'], 160) }}
</p>

Here’s what the comics page would look like:

comics page

Comic View

The comic view displays the title, full description, series title, the starting and ending year of the series, the rating, creators, characters, and the stories in the series.

@extends('layouts.default')
@section('content')
<div id="content">

    <div class="results">
        <article>
            <img src="{{ $comic['thumbnail']['path'] }}.jpg" alt="{{ $comic['title'] }} thumbnail">

            <h2>{{ $comic['title'] }}</h2>
            <p>
                {{ $comic['description'] }}
            </p>
            <div id="series">
                <h3>From Series: {{ $series['title'] }}</h3>
                <div class="years">
                    <span>Start year: {{ $series['startYear'] }}</span>
                    <span>End year: {{ $series['endYear'] }}</span>
                </div>
                <div class="rating">
                    Rating: {{ $series['rating'] }}
                </div>
                @if($series['creators']['available'] > 0)
                <div class="creators">
                    <h4>Creators</h4>
                    <ul>    
                    @foreach($series['creators']['items'] as $creator)
                        <li>{{ $creator['name'] }} ({{ $creator['role'] }})</li>
                    @endforeach
                    </ul>
                </div>
                @endif

                @if($series['characters']['available'] > 0)
                <div class="characters">
                    <h4>Characters</h4>
                    <ul>    
                    @foreach($series['characters']['items'] as $character)
                        <li>{{ $character['name'] }}</li>
                    @endforeach
                    </ul>
                </div>
                @endif

                @if($series['stories']['available'] > 0)
                <div class="stories">
                    <h4>Stories</h4>
                    <ul>    
                    @foreach($series['stories']['items'] as $story)
                        <li>
                        {{ $story['name'] }} <br>
                        type: {{ $story['type'] }}
                        </li>
                    @endforeach
                    </ul>
                </div>
                @endif
            </div>
          </footer>
        </article>

    </div>
</div>
@stop

Here’s what the comic page would look like:

comic page

Characters View

The characters view displays Marvel characters in a paginated view. For each character we’re only displaying the photo and the title. The title links to the wiki page of the character if it’s available. Below the results are the pagination links.

@extends('layouts.default')

@section('content')
<div id="content">
    <h2>Characters</h2>

    <div id="characters" class="results">

        @foreach($paginated_results as $char)
        <article class="card">
          <img src="{{ $char['thumbnail']['path'] }}/portrait_incredible.jpg" alt="{{ $char['name'] }} thumbnail">
          <footer>
            <h5>
                <a href="{{ getCharacterURL($char) }}" class="card-title">{{ $char['name'] }}</a>
            </h5>
          </footer>
        </article>
        @endforeach

    </div>
    <div id="pagination">   
    {{ $paginated_results->setPath('characters')->render() }}
    </div>
</div>
@stop

Here’s what the characters page is going to look like:

characters page

Parent View

Create a layouts/default.blade.php file inside the resources/views directory and add the following code:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Marvel Viewer</title>
    <link rel="stylesheet" href="{{ url('lib/picnic/releases/picnic.min.css') }}">
    <link rel="stylesheet" href="{{ url('css/style.css') }}">
</head>
<body>
    <div id="wrapper">  
        <div id="header">
            <h1>Marvel Viewer</h1>
        </div>
        @yield('content')
    </div>
</body>
</html>

The parent view yields the main content. What ever we defined in the content section of the child views that we’ve used earlier will get rendered. This is where we also link the stylesheet used by the website.

Helper

Earlier we used the getCharacterURL function in the characters view but we haven’t really defined it yet. To define it, create a helpers.php file inside the app directory and add the following code:

<?php
function getCharacterURL($character){

    $urls = [];
    if(!empty($character['urls'])){
        foreach($character['urls'] as $curl){
            $urls[$curl['type']] = $curl['url'];
        }
    }

    return (!empty($urls['wiki'])) ? $urls['wiki'] : '#';
}

All this function does is loop through all the available character URLs and then returns the wiki URL if it’s available, or the pound symbol if not.

Next, open the composer.json file and add it to the files array in the autoload object:

"autoload": {
    "classmap": [
        "database"
    ],
    "psr-4": {
        "App\\": "app/"
    },
    "files": [
        "app/helpers.php" 
    ]
},

Execute composer dump-autoload for the changes to take effect.

Styles

Now it’s time to sprinkle some css to make the app look decent.

The parent view uses picnic.css to make things look nicer. You can download the css file here and put it in the specified directory, or use bower or (preferably) BowerPHP to install it.

Here’s the .bowerrc file to be placed in the root directory of the project:

{
  "directory": "public/lib"
}

Next, create a style.css file under the public/css directory and add the following code:

#wrapper {
    width: 900px;
    margin: 0 auto;
}

#query {
    width: 700px;
}

.card {
    width: 200px;
    float: left;
    margin-left: 10px;
    margin-right: 10px;
    margin-bottom: 25px;
}

#comics .card {
    height: 650px;
}

#characters .card {
    height: 450px;
}

.card p {
    font-size: 15px;
    color: #676767;
}

.card h5 {
    line-height: 18px;
}

.results {
    overflow: auto;
}

.pagination li {
    display: inline-block;
    padding: 30px;
    list-style: none;
}

Conclusion

In this tutorial, you’ve learned how to work with the Marvel API in order to get data about the different comics and characters that were created by Marvel in its 70-plus years of existence. You can access the source code used in this tutorial in this Github repo.

Have you built anything interesting with their API? Do you have ideas for some cool Marvel-powered apps? Let us know in the comments!