Consuming API data

Starting point

Like everyone who has to deal with API data consumption, I've been wondering what the best solution is for switching from JSON to a strongly typed model and taking full advantage of TypeScript's static typing.

I quickly came across the Model-Adapter pattern and was initially inspired by the article by Florimond Manca, “Consuming APIs in Angular: The Model-Adapter Pattern”.

I've transcribed some of the code examples he proposes here, but I strongly recommend that you refer to his article for an overview.

An Adapter is a class that implements a simple method whose function is to instantiate an object of type T from JSON data received from the API.

export interface Adapter<T> {
  adapt(item: any): T;
}

In this example, we imagine a database containing a “courses” table with some columns [id, code, name, created].

When the API is queried by the front end, it interrogates the database and returns a response, in this case in JSON format, for example:

[
    {
        "id": 587,
        "code": "CX20240923",
        "name": "Consuming API data",
        "created": "2024-09-23"
	}
]

Front end side, we want to be able to use a Course object. We therefore declare a Course class and a CourseAdapter that implements the adapt method, which instantiates a Course object using the values received from the API.

import { Injectable } from "@angular/core";
import { Adapter } from "./adapter";

export class Course {
  constructor(
    public id: number,
    public code: string,
    public name: string,
    public created: Date
  ) {}
}

@Injectable({
  providedIn: "root",
})
export class CourseAdapter implements Adapter<Course> {
  adapt(item: any): Course {
    return new Course(item.id, item.code, item.name, new Date(item.created));
  }
}

From here, we can set up a service (in this case under Angular) into which the CourseAdapter will be injected.

export class CourseService {
  constructor(private http: HttpClient, private adapter: CourseAdapter) {}

  list(): Observable<Course[]> {
    return this.http.get("some api url").pipe(
      // Adapt each item in the raw data array
      map((data: any[]) => data.map((item) => this.adapter.adapt(item)))
    );
  }
}

Florimond deliberately dodges a number of problems that will quickly arise when we want to move on to real-life implementation. That's to be expected, it's impossible to describe every case, otherwise this is no longer an article, it's a course 😁.

Nevertheless, it's an excellent starting point.

One step further

That said, as soon as you have to manage a large number of classes to carry data from the API, it quickly becomes cumbersome to implement so many adapters and services. We'd like to be able to instantiate a class directly from JSON in a purely generic way.

Angular's HttpClient can retrieve data by specifying a type (here MyClass):

httpClient.get<MyClass>(url);

But here the result value is just an Object as MyClass, not an instance of MyClass. This is the same as writing :

httpClient
  .get(url)
  .pipe(map((data: any) => data as MyClass));
}

The TypeScript documentation on generic types gives directions for implementing a “universal adapter”.

Let's take a look at an example.

Let's say the API provides me with a JSON describing a Token object, and I've declared a Token class to carry this object. Unlike the Florimond example, here the class doesn't have a constructor, but we could as well use one, depending on what we want to do next, so you'll have to do your own testing.

export class Token {
    id: string = "";
    ip: string = "";
    creation: Date = new Date();
    lastAccess: Date | null = null;
}

Next, simply write a generic method that takes a class model and the JSON dataset as arguments. This will parse the class model's existing key/value pairs (which is why each property must be initialized with a default value), search for this key in the JSON dataset and, if found, assign its value.

createInstance<A>(c: new () => A, data: any): A {
    const result = new c();
    // get model object properties key/value pairs
    const kvps = Object.entries(result as any);
    for (let index = 0; index < kvps.length; index++) {
        const key = kvps[index][0];
        if (data[key]) {
            (result as any)[key] = data[key];
        } else {
            // not nullable properties are required
            if (data[key] !== null) {
	            console.log(`Key not found in api data : ${key}`);
            }
        }
    }
    return result;
}

In passing, we see that it is possible to check that the data supplied by the API corresponds to that expected by the front end. If a property is nullable, it has “null” as its default value and is therefore optional. It corresponds to a nullable column in the database, a non-mandatory field. So it's not necessarily abnormal not to receive it from the API.

This is a very simple and reasonably robust method for parsing data received from the API. Its use is elementary, since the function returns an instance of any class passed as an argument.

const tokenItem = createInstance(Token, apiData);

So few code to write, even if you have dozens of classes to manage.

The next level

One case that has not been taken into account is where the property is indeed of the standard type, but consists of a array, as when retrieving the result of a database view.

Even more complex, until now we've only considered the case of properties corresponding to standard javascript types (string, number, boolean, date). This is what you'll be dealing with most often when retrieving data from a database. But the API can also provide specific types.

If I have an Point type:

export class Point {
    x: number = 0;
    y: number = 0;
}

I might want to retrieve a Segment object via the :

export class Segment {
    uuid: string = "";
	startPoint: Point = new Point();
    endPoint: Point = new Point();
}

JSON arrives as:

{
    "uuid": "ht78f55",
    "startPoint": {
        "x": 0,
        "y": 0
    },
    "endPoint": {
        "x": 42,
        "y": 42
    }
}

createInstance cannot instantiate a property of type Point by (result as any)[key] = data[key];. We'll have to figure out how to handle this specific type.

And of course, to make matters worse, we could be dealing with an array of Point 😁.

And it won't be long before we're dealing with specific nested types.

export class Reticle {
	horizontalSegment: Segment = new Segment();
    verticalSegment: Segment = new Segment();
}

With a JSON as:

{
    "horizontalSegment": {
    	"uuid": "ht78f55",
        "startPoint": {
            "x": 0,
            "y": 0
        },
        "endPoint": {
            "x": 42,
            "y": 42
        }
    },
    "verticalSegment": {
    	"uuid": "815ddew",
        "startPoint": {
            "x": 0,
            "y": 0
        },
        "endPoint": {
            "x": 42,
            "y": 42
        }
    }
}

A Cliffhanger ending for a potential upcoming article 👉 Thanks for checking by && stay tuned !