NgMarvel app using Angular and Marvel API

Zaki Mohammed Zaki Mohammed
July 12, 2021 | 12 min read | 180 Views

When life gives you lemons, make lemonade; and when Marvel provides you an API, make an Angular app. In this article, we will do the Hulk Smash! and create an awesome Marvel comic explorer app using Angular and Marvel Comics API.

Album Post:

For quick read. Checkout this amazing album!

NgMarvel app using Angular and Marvel API

With great power comes great responsibility; Marvel developers took that responsibility and came up with a super awesome Marvel Comics API which one can’t resist to develop an application around it.

Front-end web apps are a great deal in today’s date and learning them is a very steep path. In order to fully focus on only one area which is front-end without building the backend logic one can simply make use of such APIs (Marvel Comic API) and learn, explore and develop awesome looking apps using one’s favorite front-end framework. Let us jump into understanding the Marvel Comics API and how we can start using it with our app and then later we will see how to integrate it with an Angular application.

Marvel Comic API Setup

The Marvel Comics API is basically a tool which helps developers around the globe to explore and develop beautiful apps and websites around it. This is the true origin of Marvel Comics API.

For doing the great setup you have to follow very simple steps,

  1. Visit: https://developer.marvel.com/
  2. Sign up and create a developer account
  3. Get an API key
  4. For front-end apps, simply register your domain by adding it to the authorized referrer list,
    • ng-marvel-app.netlify.app
    • localhost
    For local Angular development also register the localhost.
  5. Make use of http(s)://gateway.marvel.com/ endpoint for following entities:
    • Comics
    • Comic Series
    • Comic Stories
    • Comic Events
    • Creators
    • Characters
  6. Use the public key to hit the endpoint and get responses for the above entities.
  7. Checkout the interactive documentation and an API tester.

Once you get used to the developer portal you won’t find much difficulty exploring it further. The documentation is very well written with simplicity and examples. Following is an example of an endpoint which will bring you bunch of Marvel Comic characters based on your API key:

https://gateway.marvel.com:443/v1/public/characters?apikey=YOUR_PUBLIC_KEY

Don't waste your time hitting the above URL using Postman or some other API testing tools, it won’t work because for a server side implementation there are other steps which you can checkout from the documentation. For testing the endpoints you can make use of the Interactive API tester provided by Marvel Comics API.

For your front-end application you will be able to hit the above URL via your Angular application and your hosted Angular application as you have added the referrers. So the URL and the public key will be the take-away for your Angular app. Let us set up the Angular app and create services, components, routing etc.

Setting up NgMarvel app

For creating NgMarvel app we will make use of the brand new Bootstrap 5 and Bootswatch Simplex theme to give Marvel kinda look to our application. So we will first do the daily chores for setting up an Angular application.

Will execute the following command to create an Angular app and install Bootstrap 5 and Bootswatch.

ng new ng-marvel-app
npm i bootstrap bootswatch

Adding Bootstrap and Bootswatch dependencies to angular.json file:

{
    ...
    "projects": {
        "ng-marvel-app": {
            ...
            "architect": {
                "build": {
                    "options": {
                        "styles": [
							"./node_modules/bootswatch/dist/simplex/bootstrap.min.css",
							"src/styles.scss"
						],
						"scripts": [
							"./node_modules/bootstrap/dist/js/bootstrap.bundle.min.js"
						]
                    }
                },
                ...
            }
        }
    }
}

Additionally, we will be required 2 more packages for NgMarvel app, Font Awesome and Angular Infinite Scroll.

npm i ngx-infinite-scroll
npm i @fortawesome/angular-fontawesome

Importing the infinite scroll and font awesome modules in AppModule as follows:

app.module.ts

import { InfiniteScrollModule } from 'ngx-infinite-scroll';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';

