DEV Community

Zaki Mohammed
Zaki Mohammed

Posted on • Originally published at zakimohammed.Medium on

NgMarvel app using Angular and Marvel API

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.

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,
  5. ng-marvel-app.netlify.app
  6. localhost
  7. For local Angular development also register the localhost.
  8. Make use of http(s)://gateway.marvel.com/ endpoint for following entities:
  9. Comics
  10. Comic Series
  11. Comic Stories
  12. Comic Events
  13. Creators
  14. Characters
  15. Use the public key to hit the endpoint and get responses for the above entities.
  16. 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](https://gateway.marvel.com:443/v1/public/characters?apikey=YOUR_PUBLIC_KEY)
Enter fullscreen mode Exit fullscreen mode

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

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

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

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](http://twitter.com/fortawesome/angular-fontawesome)';

[@NgModule](http://twitter.com/NgModule)({
  declarations: [...],
  imports: [
    ...
    InfiniteScrollModule,
    FontAwesomeModule
  ],
  providers: [],
  bootstrap: [AppComponent],
  schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class AppModule { }
Enter fullscreen mode Exit fullscreen mode

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/'](https://gateway.marvel.com:443/v1/public/'),
  apiKey: 'YOUR_PUBLIC_KEY'
};
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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/](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](http://i.annihil.us/u/prod/marvel/i/mg/3/20/5232158de5b16/standard_fantastic.jpg)
Enter fullscreen mode Exit fullscreen mode

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');
    }
  }));
}
Enter fullscreen mode Exit fullscreen mode

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]);
}
Enter fullscreen mode Exit fullscreen mode

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}`);
}
Enter fullscreen mode Exit fullscreen mode

Example:

[https://gateway.marvel.com/v1/public/characters?apikey=YOUR\_PUBLIC\_KEY&limit=50&offset=0](https://gateway.marvel.com/v1/public/characters?apikey=YOUR_PUBLIC_KEY&limit=50&offset=0)
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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

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

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

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

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](http://twitter.com/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](http://twitter.com/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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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

searchText$ = new Subject();
scroll$ = new Subject();
Enter fullscreen mode Exit fullscreen mode

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

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

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();
  }
}
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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](http://twitter.com/angular/core)';
import { faSearch } from '[@fortawesome/free-solid-svg-icons](http://twitter.com/fortawesome/free-solid-svg-icons)';

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

faSearch = faSearch;

  [@Input](http://twitter.com/Input)() title: string = '';
  [@Output](http://twitter.com/Output)() searchEvent = new EventEmitter();

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

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.

DISCLAIMER: This article and the application is about the implementation of Angular application using Marvel Comics API as the data source. The data used in the application belongs to Marvel and used only for learning and exploring the concepts of Angular and about Marvel Comics API. This application is not used as a commercial product.

Application

Download Code

Git Repository

Summary

Learning with your favorite childhood superhero is fun, as it gives you pleasure for what you are constructing will bring the joy of your childhood memories. Exploring Marvel Comics API and developing a UI app around it was great fun to me and would definitely recommend to the fellow developers out there to at least give it a try. It is not only a fun activity; one can use many different technologies to improve their app functionality in order to build a user-happy application which leads to improving their skills and understanding of new concepts. Hulk Smash!

Hope this article helps.

Originally published at https://codeomelet.com.

Top comments (0)