@NgModule({
  declarations: [...],
  imports: [
    ...
    InfiniteScrollModule,
    FontAwesomeModule
  ],
  providers: [],
  bootstrap: [AppComponent],
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class AppModule { }

After this we will define the environment apiUrl and apiKey variables as follows:

environment.ts

export const environment = {
  apiUrl: 'https://gateway.marvel.com:443/v1/public/',
  apiKey: 'YOUR_PUBLIC_KEY'
};

These will be handy in forming the API endpoints in the service.

Setting up Marvel Model

Let us focus on the bricks rather than the wall itself and create some handy models:

  1. Request Model - contains the Marvel API request parameter types.
  2. Response Model - contains the Marvel API response, data and cache types.
  3. Image Model - contains the Marvel API image types.

request.model.ts

export interface MarvelRequestOptions {
    limit: number;
    offset: number;
    nameStartsWith?: string;
    titleStartsWith?: string;
}

export type Category = 'characters' | 'comics' | 'creators' | 'events' | 'series' | 'stories';

response.model.ts

export interface MarvelResponse {
    attributionHTML: string;
    attributionText: string;
    code: number;
    copyright: string;
    data: MarvelData;
    etag: string;
    status: string;
}

export interface MarvelData {
    count: number;
    limit: number;
    offset: number;
    results: any[];
    total: number;
}

export interface MarvelCache {
    characters?: MarvelData;
    comics?: MarvelData;
    creators?: MarvelData;
    events?: MarvelData;
    series?: MarvelData;
    stories?: MarvelData;
}

image.model.ts

export enum ImageVariant {
    detail = "detail",
    full = "",
    portrait_small = "portrait_small",
    portrait_medium = "portrait_medium",
    portrait_xlarge = "portrait_xlarge",
    portrait_fantastic = "portrait_fantastic",
    portrait_uncanny = "portrait_uncanny",
    portrait_incredible = "portrait_incredible",
    standard_small = "standard_small",
    standard_medium = "standard_medium",
    standard_large = "standard_large",
    standard_xlarge = "standard_xlarge",
    standard_fantastic = "standard_fantastic",
    standard_amazing = "standard_amazing",
    landscape_small = "landscape_small",
    landscape_medium = "landscape_medium",
    landscape_large = "landscape_large",
    landscape_xlarge = "landscape_xlarge",
    landscape_amazing = "landscape_amazing",
    landscape_incredible = "landscape_incredible"
}

export interface ImageThumbnail {
    path: string;
    extension: string
}

Setting up Marvel Services

Let us focus on some important methods of our marvel service:

  1. Get Image - forms an image url using image thumbnail and variant
  2. Get Data - gets the entity data based on category and options

1. Get Image

This method will take care of our image requirements, as Marvel has a bunch of image ratios which you can request as per your needs. But the construction for these images is a little tricky.

marvel.service.ts

getImage(thumbnail: ImageThumbnail, variant: ImageVariant = ImageVariant.full) {
  return thumbnail && `${thumbnail.path}/${variant}.${thumbnail.extension}`;
}

For most of the endpoints you will get a thumbnail object which contains a path and extension properties using which you can make a URL which can bring the image as per required size variant. For the variant we have created an enum in the image model.

Now the getImage() method simply takes a thumbnail object and an image size variant and forms an image url as “path/variant.extension”.

Example:

Path: http://i.annihil.us/u/prod/marvel/i/mg/3/20/5232158de5b16/
Variant: standard_fantastic
Extension: .jpg
Final URL: http://i.annihil.us/u/prod/marvel/i/mg/3/20/5232158de5b16/standard_fantastic.jpg

2. Get Data

This method is the heart and soul for the project, it takes 2 parameters: category and options. The category contains the entity resource you are asking for, e.g. characters, comics etc. The options contain offset, limit, nameStartsWith and you can as many as you want based on Marvel API request criteria, in this app we are only using 3 of them to develop a basic application.

marvel.service.ts

getData(category: Category, options?: MarvelRequestOptions): Observable {
  if (this.cache[category] && options?.offset === 0 && !(options?.nameStartsWith || options?.titleStartsWith)) {
    return of(this.cache[category]);
  }

  let url = `${this.url}${category}?apikey=${this.apiKey}`;
  if (options) {
    Object.entries(options).forEach(([key, value]) => url += `&${key}=${value}`);
  }
  return this.http.get(url).pipe(map(response => {
    if (response.status === 'Ok') {

      if (!(options?.nameStartsWith || options?.titleStartsWith)) {
        if (this.cache[category]) {
          this.cache[category] = {
            ...response.data,
            results: [...(this.cache[category]?.results || []), ...response.data.results]
          };
        } else {
          this.cache[category] = response.data;
        }
      }

      return response.data;
    } else {
      throw new Error('Something went wrong');
    }
  }));
}

Being a good citizen and reducing the repetitive calls to the server we are making use of simple cache variables to store already fetched data. Only when the user will search or scroll to bring the next bunch of records; we make an API call otherwise our cache will have plenty.

if (this.cache[category] && options?.offset === 0 && 
    !(options?.nameStartsWith || options?.titleStartsWith)) {
    return of(this.cache[category]);
}

We are forming the request URL using the options, by simply iterating the properties of options and creating query strings which will be appended to the endpoint.

let url = `${this.url}${category}?apikey=${this.apiKey}`;
if (options) {
    Object.entries(options).forEach(([key, value]) => url += `&${key}=${value}`);
}

Example:

https://gateway.marvel.com/v1/public/characters?apikey=YOUR_PUBLIC_KEY&limit=50&offset=0

In case of search we won’t touch the cache otherwise we will append the data to the cache or initialize cache if it is not present.

if (!(options?.nameStartsWith || options?.titleStartsWith)) {
  if (this.cache[category]) {
    this.cache[category] = {
  	...response.data,
  	results: [...(this.cache[category]?.results || []), ...response.data.results]
    };
  } else {
    this.cache[category] = response.data;
  }
}

Setting up the Routing

Before checking out the routing we will check out the project structure as follows. The partial components are non-routed child components, all the other components can be routed.

app
|-- components
	|-- about
	|-- characters
	|-- comics
	|-- events
	|-- patials
		|-- banner
		|-- footer
		|-- header
		|-- list
		|-- list-group
		|-- loader
		|-- not-found
		|-- search
	|-- series
	|-- stories
|-- models
	|-- image.model.ts
	|-- request.model.ts
	|-- response.model.ts
|-- services
	|-- marvel.service.spec.ts
	|-- marvel.service.ts
|-- app-routing.module.ts
|-- app.component.html
|-- app.component.scss
|-- app.component.spec.ts
|-- app.component.ts
|-- app.module.ts

Our routing looks something like this:

app-routing.module.ts

const routes: Routes = [
  { path: 'characters', component: CharactersComponent },
  { path: 'comics', component: ComicsComponent },
  { path: 'events', component: EventsComponent },
  { path: 'series', component: SeriesComponent },
  { path: 'stories', component: StoriesComponent },
  { path: 'about', component: AboutComponent },
  { path: '', redirectTo: 'characters', pathMatch: 'full' },
  { path: '**', component: CharactersComponent }
];

Setting up the Components

The app component has the skeleton of the app, it contains the partial components header, footer and banner, also the router-outlet.

app.component.html

<app-header></app-header>

<app-banner [subTitle]="subTitle"></app-banner>

<div class="main-container py-4 py-sm-5 bg-white">
    <div class="container">
        <router-outlet></router-outlet>
    </div>
</div>

<app-footer></app-footer>

Apart from the app component let us talk more about the showstopper components character, comic, event, series and stories. All of these components share similar concepts and functionalities, we will simply check out the character component.

characters.component.html

<!-- search -->
<app-search title="character" (searchEvent)="onSearch($event)"></app-search>

<!-- not found -->
<app-not-found [notFound]="notFound"></app-not-found>

<!-- loader -->
<app-loader [status]="!characters.length && !notFound"></app-loader>

<!-- list -->
<app-list [items]="characters" (onScrollEvent)="onScroll()" (onItemClickEvent)="onCharacterClick($event)"></app-list>

<!-- view -->
<div class="modal" id="modal" tabindex="-1" aria-labelledby="exampleModalLabel" aria-hidden="true">
    <button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
    <div class="modal-dialog modal-xl">
        <div class="modal-content">
            <div class="modal-header">
                <img *ngIf="character" [src]="getImage(character)" class="img-fluid mx-auto d-block">
            </div>
            <div class="modal-body">
                <ng-container *ngIf="character">
                    <h1 class="text-primary">{{character.name}}</h1>
                    <p [innerHtml]="character.description"></p>
        
                    <!-- comics -->
                    <app-list-group title="Comics" [items]="character.comics.items"></app-list-group>
        
                    <!-- series -->
                    <app-list-group title="Series" [items]="character.series.items"></app-list-group>
        
                    <!-- stories -->
                    <app-list-group title="Stories" [items]="character.stories.items"></app-list-group>
        
                    <!-- events -->
                    <app-list-group title="Events" [items]="character.events.items"></app-list-group>
        
                    <!-- references -->
                    <app-list-group title="References" key="type" link="url" [isLink]="true" [items]="character.urls">
                    </app-list-group>
        
                </ng-container>
            </div>
        </div>
    </div>
</div>

We will pick up the small components and understand them individually. First we will check the search component. The below search component will form the search input and trigger an output event named searchEvent which will provide the search text based on which we can invoke the API.

<!-- search -->
<app-search title="character" (searchEvent)="onSearch($event)"></app-search>

When the searched entity is not found then the following component will simply show the not found message.

<!-- not found -->
<app-not-found [notFound]="notFound"></app-not-found>

Below one shows a loader icon based on status value which is itself based on a condition.

<!-- loader -->
<app-loader [status]="!characters.length && !notFound"></app-loader>

The below list component shows the list of items and also handles the infinite scroll within, and triggers an event named onScrollEvent which is invoked when a scroll event occurs.

<!-- list -->
<app-list [items]="characters" (onScrollEvent)="onScroll()" (onItemClickEvent)="onCharacterClick($event)"></app-list>

The final view modal holds up the details section for the entities and provides more details of individual items when clicked. Let us check out the code behind this component.

characters.component.ts

import { AfterViewInit, Component, OnInit } from '@angular/core';
import { ImageVariant } from 'src/app/models/image.model';
import { Category, MarvelRequestOptions } from 'src/app/models/request.model';
import { MarvelService } from 'src/app/services/marvel.service';
import { Subject } from 'rxjs';
import { concatMap, debounceTime, distinctUntilChanged, switchMap } from 'rxjs/operators';

declare let bootstrap: any;

@Component({
  selector: 'app-character',
  templateUrl: './characters.component.html',
  styleUrls: ['./characters.component.scss']
})
export class CharactersComponent implements OnInit, AfterViewInit {

  category: Category = 'characters';
  characters: any[] = [];
  character: any;
  total = 0;
  notFound = false;
  modal: any;
  options!: MarvelRequestOptions;

  searchText$ = new Subject();
  scroll$ = new Subject();

  constructor(private marvelService: MarvelService) { }

  ngOnInit(): void {
    this.options = {
      limit: 50,
      offset: 0
    };

    this.get();
    this.search();

    this.scroll$.next(0);
  }

  ngAfterViewInit(): void {
    this.modal = new bootstrap.Modal(document.getElementById('modal'));
  }

  getImage(character: any) {
    return this.marvelService.getImage(character.thumbnail, ImageVariant.standard_fantastic);
  }

  get() {
    this.scroll$.pipe(
      debounceTime(400),
      distinctUntilChanged(),
      concatMap(offset => {
        this.options.offset = offset;
        return this.marvelService.getData(this.category, this.options);
      })).subscribe(data => this.handleResponse(data));
  }

  search() {
    this.searchText$.pipe(
      debounceTime(400),
      distinctUntilChanged(),
      switchMap(() => this.marvelService.getData(this.category, this.options))).subscribe(data => this.handleResponse(data, true));
  }

  onScroll() {
    const offset = this.options.offset + this.options.limit;
    if (offset < this.total) {
      this.scroll$.next(offset);
    }
  }

  onSearch(searchText: string) {
    if (searchText !== this.options.nameStartsWith) {
      if (searchText) {
        this.options = {
          limit: 50,
          offset: 0,
          nameStartsWith: searchText
        };
      } else {
        this.options = {
          limit: 50,
          offset: 0
        };
      }
      this.characters = [];
      this.total = 0;
      this.notFound = false;
      this.searchText$.next(searchText);
    }
  }

  onCharacterClick(character: any) {
    this.character = character;
    if (this.modal) {
      this.modal.show();
    }
  }

  handleResponse(data: any, reset: boolean = false) {
    this.characters = reset ? data.results : [...this.characters, ...data.results];
    this.total = data.total;
    this.options.offset = this.options.offset || data.offset;
    this.notFound = !!!data.results.length;
  }
}

The 2 major elements of the component which controls most of the functionalities are below 2 Subjects:

searchText$ = new Subject();
scroll$ = new Subject();

The searchText$ subject next method is called when the search text changes, and the scroll$ next method is called when the scroll event occurs.

The 2 methods get and search handles the scroll and search changes, whenever the search text change or scroll event occurs getData service method will be invoked in order to bring the new or search records.

get() {
  this.scroll$.pipe(
    debounceTime(400),
    distinctUntilChanged(),
    concatMap(offset => {
      this.options.offset = offset;
      return this.marvelService.getData(this.category, this.options);
    })).subscribe(data => this.handleResponse(data));
}

search() {
  this.searchText$.pipe(
    debounceTime(400),
    distinctUntilChanged(),
    switchMap(() => this.marvelService.getData(this.category, this.options))).subscribe(data => this.handleResponse(data, true));
}

In both of the above methods we are adding a debounce time to create a manual delay to trigger the event of 400 milliseconds, this will avoid the stacking up of many API calls as it provides a breathing time for the API calls since our API will be called on scroll and search change event which totally runs on user’s mercy. The distinctUntiChange check for distinct calls are happening or not, in order to simply keep each API call distinct from others.

Lastly for scroll we want that every call must happen and should follow the sequence by letting the previous call complete before shooting the next onel, so for ensuring that we have to use concatMap. For the search we can simply use switchMap as completion of the previous call is not that important as compared to searching for the latest query.

The onScroll and onSearch event handlers are called when any of the respective events occurs and calls the next methods.

For handling the response in case of scroll and search we have added a common method named handleResponse which actually adjust the local values as shown below:

handleResponse(data: any, reset: boolean = false) {
  this.characters = reset ? data.results : [...this.characters, ...data.results];
  this.total = data.total;
  this.options.offset = this.options.offset || data.offset;
  this.notFound = !!!data.results.length;
}

When user clicks on any listed item we are simply opening the Bootstrap modal and provides the clicked item data to the modal as shown below:

onCharacterClick(character: any) {
  this.character = character;
  if (this.modal) {
    this.modal.show();
  }
}

Inside the modal body we have added a list group component to show different listing data in a Bootstrap based list group UI component.

<app-list-group title="Comics" [items]="character.comics.items"></app-list-group>

Inside the app-list component we have added the infinite scroll, the app-list component is shown below with the added infinite scroll properties infiniteScrollDistance and infiniteScrollThrottle and the scrolled event:

partials/list.component.html

<div class="row" infiniteScroll [infiniteScrollDistance]="2" [infiniteScrollThrottle]="50" (scrolled)="onScroll()">
    <div *ngFor="let item of items" class="col-6 col-sm-3 col-md-3 col-lg-2 mb-3">
        <div class="card bg-dark text-white" (click)="onItemClick(item)">
            <img [src]="getImage(item)" class="card-img">
            <div class="card-img-overlay">
                <h5 class="card-title">{{item[key]}}</h5>
            </div>
        </div>
    </div>
</div>

On item click or scroll events we are simply emitting the output events to let the parent know about the scroll or click event.

partials/list.component.ts

onScroll() {
  this.onScrollEvent.emit();
}

onItemClick(item: any) {
  this.onItemClickEvent.emit(item);
}

Finally let us talk about the search component, in this component we have used the Font Awesome icon search as shown below:

partials/search.component.html

<div class="row">
    <div class="col">
        <div class="input-group input-group-lg mb-3">
            <span class="input-group-text" id="searchIcon">
                <fa-icon class="text-primary" [icon]="faSearch"></fa-icon>
            </span>
            <input type="text" class="form-control" placeholder="Search {{title}}"
                aria-describedby="searchIcon" (keyup)="onSearch($event)">
        </div>
    </div>
</div>

We simply have to use the fa-icon element and provide an icon object to it from the Font Awesome module. The icon can be obtained as follows:

partials/search.component.ts

import { Component, EventEmitter, Input, Output } from '@angular/core';
import { faSearch } from '@fortawesome/free-solid-svg-icons';

@Component({
  selector: 'app-search',
  templateUrl: './search.component.html',
  styleUrls: ['./search.component.scss']
})
export class SearchComponent {

  faSearch = faSearch;
  
  @Input() title: string = '';
  @Output() searchEvent = new EventEmitter();

  onSearch($event: any) {
    const searchText = $event && $event.target && $event.target.value || '';
    this.searchEvent.emit(searchText);
  }
}

Apart from the Font Awesome icon in the search component we are firing the search event on every time the keyup event occurs.

Well that explains something about the project implementation of NgMarvel app. Obviously some of the components are not mentioned but they are simple to understand and not playing a major role.

The Marvel Comics API is a great resource for beginner to advanced developers, as it opens the door of learning how to manage huge amounts of data coming from beautifully constructed API. In our case, with NgMarvel we have explored the new Bootstrap 5, Bootswatch theme, Angular Infinite Scroll, Font Awesome, RxJS operators and functions and many more.


Zaki Mohammed
Zaki Mohammed
Learner, developer, coder and an exceptional omelet lover. Knows how to flip arrays or omelet or arrays of omelet.